πŸ§…TetCTF 2024

My writeups, aimed to be more descriptive for beginners.

Reverse

BabyASM (92 solves)

Description:

  • Author: zx

  • Can you unlock it?

  • Server: http://103.3.61.46/TetCTF2024-babyasm/babyasm.html

Solution:

The babyasm.html file checks if the input is a total of 27 characters and fits the flag format TetCTF{...}, then passes the last 20 characters Including '}' into the wasm function.

When looking at the console log, it can be seen that there is an error when trying to run the file in firefox. Same when trying to use command line tools to decompile it. The solution is to use a chrome-based browser.

The picture above shows how debugging web assembly looks like. The main file and wasm can be seen by clicking babyasm.html and 8fa74aba. A breakpoint can be added by clicking the address to the left of the code, and when the breakpoint is hit, it can either be passed by clicking the blue play button in the top left or clicking the step button to run the following lines. The Scope section to the left allows the user to see all declared variables and the stack, which is useful to follow what's going on in the code.

With the above knowledge, the goal is to simply follow along and reverse engineer what's happening to the input. I started with aaaa... as an input and then abcd... as an input to come up with the following:

g = [96, 101, 20, 177, 155, 116, 108, 69, 84, 109, 103, 110, 111, 95, 116, 103, 97, 72, 20, 59]
t = [38793, 584, 738, 38594, 63809, 647, 833, 63602, 47526, 494, 663, 47333, 67041, 641, 791, 66734, 35553, 561, 673, 35306]

# Plugging in abcd... (97-100 + 83 = 180-183)
2: ((181 + (180 + 96)) ^ 32) + 83 = 572
3: ((182 + (572 - 101)) ^ 36) + 83 = 764
4: ((183 + (764 * 20)) ^ 19) + 83 = 15559
1: ((180 + (15559 ^ 177)) ^ 55) + 83 = 15728
-----
6: ((185 + (180 + 155)) ^ 32) + 83 = 630
...

# Resulting equations
((b+(a+g[0])^32)+83 = t[1]
((c+(t[1]-g[1])^36)+83 = t[2]
((d+(t[2]*g[2])^19)+83 = t[3]
((a+(t[3]^g[3])^55)+83 = t[0]

((f+(e+g[4])^32)+83 = t[5]
((g+(t[5]-g[5])^36)+83 = t[6]
((h+(t[6]*g[6])^19)+83 = t[7]
((e+(t[7]^g[7])^55)+83 = t[4]
...

There is a global set of constants (g), a target (t), and 20 character inputs. 83 is added to every input and the results are calculated in the order 2, 3, 4, 1. We get a system of equations, four unknowns and four equations and just need to solve five sets of four equations to get the flag.

I briefly tried a math solver approach before running into issues with the way xor is handled, before I gave up and just went with a brute force approach which is fine since the search space is very limited (instant).

#g = [96, 101, 20, 177, 155, 116, 108, 69, 84, 109, 103, 110, 111, 95, 116, 103, 97, 72, 20, 59]
g = [115, 82, 52, 149, 136, 67, 76, 97, 71, 90, 71, 74, 124, 104, 84, 67, 114, 127, 52, 31]
t = [38793, 584, 738, 38594, 63809, 647, 833, 63602, 47526, 494, 663, 47333, 67041, 641, 791, 66734, 35553, 561, 673, 35306]

def brute_force_solve(g, t, o):
    for a in range(32+83, 127+83):
        for b in range(32+83, 127+83):
            eq1 = ((b + (a + g[0+4*o])) ^ 32) + 83
            if eq1 == t[1+4*o]:
                for c in range(32+83, 127+83):
                    eq2 = ((c + (eq1 - g[1+4*o])) ^ 36) + 83
                    if eq2 == t[2+4*o]:
                        for d in range(32+83, 127+83):
                            eq3 = ((d + (eq2 * g[2+4*o])) ^ 19) + 83
                            eq4 = ((a + (eq3 ^ g[3+4*o])) ^ 55) + 83
                            if eq3 == t[3+4*o] and eq4 == t[0+4*o]:
                                return a, b, c, d
    return None, None, None, None

for o in range(0,5):
    a, b, c, d = brute_force_solve(g, t, o)
    print(chr(a-83), chr(b-83), chr(c-83), chr(d-83), sep="", end="")

At first I used the global array from the decompiled wasm, but couldn't find a solution. I didn't go through to see what the mechanism is, but it seems the global array alternates between two different sets which is why the first line is commented. I initially thought the global array was being "corrupted" mistakenly, and would just run a dummy submission after every test to "fix" it, so I did get stuck before realizing. Running the above code gives us the flag: TetCTF{WebAss3mblyMystique}

Crypto

Flip (87 solves) and Flip v2 (13 solves)

Description:

  • Author: ndh

  • flip

    • You are allowed to inject a software fault.

    • Server: nc 139.162.24.230 31339

  • flip v2

    • Changing in main() is not allowed.

    • Server: nc 139.162.24.230 31340

Solution:

The way main.py works is it loads the "encrypt" binary into memory and allows the user to modify the plaintext as well as flip a specific bit in the binary.

// encrypt.c
#include "tiny-AES-c/aes.h"
#include <unistd.h>

uint8_t plaintext[16] = {0x20, 0x24};
uint8_t key[16] = {0x20, 0x24};

int main() {
    struct AES_ctx ctx;
    AES_init_ctx(&ctx, key);
    AES_ECB_encrypt(&ctx, plaintext);
    write(STDOUT_FILENO, plaintext, 16);
    return 0;
}
# main.py excerpt

# Please ensure that you solved the challenge properly at the local.
# If things do not run smoothly, you generally won't be allowed to make another attempt.
from secret.network_util import check_client, ban_client

import sys
import os
import subprocess
import tempfile

OFFSET_PLAINTEXT = 0x4010
OFFSET_KEY = 0x4020

def main():
    if not check_client():
        return

    key = os.urandom(16)
    with open("encrypt", "rb") as f:
        content = bytearray(f.read())

    # input format: hex(plaintext) i j
    try:
        plaintext_hex, i_str, j_str = input().split()
        pt = bytes.fromhex(plaintext_hex)
        assert len(pt) == 16
        i = int(i_str)
        assert 0 <= i < len(content)
        j = int(j_str)
        assert 0 <= j < 8
    except Exception as err:
        print(err, file=sys.stderr)
        ban_client()
        return

    # update key, plaintext, and inject the fault
    content[OFFSET_KEY:OFFSET_KEY + 16] = key
    content[OFFSET_PLAINTEXT:OFFSET_PLAINTEXT + 16] = pt
    content[i] ^= (1 << j)
...

The offsets at the top of main.py correspond to the location in memory for plaintext and key in the binary. When reading the input from the user, the pt is checked to be 16 bytes, i needs to be within the bound of the binary (21032 bytes), and j needs to select a bit from 0-7. The goal is to determine what plaintext and bit to flip to be able to determine the randomized key based on a single program output.

When I went to brute force what would happen to the binary when I flipped each bit and to check if perhaps it may just output the key directly, I ended up solving both flip and flip v2. I'm not entirely sure why there not more solves for this since I spent way longer on the other challenges. The following is the full script I used to brute force each bit flip output.


import sys
import os
import subprocess
import tempfile
import binascii

OFFSET_PLAINTEXT = 0x4010
OFFSET_KEY = 0x4020
pt = bytes.fromhex("00000000000000000000000000000000")

def main():
    key = os.urandom(16)
   
    for i in range(21032):
        print(i)
        for j in range(8):
            with open("encrypt", "rb") as f:
                content = bytearray(f.read())

            # update key, plaintext, and inject the fault
            content[OFFSET_KEY:OFFSET_KEY + 16] = key
            content[OFFSET_PLAINTEXT:OFFSET_PLAINTEXT + 16] = pt
            content[i] ^= (1 << j)

            tmpfile = tempfile.NamedTemporaryFile(delete=True)
            with open(tmpfile.name, "wb") as f:
                f.write(content)
            os.chmod(tmpfile.name, 0o775)
            tmpfile.file.close()

            # execute the modified binary
            try:
                ciphertext = subprocess.check_output(tmpfile.name, timeout=0.001)
                if binascii.hexlify(ciphertext) == binascii.hexlify(key):
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
                    print("Match found!", binascii.hexlify(ciphertext), binascii.hexlify(key), i, j)
            except:
                pass
main()

Because this checks around 100 byte positions every couple seconds, I added many print statements so I would notice when a match was found. The timeout could probably be even lower to solve it quicker, but this solves both challenges in a minute or two. The first three solutions it finds are (byte=4545, bit=4), (byte=4551, bit=5), and (byte=5463, bit=1). I cancelled it soon after finding the third solution so there may be more.

Now the hard part: testing locally to make sure we don't get banned. Refer to the main.py snippet at the top, any exception hit causes ban_client(), which would be a shame after solving the challenge. Here are the docker commands to build and test locally.

docker build -t flip .
docker run -p 31339:31339 --name flip flip

# Also some useful commands for cleanup
docker ps --all    # List all containers
docker stop flip   # Stop the named container
docker rm flip     # Delete the named container
docker image ls    # List all downloaded images
docker rmi flip    # Delete the named image
docker rmi <IMGID> # Delete those unnamed images

You can then nc to localhost 31339, and input the pt (16 bytes of 0s) and solution (e.g. 4545 4). For flip v1, any of the solutions will work, so test locally first before testing at the remote to get the flag.

TetCTF{fr0m_0n3_b1t_fl1pp3d_t0_full_k3y_r3c0v3ry}

The only difference with flip v2 is that you can't flip a bit in main.

OFFSET_MAIN_START = 0x1169 # 4457
OFFSET_MAIN_END = 0x11ed # 4589

That means we can't use one of the first two solutions we found. Good thing we found more than just those.

TetCTF{fr0m_0n3_b1t_fl1pp3d_t0_full_k3y_r3c0v3ry_d043a7ff4cf6285a}

Easiest points of my life. Due to weird point scaling, this was worth 10x the other 3 challenges I solved (which were worth the same 100 points as the Welcome challenge πŸ˜†).

Misc

TET & 4N6 (52 solves)

Description:

  • Author: Stirring

  • Tet is coming, TetCTF is coming again. Like every year, I continued to register to play CTF, read the rules to prepare for the competition. After reading the rules, my computer seemed unusual, it seemed like it was infected with malicious code somewhere. Can you find out?

  1. Find the malicious code and tell me the IP and Port C2

  2. What was the first flag you found?

  3. After registering an account, I no longer remember anything about my account. Can you help me find and get the second flag?

Format : TetCTF{IP:Port_Flag1_Flag2}

Ex: TetCTF{1.1.1.1:1234_Hello_HappyForensics}

Solution:

The hardest 100 points welcome-equivalent solve of my life.

We're given a raw dump (TETCTF-2024-20240126-203010.raw) that's 5.4 GB (!!!), and a Backup.ad1 file that's 222 MB and need to find the malicious code and then something about their account.

Some grep-fu:

# Useful grep flags:
-r: Recursive match
-i: Match case insenitive
-n: Print line number for match
-a: Print match even for binaries
-o: Print only the match
-C <n>: Provide n lines of context above/below match
-E (or egrep): Regex matching

We can combine this with file redirection to get binary snippets put in a file to explore with your method of choice (vim/hex editor/strings/cat/etc.). An example: grep -ia -C 10 tetctf TET*.raw > tetctfmatch.bin. We can then strings tetctfmatch.bin | less to get a quick idea if we want to explore further, and can open the file to see all the hex or cat it to a file to strip out all the non-printable binary characters (since sometimes text is obfuscated with nulls in between each character so it won't be listed in strings).

After an hour or two of this, it'll be clear that the raw file captures the process of accessing and registering at the tetctf website, doing google searches, and various accesses to pastebin and downloads. There's a dummy pastebin link scattered in the binary that is just a placeholder flag, and another pastebin link that is locked. However, with enough analysis of how web traffic is saved in the binary, it's clear that pastebin contents are saved with the following postfix: - Pastebin.com

There are many pastebins in the raw file (seemingly just from general web browsing), but that prefix always follows when it's in relation to a capture of the actual content on the page. Doing a grep for that will show many instances of the second part of the flag.

Flag 2: R3c0v3rry_34sy_R1ght? - Pastebin.com

Comparatively the first part of the challenge is a lot less straight-forward. There is a suspicious zip file that's listed many times in the raw file: https://www.file.io/GN6v/download/eKHCxsHdpFZc. This file is seen to be a zip file that was later extracted, and is related to the part in the description regarding being infected after reading the rules. Some other interesting snippets in the raw:

misc #3TetCTF2024-Rules.LNK
C:\Program Files\Microsoft Office\Root\Office16\WINWORD.EXE/nC:\Users\Stirring\Downloads\TetCTF2024-Rules.docx
# And a ton of red herrings such as google searches, ommitted

A rough idea can be gathered from this, where a malicious word document was downloaded and run. From here, there's no way around it but to go back to the ad1 file that seemed relatively useless with the same methods. Basically, the files in it can be extracted if you download FTK Imager (link: https://www.exterro.com/ftk-imager). They ask for a ton of info for the download (which isn't verified aside from email format), and it's only compatible with Windows, two things that stopped me from doing this for a few hours.

Once you install it, you can add the .ad1 and then right click one of the top level entries to extract all the files. I extracted them to a shared folder that I use between all my VMs.

At this point, things were a bit of a blur and I went in circles, (a red herring minikatz direction, various system logs, etc.), but knew the end goal was to find something related to a malicious word file. I eventually stumbled upon the Word template files, which included ./Roaming/Microsoft/Templates/normal/word/vbaProject.bin. I may have needed to unzip a doc to see it, don't remember for sure.

There are some IP addresses and ports in there which seems weird, and looking at the output of strings on the file, there is a suspicious base64 string: Vmxjd2VFNUhSa2RqUkZwVFZrWndTMVZ0ZUhkU1JsWlhWRmhvVldGNlZrbFdSM2hQVkd4R1ZVMUVhejA9. Putting it in cyberchef to base64 decode it, it seems like garbage, but don't give up.

We now have the flag!

TetCTF{172.20.25.15:4444_VBA-M4cR0_R3c0v3rry_34sy_R1ght?}

Last updated