Over the past week or so, I have been playing TISC, CSIT’s annual CTF. I managed to solve all 12 challenges in 11 days, coming in 1st.

TISC is an individual CTF hosted by CSIT that is open to all Singaporeans. The challenges roughly get progressively more difficult. You must solve each level to unlock the next challenge.

Nominative determinism at play?

Here are my write-ups for the challenges. The write-ups are split across 3 posts since they otherwise would be too long. The first post (this!) covers levels 1-5, the second post covers levels 6-9, and the final post covers levels 10-12. Since the challenges remain untagged on the competition platform, the following tags are my own.

The first level of TISC is an OSINT/Misc challenge. The challenge description gives us the fictitious target’s online username:

1
Recent breakthroughs have unveiled **Vivoxanderith's online persona: vi_vox223**.

As per standard procedure, we use Sherlock to enumerate social media accounts with that username. While Sherlock returns a few entries, all are false positives except for the target’s Instagram account. Viewing their stories, we see a story referencing a Discord bot with ID: 1258440262951370813 and another story mentioning users with the "D0PP3L64N63R" role unlocking hidden bot features. This is clearly hinting at adding the bot to a server and interacting with it while having the specific role. We can use the Discord Permission Calculator (throwback to TISC’23 Level 5) to generate a URL to authorize the bot to join a server with specific permissions.

Interacting with the bot. The first !help command is before adding the custom role to myself.


Using the !list and !download bot commands, we retrieve a .eml email file. Viewing this in Outlook, we see the message:

1
2
3
4
5
6
7
8
9
I trust this message reaches you securely. I am writing to provide an update on my current location. I am currently positioned close to the midpoint of the following IDs:

- 8c1e806a3ca19ff 
- 8c1e806a3c125ff 
- 8c1e806a3ca1bff 

My location is pinpointed with precision using Uber's cutting-edge geospatial technology, which employs shape-based location triangulation and partitions areas of the Earth into identifiable cells.

To initiate secure communication with me, please adhere to the discreet method we've established. Transmit the identified location's name through the secure communication channel accessible at https://www.linkedin.com/company/the-book-lighthouse

A quick google search reveals that the “Uber’s cutting-edge geospatial technology” is referencing Uber H3. Putting in the three IDs reveals the target’s location. I had to use Google Maps together with the street name to obtain the location name “Quercia secolare”.

The last step is finding the “secure communication channel”. In the provided LinkedIn profile, we find a post referencing a Telegram bot @TBL_DictioNaryBot. Sending the location name to the Telegram bot gives us our first flag: TISC{OS1N7_Cyb3r_InV35t1g4t0r_uAhf3n}

Language, Labyrinth and (Graphics)Magick

We are given the following challenge description:

1
2
3
4
5
6
Beyond Discord and Uber H3, seems like our enemies are super excited about AI and using it for image transformation. Your fellow agents have managed to gain access to their image transformation app. Is there anyyy chance we could find some vulnerabilities to identify the secrets they are hiding?

Any one of the following instances will work:
http://chals.tisc24.ctf.sg:36183/
http://chals.tisc24.ctf.sg:45018/
http://chals.tisc24.ctf.sg:51817/

Visiting the site, we are greeted with the following form.

We can upload a file and specify transformation instructions. After clicking Transform, we are presented with the transformed image and a hash.txt. Helpfully, this file tells us the command that was run in the backend. For example, when uploading tiny.jpg with no instructions, hash.txt reads: gm convert /tmp/96c17439d8c548b68d29026f1294edf0_tiny.jpg /tmp/96c17439d8c548b68d29026f1294edf0_tiny.jpg_output.png.

Playing around with the service, we quickly realize that there is a command injection vulnerability in the instructions parameter. I used this instruction to read /etc/passwd: convert image.png -set comment "$(cat /etc/passwd)" output.png. The contents of /etc/passwd end up in the comment field of the transformed image’s EXIF data. Interestingly, I realized that sending the same input, I sporadically got an error from the backend…

Our next step is to read the flag. After enumerating the directory contents, we realize that the flag is stored in /app/flag. However, viewing hash.txt, we find that command injection attempts with some variant of /app/flag were sanitized: gm convert /tmp/ad1c3c48f214419a9089a615298cd0b9_tiny.jpg -set comment "$(cat /app/hash_***.txt)" /tmp/ad1c3c48f214419a9089a615298cd0b9_tiny.jpg_output.png.

From the challenge title (referencing LLMs), description and the sporadic behaviour of the backend, I surmised that there was a LLM acting as a sanitizer before the command was executed. Given that LLMs don’t always return the same result for the same prompt (explaining the sporadic behaviour), it is pretty easy to bypass them. My attack strategy was to use a not-so-sus injection like cat /app/f* and run it until the LLM randomly fails to block it.

We can retrieve the flag from the transformed file’s EXIF data: TISC{h3re_1$_y0uR_pr0c3s5eD_im4g3_&m0Re}

At the start of the competition, this challenge only had a single instance. As the influx of competitors tried to access the instance, there was huge amounts of lag with the service. That was why I used a small image file tiny.jpg. In the end, the organizers added two other instances, alleviating the load.

Digging Up History

1
A disc image file was recovered from them! We have heard that they have a history of hiding sensitive data through file hosting sites...

We are provided with a Windows logical image file csitfanUPDATED0509.ad1. Opening the file in FTK Imager (the go-to tool for ad1 file analysis), we find that this is a Windows XP image. Exploring the file system, we see mypal in the csitfan1 user’s desktop. Mypal is a third-party browser for Windows XP. Given that it is in the user’s desktop, it is safe to assume that it is relevant to the challenge.

As is the theme with many forensics challenges, we continue by analyzing the user’s browser data. After a bit of digging, we find the user’s browser history in Documents and Settings/csitfan1/Local Settings/Application Data/Mypal68/Profiles/*/cache2/entries.

Extracting the folder for further analysis, we see that it contains many files. Some are images but many are of an unknown format. A good portion contain URLs. I wrote a Python script to parse the files and extract all the URLs with a regex.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os
from urllib.parse import urlparse
import re
def extract_urls(text):
    urls = re.findall(r'(?P<url>https?://[^\s]+)', text)
    return urls

def domain(x):
    return urlparse(x).netloc

urls = []
for entry in os.scandir("entries"):
    print(entry.path)
    with open(entry.path, "rb") as f:
        data = f.read()
        urls.extend(extract_urls(str(data)))

# print(urls)  # really long!

domains = set([domain(url) for url in urls])
print(domains)  # manageable

Since there were many URLs, I decided to look through the set of domains first. I noticed csitfan-chall.s3.amazonaws.com. Looking for the URLs with this domain, I found https://csitfan-chall.s3.amazonaws.com/flag.sus. This file contains the text: VElTQ3t0cnUzXzFudDNybjN0X2gxc3QwcjEzXzg0NDU2MzJwcTc4ZGZuM3N9. Decoding this base64 string gives us the flag: TISC{tru3_1nt3rn3t_h1st0r13_8445632pq78dfn3s}

AlligatorPay

1
2
3
Your task is to find a way to join this exclusive member tier within AlligatorPay and give us intel on future cyberattacks. AlligatorPay recently launched an online balance checker for their payment cards.

https://agpay.chals.tisc24.ctf.sg/

Visiting the URL, we are presented with a form to upload a card.

Upon uploading our card, the card at the bottom of the screen is updated with our details, including our balance. Looking at the page source, we find two interesting comments.

1
2
3
      <!-- banner advertisement for AGPay Exclusive Club promo for customers with exactly $313371337 balance -->
...
      <!-- Dev note: test card for agpay integration can be found at /testcard.agpay  -->

It looks like our goal is to upload a card with a balance of $313371337. Uploading the test card, we see that it has a balance of $12345678.

Looking at the page source, we realize that there is a client-side implementation of the card checking logic. The JavaScript function parseFile() parses our input file and extracts the card details (including balance) while performing a series of checks. If the card is legitimate, it sends a POST request to the backend server with the card data. The backend likely uses similar checks as the client logic. So, we need to reverse the client logic and forge a card with the required balance.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
async function parseFile() {
	// [omitted] get file from form input
	const arrayBuffer = await file.arrayBuffer();
	const dataView = new DataView(arrayBuffer);

  // [1] Header check
	const signature = getString(dataView, 0, 5);
	if (signature !== "AGPAY") {
		alert("Invalid Card");
		return;
	}
	const version = getString(dataView, 5, 2);
	const encryptionKey = new Uint8Array(arrayBuffer.slice(7, 39));
	const reserved = new Uint8Array(arrayBuffer.slice(39, 49));

  // [2] Footer check
	const footerSignature = getString(dataView, arrayBuffer.byteLength - 22, 6);
	if (footerSignature !== "ENDAGP") {
		alert("Invalid Card");
		return;
	}
	const checksum = new Uint8Array(
		arrayBuffer.slice(arrayBuffer.byteLength - 16, arrayBuffer.byteLength)
	);

	const iv = new Uint8Array(arrayBuffer.slice(49, 65));
	const encryptedData = new Uint8Array(
		arrayBuffer.slice(65, arrayBuffer.byteLength - 22)
	);

  // [3] Checksum check
	const calculatedChecksum = hexToBytes(
		SparkMD5.ArrayBuffer.hash(new Uint8Array([...iv, ...encryptedData]))
	);

	if (!arrayEquals(calculatedChecksum, checksum)) {
		alert("Invalid Card");
		return;
	}

	const decryptedData = await decryptData(encryptedData, encryptionKey, iv);

	const cardNumber = getString(decryptedData, 0, 16);
	const cardExpiryDate = decryptedData.getUint32(20, false);
	const balance = decryptedData.getBigUint64(24, false);

	// [omitted] updates frontend with the parsed values
	if (balance == 313371337) {
		// [omitted] sends POST request to backend endpoint with cardData
	}
}
All comments were added by me. Irrelevant code was omitted for brevity.

This reveals the card structure:

1
2
3
4
5
6
7
8
9
Start  End   Description
0      5     AGPAY (header)
5      7     version (unused)
7      39    encryptionKey
39     49    reserved (unused)
49     65    iv
65     -22   encryptedData
-22    -16   ENDAGP (footer)
-16    -0    checksum

There are three main checks of the card structure. Firstly, the first 5 bytes must be “AGPAY”. Secondly, the 6 bytes starting from index -22 (the 22nd character from the end) must be “ENDAGP”. These two checks are easy to pass. The last check is the most important one. It checks that checksum == md5(iv || encryptedData), where || is concatenation. Critically, encryptedData contains the encrypted version of our payload (card number, expiry date and balance). The payload is decrypted with AES-CBC using iv (I omitted the decryptData() function body from the above snippet).

To forge a card with a specific balance, we just need to pass these checks in reverse order. We first define an iv and encrypt our desired payload (with the required balance). Then, we calculate the checksum. Finally, we append the headers and footers. Supplying a card with a balance of $313371337 gives us the flag: TISC{533_Y4_L4T3R_4LL1G4T0R_a8515a1f7004dbf7d5f704b7305cdc5d}.

The site’s soundtrack (volume warning) has no reason being this good.

Hardware isn’t that Hard!

This is the first of two hardware-related challenges in the CTF. The challenge description reads:

1
2
3
4
5
6
7
8
9
10
11
12
13
Shucks... it seems like our enemies are making their own silicon chips??!? They have decided to make their own source of trust, a TPM (Trusted Platform Module) or I guess their best attempt at it.

Your fellow agent smuggled one out for us to reverse engineer. Don't ask us how we did it, we just did it, it was hard ...

All we know so far is that their TPM connects to other devices using the i2c bus and does some security stuff inside. Agent! Your mission, should you choose to accept it, is to get us unparalleled intel by finding their TPM's weakness and exfiltrating its secrets.

You will be provided with the following compressed flash dump:
- MD5 (flash_dump.bin.xz) = fdff2dbda38f694111ad744061ca2f8a

Flash was dumped from the device using the command:
esptool.py -p /dev/REDACTED -b 921600 read_flash 0 0x400000 flash_dump.bin

You can perform your attack on a live TPM module via the i2c implant device hosted behind enemy lines: nc chals.tisc24.ctf.sg 61622

In this challenge, we are given a fake custom TPM’s flash dump (you don’t need to know what a TPM is; I personally didn’t see the link even after reversing). We are also told that we can interact with a live challenge instance via I2C to obtain the flag. At this stage, I had never used I2C before so I connected to remote to see what to expect.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# nc chals.tisc24.ctf.sg 61622
TISC 2024 :: I2C IMPLANT

Available commands:
- SEND <bytes>     - Sends hex encoded bytes to I2C bus
- RECV <num_bytes> - Receive num_bytes from I2C bus
- EXIT             - Exit the program

Example:
> SEND 12 34       - Sends 0x12, 0x34 to I2C bus, which will be received by the device at addr 0x09 as a WRITE request and payload 0x34
> RECV 4           - Attempts to receive 4 bytes from the I2C bus, if no slave device sends data, it will return 0s

Read More: https://en.wikipedia.org/wiki/I%C2%B2C#Reference_design

>

The remote server exposes a nice interface that allows us to communicate with the I2C bus with SEND and RECV commands. Doing some quick testing, I found that the RECV command seemed to always return null bytes.

1
2
3
4
> SEND 12 34
> RECV 4
00 00 00 00
>

Initially, I spent some time rev-ing the provided file but was making slow progress. I decided to play around with the remote server to try and better understand the system. We’ll take a look at the actual reversing in a bit.

Reading up on I2C, I found that it was a communication protocol common in embedded systems. In this case, the reference design allows master nodes to communicate with slave nodes. From the remote’s message, I guessed that the first byte after SEND was some sort of device identifier, with the actual payload being the remaining bytes. Playing around with the system, I realized that the SEND command accepted a maximum of 32 bytes.

1
2
3
> SEND 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12
> SEND 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12
Error: Too many bytes

At this point, my goal was to better understand the system (and SEND command structure). The obvious target was to be able to RECV some non-null data. With such a limited input size on only 32 bytes per SEND command, I realized that the input space was easily fuzz-able. I wrote a short Python script to fuzz the system, which quickly produced findings.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *
p = remote("chals.tisc24.ctf.sg", 61622)

def send(i):
    print(i)
    p.sendlineafter(b"> ", b"SEND " + i.encode("ascii"))

def recv(i):
    p.sendlineafter(b"> ", b"RECV " + str(i).encode("ascii"))
    res = p.recvline()
    for v in res.strip().split(b" "):
        if v != b'00':
            print(res)
            assert False

p.recvuntil(b"Read More:")

import random
for _ in range(100000):
    num_selections = random.randint(1, 25)
    random_selection = random.choices([f"{i:02x}" for i in range(256)], k=num_selections)
    payload = " ".join(random_selection)
    send(payload)
    recv(0x20)

p.interactive()

After 1827 inputs, I managed to get a response. We can pinpoint the offending inputs by bisecting the initial inputs. This narrowed it down to:

1
2
3
4
> SEND d2 46 7c d5 55 7c 74 8e
> SEND d3 8d 5b b8 d9 ec 6e 8c ab 7d 41 40
> RECV 20
42 52 59 58 63 6f 72 70 5f 43 72 61 70 54 50 4d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

I then produced a minimal reproduction with:

1
2
3
4
> SEND d2 46
> SEND d3
> RECV 20
d8 e3 e0 5c 80 74 05 5f 95 9f 0d 74 41 af 89 7a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

Great! We’ll revisit these findings subsequently. For now, that’s enough blackboxing. Let’s begin actually reversing the provided file.

1
2
3
4
5
6
7
8
9
10
11
12
13
➜  lvl5 file dump
dump: data
➜  lvl5 binwalk dump

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
66009         0x101D9         Unix path: /home/jiefeng/.arduino15/packages/esp32/hardware/esp32/2.0.14/tools/sdk/esp32/include/hal/esp32/include/hal/i2c_ll.h
103748        0x19544         SHA256 hash constants, little endian
1118860       0x11128C        Unix path: /dev/uart/0
1140460       0x1166EC        AES Inverse S-Box
1157124       0x11A804        Unix path: /home/fzb/share/proj_smartconfig/SSC/components/smartconfig/./sc_sniffer.c
1175056       0x11EE10        SHA256 hash constants, little endian
...

Clearly, this is an Arduino ESP32 firmware dump. We can analyze the image using Tenable’s esp32_image_parser tool and extract the app0 partition (where the interesting code logic likely lives) as an ELF.

1
2
3
4
➜  lvl5 python3 esp32_image_parser.py create_elf -partition app0 -output app0 ../dump
Dumping partition 'app0' to app0_out.bin

Writing ELF to app0...

I ran into many weird issues trying to get esp32_image_parser to work. This patch on Github was what worked in the end.

Analyzing the binary in Ghidra, we find i2c_recv, which presumably contains the logic that handled our SEND payloads from earlier. At a high level, it defines the logic for handling three different commands. Here is the cleaned up decompilation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void i2c_recv(uint num_bytes)
{
    uint command;
    int in_WindowStart;
    undefined auStack_30[12];
    unsigned char flag_char;

    memw();
    memw();
    uint uStack_24 = _DAT_3ffc20ec;
    does_print(0x3ffc1ecc, s_i2c_recv_ % d_byte(s): _3f400163, num_bytes);
    int iVar1 = (uint)(in_WindowStart == 0) * (int) auStack_30;
    int iVar2 = (uint)(in_WindowStart != 0) * (int)(auStack_30 + -(num_bytes + 0xf & 0xfffffff0));
    set_buf(0x3ffc1cdc, iVar1 + iVar2, num_bytes);
    does_printfs(iVar1 + iVar2, num_bytes);
    if (0 < (int) num_bytes) {
        command = (uint) * (byte * )(iVar1 + iVar2);
        if (command != 0x52) goto LAB_400d1689;
        memw();
        uRam3ffc1c80 = 0;
    }
    while (true) {
        command = uStack_24;
        num_bytes = _DAT_3ffc20ec;
        memw();
        memw();
        if (uStack_24 == _DAT_3ffc20ec) break;
        func_0x40082818();
        LAB_400d1689:
            if (command == 0x46) {
                int i = 0;
                do {
                    memw();
                    flag_char = ( & flag_start)[i];
                    unsigned char key = transform_key();
                    memw();
                    *(byte * )(i + 0x3ffc1c80) = flag_char ^ key;
                    i = i + 1;
                } while (i != 0x10);
            }
        else if (command == 0x4d) {
            memw();
            uRam3ffc1c80 = DAT_3ffbdb7a;
            memw();
        } else if ((num_bytes != 1) && (command == 0x43)) {
            memw();
            flag_char = * (byte * )( * (byte * )(iVar1 + iVar2 + 1) + 0x3ffbdb09);
            key = transform_key();
            memw();
            ( & DAT_3ffc1c1f)[ * (byte * )(iVar1 + iVar2 + 1)] = flag_char ^ key;
        }
    }
    return;
}

The interesting behaviour happens when command == 0x46. The program encrypts the flag (a fake flag is given in the provided firmware) byte by byte. It iterates over 0x10 bytes of the flag, XOR-ing each byte with a key value, which is obtained from transform_key().

1
2
3
4
5
6
7
ushort transform_key(void)
{
    ushort uVar1 = key_val << 7 ^ key_val;
    uVar1 = uVar1 >> 9 ^ uVar1;
    key_val = uVar1 << 8 ^ uVar1;
    return key_val;
}

key_val is a two-byte global variable, which gets manipulated in transform_key(), ensuring that each byte of the flag is XOR-ed with a different value. However, the key in each iteration is entirely dependent on the previous key.

Revisiting the fuzzer’s finding earlier, we begin to make sense of it.

1
2
3
4
5
6
7
8
9
// d2 is probably some device ID, and 46 is the command to encrypt the flag
> SEND d2 46

// this probably flushes the encrypted flag to an output stream
> SEND d3

// we receive the 0x10 encrypted flag bytes
> RECV 20
d8 e3 e0 5c 80 74 05 5f 95 9f 0d 74 41 af 89 7a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

So, we can obtain (what seems to be) the encrypted flag. You can play around with the other two commands to verify this. Since the real flag starts with TISC{, we can retrieve the initial key by brute-forcing until we find a sequence of keys that XOR with TISC{ to produce the encrypted flag’s first 5 bytes. The brute-force is feasible since the search space is only 2 bytes. Instead of brute-forcing, I opted to use z3 to solve the equations instead. Here is the script I used to retrieve the initial key.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pwn import *
p = remote("chals.tisc24.ctf.sg", 61622)

def send(i):
    p.sendlineafter(b"> ", b"SEND " + i.encode("ascii"))

def recv(i):
    p.sendlineafter(b"> ", b"RECV " + str(i).encode("ascii"))
    res = p.recvline()
    return res.strip().split(b" ")
    
p.recvuntil(b"Read More:")
send("d2 46")
send("d3")
leak = [int(x.decode("ascii"), 16) for x in recv(0x10)]

from z3 import *
solver = Solver()
initial_key = BitVec('intial_key', 16)
start = 'TISC'
key = initial_key
for i in range(len(start)):
    uVar1 = key << 7 ^ key
    uVar1 = LShR(uVar1, 9) ^ uVar1  # remember to use LShR and not >>
    key = (uVar1 << 8) ^ uVar1
    solver.add((key ^ ord(start[i])) % 0x100 == leak[i])

assert solver.check() == sat  # else, no solution found
model = solver.model()
print(f"Solution found: initial_key = {model[initial_key]}")
p.interactive()

We can then plug the initial key back into the original program together with the encrypted flag, which will undo the XORs, giving us the flag: TISC{hwfuninnit}