Writeups for all misc challenges and soft release of my new AI toolbox project (autonomous CTF solver)
Misc
γ‘γ¬A (110 solves)
Description:
Join the 16-bit revolution! Can you help Sonk the Rabbit find all flags? Submit your recording to get the flag: python3 submit.py solution.inp mega.2025.ctfcompetition.com 1337
Note: Submit the flag starting with "CTF{A:" here. For submitting the other flag, see the challenge titled "γ‘γ¬B".
Note 2: To make the γ‘γ¬ challenges less annoying for those who already solved it locally, the ability to get the flag from the server may depend on mame version you installed. This wasn't obvious and we apologise. The version we run at the server is 0.277+dfsg.1-0ubuntu1~ppa3~noble1 and it comes from ppa:c.falco/mame.
Unzipping the challenge, we see a misc-mega-rust-1 folder with a bunch of rust game files and a Sega Mega Drive ROM image (sonk.md). Below is the README.md for convenience.
# Mega Sonk in MegaRustTo run the game:```mame genesis -cart sonk.md```Record your solution and send it up to the server to get the flag! Make sure your recording is at most 5 minutes long.```mame genesis -cart sonk.md -record solution.inppython3 submit.py /home/user/.mame/inp/solution.inp mega.2025.ctfcompetition.com 1337```To rebuild the game:```make sonk.md```On the first run this builds the compilation toolchain which could take up to half an hour.Rebuild and run the game:``` make run```## CreditsCompilation toolchain based on* https://github.com/ricky26/rust-mega-drive* https://github.com/rust-lang/rustc_codegen_gccGraphics credits:* https://opengameart.org/content/dashie-supertux-advance-style* https://opengameart.org/content/plastic-shamtastic* https://opengameart.org/content/wasp-0
Running the game following the instructions, we can quickly explore the entire map and see the two flags.
Flag A
Flag B
We can't walk into the spikes for flag A and we can't jump high enough to hit flag b. The only enemies are wasps which knock us back if we hit them but we don't take damage (similar to hitting spikes). We can build up a lot of speed and jump pretty far when jumping off a hill. Finally, we can go off screen slightly on the left and right.
Early theories were that we could do something off screen or building up speed and hitting hills/enemies/spikes could cause glitches. We also thought maybe the flag collision area might shift over time. We are given all the tools needed to rebuild the game so we can modify it to give us some debugging information.
Here are all the mods I added in the game.rs draw function (right after the first if statement):
This allows us to see everything that could be significant for the challenge. We can see absolute X position (a vertical line every 500 pixels), x/y position of collision boxes for the flags and the player (rendered with a vertical and horizontal line), an x/y position indicator (with flags to represent digits because numbers can't be rendered on the map and there's no terminal), a speed indicator, and a few other things. The below two images show some examples.
X/Y relative position indicators (left), horizontal position of player (green)
horizontal position of flag (white), vertical marker for 500 feet (white)
Checking flag position (left vertical line) when we're hit off screen
These mods let us learn a few interesting things like when we're off screen, we quickly hit an invisible wall, but we still build up "speed" when we move against it. Also when we're hit by a wasp off screen downhill, our y position stays at the last position on screen until we move. We also can see if the flag collision our or collision box ever gets moved somehow.
This represents all the information that we can learn by rebuilding the game. Does any of this help? Sadly no.
Flag A is very simple, we just need to be hit offscreen into the spikes. There's a wasp suspiciously close to the right of the spikes with the flag, if we move ourself to the left of the screen and let ourselves get hit, we can go through the spikes that haven't spawned and get it. π€·ββοΈ
γ‘γ¬B (87 solves)
Flag B ended up being nothing related to being off screen, velocity, collision boxes, etc., but rather the spawn mechanism of the wasp.
When we first see the wasp spawn coordinate on screen, it will spawn at that time, but it doesn't despawn any wasps that were already existing, meaning we can walk back and forth with the right side of the window right at the spawn point to spawn multiple wasps at the same time. This is the only obvious mechanism that can be abused, and after a period of time trying to spawn as many as possible, we'll see that if the right side of the screen is exactly on the spawn point, it'll constantly spawn wasps until the game breaks.
Player is now above Flag B, can see their feet when moving side to side
The player will conveniently glitch up to the top of the screen when there are too many wasps. Then, just walking back and forth will eventually collect the flag. Following the instructions, we can generate recordings (needs to be the unmodded game) to submit both flags. A lot of "unnecessary" game mods were done but this helps through process of elimination to narrow down where to look for the solution.
Unzipping the challenge, we see the following docker image.
Basically, connecting to this challenge drops us into a shell where a BPF probe seems to kill any process that tries to read /flag.txt. The proof of work script is tuned to take a long time (at least 30 seconds to a minute on my machine) and it kicks you out after a minute, so building the challenge with the Dockerfile is probably required to reliably solve it. However I got lucky and solved it in a few minutes so I didn't have to. π
Testing random commands to see what happens, our command gets echo'd back to us if we try to directly access flag.txt. Thinking maybe the BPF probe only checks if the process is trying to access flag.txt as soon as it's created, I added a sleep before it and it worked.
Fishmaze (30 solves)
Description:
Escape the maze.
Solution:
There's no sources given, we just have the html on the launched instance.
Launched instance, we can write player kernel and if it passes a syntax check, see the run
The html code doesn't help us too much aside from the hint at the end. It basically renders our fish moving based on the player_kernel, which runs every tick. Whatever we do seems to just be repeated each time, so if we have it move one direction, we keep moving in that direction. We need to either be able to choose decision based on nearby objects (very hard/impossible to leave the maze on a generic ruleset) or figure out how to use the AUX_DATA as memory to store a sequence of memories.
The problem is random compilations either succeed or don't succeed and there's no debugging info provided on why compilations don't succeed. To solve this, we can use asserts to learn more about the system. The following script is used to determine what mapdata_ref actually is.
This does a binary search for each character of the type, so sees if it's upper/lower/digit/symbol, then narrows down on what it is with asserts one at a time. A failed compile means it's not that. Running this we find it is Traced<int32[]>with<DynamicJaxprTrace> and looking into that, it is JAX. Knowing this, we can build this simple proof of concept to change directions based on tick.
From here, it's simple iteration to build up the full solution. Count out the amount to move, run it, make adjustments, wait to avoid the enemies, and we win.
HWSIMv2 (27 solves)
Description:
Last year we were notified that our circuitry was backdoored, so now we formally verify our designs before they get sent to the factory. Implement an instruction decoder for our next-gen CPU!
Unzipping the challenge, we see the following challenge.
Basically we need to build the CPU and then run instructions to print the flag. A teammate (quasar) wrote the CPU so I don't fully understand that part, but after using AI to build a solve script, we have the final solution here.
Basically we automate solving the pow and then paste in the instructions, after 5 it will either verify or not. Then we send a payload to dump it. The following is the explanation of all this written by AI.
1. What the verifier is supposed to check
For every possible assignment of the 32 βbasic-inputβ bits
the solver proves that all required outputs satisfy the official formulas, the
most important of which is the security check:
If any model violates any equality, Z3 finds a counter-example and the menu
prints βFormal verification failed!β.
2. Two constants are enough to break the proof
We add five tiny gates (the first five lines of the new working2.txt):
security_exception is a hard-wired 0.
is_root_now is a hard-wired 1.
Now, for every possible input valuation,
so the equality holds identically. The solver happily reports
All the other outputs (is_load, is_store, β¦) are implemented exactly as
the organisersβ reference circuit, so their equalities hold too.
Key bug:
The challenge treats a wire name that appears in basic_inputs and in the
gate list as one single Boolean variable. By giving it a gate
definition we override the would-be input instead of adding a constraint
between two distinct signals.
3. Why it works at run-time even in user mode
During emulation the dispatcher calls
known initially contains only the real run-time value of is_root_now
( True in kernel mode, False otherwise ).
But because security_exception is not in that dict, the recursive NAND
evaluation walks the circuit and discovers that it is the constant 0, neverconsulting is_root_now at all.
So:
user code can execute load / store to any address,
or call putc, without ever tripping the check;
we do not need to stay in kernel mode or ret-leaf from a syscall.
4. The 105-byte flag-dump program
Each repetition does
for xx = 0x80 β¦ 0x99, followed by 0f (HALT).
Because security_exception can never go high, every LOAD from the kernel
half of RAM succeeds even though we remain in user mode, and every byte is
printed via PUTC.
5. Why the constant trick is undetectable by the verifier
Quantifiers β the solver checks the equalities for all possible
assignments, but our gates force one particular assignment
(is_root_now = 1, security_exception = 0), making every clause true.
No extra constraint β the organiser intended
βis_root_now is an independent input you may read, not driveβ.
By re-defining it as a gate output we silently removed that degree of
freedom, but the verifier never notices: it still has a single Boolean
is_root_now, only now equated to a NAND of two zeros.
6. Bottom line
The whole exploit is a textbook βdrive an inputβ hardware-Trojan:
Synthesize constants with three NANDs.
Wire security_exception to 0.
Wire is_root_now to 1 so the spec equation stays tautologically
true.
Keep every other output behaviourally identical, so the verifier has
nothing else to complain about.
Run any user program you like β the flag sits unprotected at 0x80+.
Thatβs why the menu shows βLooks good!β yet the remote chip obediently leaks
CTF{β¦} a few milliseconds later.
Crypto
numerology (218 solves)
Description:
I made a new cipher, can you help me test it? I'll give you the key, please use it to decrypt my ciphertext.
I wanted to see if my new automated AI solver could solve a GoogleCTF question, but unfortunately only using o3 on the backend will do it one-shot. Basically the solution can be found by decompiling the pyc (either pylingual.io or use pycdc on your computer, one of the few options available for python 3.12), and then passing it in to o3.
Using GPT 4.1 mini on the backend though, my automated solver was able to solve with a few (a lot) of nudges. Take a look at https://krauq.com and join the discord.
// Add position markers before final render
if self.win_scene.is_none() {
// Draw vertical lines every 500 pixels
let world_x_start = -self.map.scroll_x;
// Regular markers every 500 pixels
for i in 0..13 {
let marker_world_x = i * 500;
let marker_screen_x = marker_world_x + self.map.scroll_x + 128;
if marker_screen_x >= 64 && marker_screen_x <= 448 {
// Draw a thin vertical line using a sprite
let marker_sprite = Sprite {
size: SpriteSize::Size1x4,
x: marker_screen_x as u16,
y: 128,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::C), // Use spike tile
};
let _ = self.renderer.add_sprite(marker_sprite);
}
}
// Draw actual flag collision positions
for (i, flag) in self.flags.iter().enumerate() {
// flag.x and flag.y are the collision coordinates
// They start as world coords but get modified by camera
// Need to convert back to screen position
let flag_screen_x = flag.x;
let flag_screen_y = flag.y;
// Draw vertical line at flag X position
if flag_screen_x >= 64 && flag_screen_x <= 448 {
for y in 0..7 {
let flag_marker = Sprite {
size: SpriteSize::Size1x4,
x: flag_screen_x,
y: (128 + y * 32) as u16,
link: 0,
flags: if i == 0 {
TileFlags::for_tile(SPIKE_TILE_OFFSET + 1, Palette::B) // Flag A
} else {
TileFlags::for_tile(SPIKE_TILE_OFFSET + 2, Palette::D) // Flag B
},
};
let _ = self.renderer.add_sprite(flag_marker);
}
}
// Draw marker at the actual flag position
if flag_screen_x >= 64 && flag_screen_x <= 448 && flag_screen_y >= 64 && flag_screen_y <= 352 {
// Show exactly where the flag collision box is
let collision_marker = Sprite {
size: SpriteSize::Size2x1,
x: flag_screen_x,
y: flag_screen_y,
link: 0,
flags: TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::A),
};
let _ = self.renderer.add_sprite(collision_marker);
}
}
}
// Draw horizontal line showing Sonk's Y position
if self.win_scene.is_none() {
// Draw a horizontal line at Sonk's Y coordinate
let sonk_x = self.sonk.sprite.x;
let sonk_y = self.sonk.sprite.y;
for x in 0..10 {
let y_marker = Sprite {
size: SpriteSize::Size1x1,
x: (128 + x * 32) as u16,
y: sonk_y,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::A), // Red color
};
let _ = self.renderer.add_sprite(y_marker);
}
// Draw a vertical line at Sonk's X coordinate
for y in 0..7 {
let x_marker = Sprite {
size: SpriteSize::Size1x1,
x: sonk_x,
y: (128 + y * 32) as u16,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::A), // Red color
};
let _ = self.renderer.add_sprite(x_marker);
}
// Draw horizontal lines for flags
for flag in &self.flags {
if flag.y >= 64 && flag.y <= 352 {
for x in 0..10 {
let flag_y_marker = Sprite {
size: SpriteSize::Size1x1,
x: (128 + x * 32) as u16,
y: flag.y,
link: 0,
flags: TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::D), // Different color for flags
};
let _ = self.renderer.add_sprite(flag_y_marker);
}
}
}
// Draw indicators for important game state
// Show if Sonk is damaged (blinking indicator in top right)
if self.sonk.damaged {
let damage_indicator = Sprite {
size: SpriteSize::Size2x2,
x: 400,
y: 140,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::A), // Red spike to show damaged
};
let _ = self.renderer.add_sprite(damage_indicator);
}
// Show Sonk's speed (visual bars)
let speed_x_abs = self.sonk.speed_x.abs() as u16;
let speed_y_abs = self.sonk.speed_y.abs() as u16;
// Horizontal speed bar
for i in 0..(speed_x_abs / 10).min(12) {
let speed_marker = Sprite {
size: SpriteSize::Size1x1,
x: 380 + i * 8,
y: 180,
link: 0,
flags: if self.sonk.speed_x < 0 {
TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::C) // Blue for left
} else {
TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::B) // Green for right
},
};
let _ = self.renderer.add_sprite(speed_marker);
}
// Vertical speed bar
for i in 0..(speed_y_abs / 10).min(10) {
let speed_marker = Sprite {
size: SpriteSize::Size1x1,
x: 380 + i * 8,
y: 190,
link: 0,
flags: if self.sonk.speed_y < 0 {
TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::A) // Red for up
} else {
TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::D) // Purple for down
},
};
let _ = self.renderer.add_sprite(speed_marker);
}
// Show if Sonk is off-screen (important for the exploit!)
let mid_x = sonk_x as i16 + 16 - 128;
if mid_x < 0 || mid_x > 319 {
// Big warning indicator that we're off-screen
let offscreen_indicator = Sprite {
size: SpriteSize::Size4x4,
x: 350,
y: 200,
link: 0,
flags: TileFlags::for_tile(WASP_TILE_OFFSET, Palette::A), // Red wasp = danger/warning
};
let _ = self.renderer.add_sprite(offscreen_indicator);
}
// Calculate Sonk's world coordinates
let world_x = (sonk_x as i16 - 128 - self.map.scroll_x) as u16;
let world_y = (sonk_y as i16 - 128 - self.map.scroll_y) as u16;
// Draw X coordinate display (top left)
let x_thousands = (world_x / 1000) as u16;
let x_hundreds = ((world_x % 1000) / 100) as u16;
let x_tens = ((world_x % 100) / 10) as u16;
let x_ones = (world_x % 10) as u16;
// X coordinate markers
for i in 0..x_thousands.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 140,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::D), // Purple for thousands
};
let _ = self.renderer.add_sprite(marker);
}
for i in 0..x_hundreds.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 150,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::A), // Red for hundreds
};
let _ = self.renderer.add_sprite(marker);
}
for i in 0..x_tens.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 160,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::B), // Green for tens
};
let _ = self.renderer.add_sprite(marker);
}
for i in 0..x_ones.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 170,
link: 0,
flags: TileFlags::for_tile(SPIKE_TILE_OFFSET, Palette::C), // Blue for ones
};
let _ = self.renderer.add_sprite(marker);
}
// Draw Y coordinate display (below X)
let y_hundreds = (world_y / 100) as u16;
let y_tens = ((world_y % 100) / 10) as u16;
let y_ones = (world_y % 10) as u16;
// Y coordinate markers
for i in 0..y_hundreds.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 190,
link: 0,
flags: TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::A), // Red for hundreds
};
let _ = self.renderer.add_sprite(marker);
}
for i in 0..y_tens.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 200,
link: 0,
flags: TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::B), // Green for tens
};
let _ = self.renderer.add_sprite(marker);
}
for i in 0..y_ones.min(8) {
let marker = Sprite {
size: SpriteSize::Size1x1,
x: 140 + i * 8,
y: 210,
link: 0,
flags: TileFlags::for_tile(FLAG_TILE_OFFSET, Palette::C), // Blue for ones
};
let _ = self.renderer.add_sprite(marker);
}
}
from z3 import *
import string
import time
from flag import flag
import sys
spec = """
ISA:
- 4 8-bit registers
...
"""
def menu():
global gates
print("1. Print specification.")
print("2. Clear design.")
print("3. Add gate.")
print("4. Print design.")
print("5. Verify design.")
print("6. Quit.")
for i, c in enumerate(flag):
...
def get_and_run():
print("Ok. The CPU design was sent to the factory, and we got the chip now.")
print("Let's run some code!")
print("Example: 09020a480c0a690c0a210c0a200c0a540c0a680c0a650c0a200c0a740c0a690c0a6d0c0a650c0a200c0a690c0a730c0a200c0a780c0a780c0a3a0c0a780c0a780c0a3a0c0a780c09010c0b303b09020c0a0a0c09000c")
print("Input (hex-encoded) user code (up to 128 bytes):")
HALT = b"\x0f"
code = bytes.fromhex(input().strip())[:128]
code += HALT * (128 - len(code))
KERNEL_CODE = bytes.fromhex("0f0f 0b91 870bff37 0b92 870bff37 0b96 87 0f 0209010e 0609020e")
KERNEL_CODE += HALT * (128 - len(KERNEL_CODE))
code += KERNEL_CODE
run(code)
def main():
print("Implement the CPU decoder circuit!")
while not finished_circuit:
menu()
get_and_run()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Automated solver for the CPU decoder CTF challenge
Reads entire solution from submit.txt (including hex exploit)
"""
import socket
import subprocess
import time
import re
import sys
import os
import urllib.request
import tempfile
# Hardcoded connection details
HOST = "hwsim.2025.ctfcompetition.com"
PORT = 1337
def solve_pow(challenge):
"""Solve the proof-of-work challenge"""
print(f"[*] Solving PoW: {challenge}")
# Download and run the PoW solver
print("[*] Downloading PoW solver...")
response = urllib.request.urlopen('https://goo.gle/kctf-pow')
solver_code = response.read().decode('utf-8')
# Save to temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(solver_code)
temp_path = f.name
try:
# Run the solver - this takes about 20 seconds
print(f"[*] Running PoW solver (this takes ~20 seconds)...")
cmd = ['python3', temp_path, 'solve'] + challenge.split()
result = subprocess.run(cmd, capture_output=True, text=True)
# The solution is the last line that starts with 's.'
solution = None
for line in result.stdout.strip().split('\n'):
if line.startswith('s.') and len(line) > 50:
solution = line.strip()
if not solution:
print("[!] Failed to get PoW solution")
print("Output:", result.stdout)
print("Error:", result.stderr)
return None
print(f"[*] PoW solution found: {solution[:50]}...")
return solution
finally:
os.unlink(temp_path)
def read_working2():
"""Read the complete solution from submit.txt"""
if not os.path.exists('submit.txt'):
print("[!] Error: submit.txt not found!")
print("[!] Please create submit.txt with the circuit solution and hex exploit")
sys.exit(1)
with open('submit.txt', 'r') as f:
content = f.read()
print(f"[*] Read {len(content)} bytes from submit.txt")
return content
def recv_until(sock, pattern, timeout=30):
"""Receive data until pattern is found"""
buffer = b""
sock.settimeout(timeout)
start_time = time.time()
while True:
if time.time() - start_time > timeout:
raise TimeoutError("Timeout waiting for pattern")
try:
data = sock.recv(4096)
if not data:
break
buffer += data
# Try to decode and check for pattern
try:
decoded = buffer.decode('utf-8', errors='ignore')
if pattern in decoded:
return decoded
except:
pass
except socket.timeout:
continue
return buffer.decode('utf-8', errors='ignore')
def main():
print(f"[*] CPU Decoder CTF Solver")
print(f"[*] Target: {HOST}:{PORT}")
print("=" * 50)
# Connect to the server
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(60)
try:
print(f"[*] Connecting to {HOST}:{PORT}...")
sock.connect((HOST, PORT))
print("[*] Connected!")
# Read initial PoW challenge
print("[*] Waiting for PoW challenge...")
initial_data = recv_until(sock, "Solution?", timeout=10)
print(initial_data)
# Extract PoW challenge
match = re.search(r'solve\s+(s\.\w+\.\w+)', initial_data)
if not match:
print("[!] Could not find PoW challenge!")
return
pow_challenge = match.group(1)
# Solve PoW
solution = solve_pow(pow_challenge)
if not solution:
print("[!] Failed to solve PoW")
return
# Send solution
print(f"\n[*] Sending PoW solution...")
sock.send((solution + "\n").encode())
# Wait for the main program to start
print("[*] Waiting for main program to start...")
time.sleep(2)
# Read any response after PoW
sock.settimeout(2)
try:
response = sock.recv(4096).decode('utf-8', errors='ignore')
if response:
print(response)
except socket.timeout:
pass
# Read the entire solution from submit.txt
print("\n[*] Reading complete solution from submit.txt...")
complete_solution = read_working2()
# Send the entire content line by line
print("[*] Sending complete solution (circuit + exploit)...")
lines = complete_solution.strip().split('\n')
for i, line in enumerate(lines):
# Show progress for long inputs
if i % 10 == 0:
print(f"[*] Progress: {i}/{len(lines)} lines sent")
sock.send((line + "\n").encode())
# Small delay between lines to avoid overwhelming the server
time.sleep(0.001)
print(f"[*] All {len(lines)} lines sent!")
# Read all remaining output
print("\n[*] Reading output (waiting for flag)...")
print("=" * 50)
sock.settimeout(5)
output_buffer = ""
last_data_time = time.time()
while time.time() - last_data_time < 3: # Wait up to 3 seconds of silence
try:
data = sock.recv(4096).decode('utf-8', errors='ignore')
if data:
output_buffer += data
print(data, end='', flush=True)
last_data_time = time.time()
except socket.timeout:
continue
except:
break
print("\n" + "=" * 50)
except KeyboardInterrupt:
print("\n[!] Interrupted by user")
except Exception as e:
print(f"\n[!] Error: {e}")
import traceback
traceback.print_exc()
finally:
sock.close()
print("\n[*] Connection closed")
if __name__ == "__main__":
main()