π Forensics
Tesla
Challenge Description
The challenge provided a .sub file (Tesla.sub), which is a Sub-GHz signal file for a Flipper Zero. The file description hinted at it being a βBad Usb 0xfunβ.
Analysis
1. Examining the File
Opening Tesla.sub, we observed a RAW_Data field containing a long sequence of binary strings separated by spaces.
RAW_Data: 11111111 11111110 00100110 01000000 ...2. Decoding Binary to Text
Converting these binary octets to ASCII characters revealed an obfuscated Windows Batch script.
&@cls&@set "Ilc=pesbMUQl73oWnqD9rAvFRKZaf0hO5@dBN4uSzCtGjE YxITwXiVm1Jcgy26LkH8P"
%Ilc:~29,1Ilc:~54,1%...3. Deobfuscation
The script used variable substring replacement (e.g., %Ilc:~N,1%) to construct commands. By mapping these indices to the string pesbMUQl73oWnqD9rAvFRKZaf0hO5@dBN4uSzCtGjE YxITwXiVm1Jcgy26LkH8P, we reconstructed the original script:
@echo off
powershell -NoProfile -Command "[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('i could be something to this'))"
:: 5958051a1b170013520746265a0e51435b36165752470b7f03591d1b364b501608616e ::
:: ive been encrypted many in ways::
pause4. Decrypting the Flag
The script contained a suspicious hex string in the comments: 5958051a1b170013520746265a0e51435b36165752470b7f03591d1b364b501608616e.
The PowerShell command referenced the string 'i could be something to this'.
Suspecting an XOR cipher, we XORed the hex string with the key 'i could be something to this'.
hex_str = "5958051a1b170013520746265a0e51435b36165752470b7f03591d1b364b501608616e"
key_str = "i could be something to this"
# ... XOR logic ...
# Result: 0xfun{d30bfU5c473_x0r3d_w1th_k3y}Flag
0xfun{d30bfU5c473_x0r3d_w1th_k3y}
Nothing_Expected
Challenge Analysis
We are provided with a PNG file file.png.
Initial analysis using strings or binwalk reveals the presence of the string application/vnd.excalidraw+json, indicating that the PNG file contains embedded Excalidraw scene data. Excalidraw often saves scene data inside the PNG tEXt chunks.
Solution Steps
-
Extract Excalidraw Data: The
tEXtchunk contains a JSON object. Inside this JSON, there is anencodedfield which contains the actual scene data, compressed. -
Decompress Data: The
encodedstring is a sequence of bytes (latin-1 mapping) that needs to be decompressed using zlib. -
Reconstruct the Image: Parsing the decompressed JSON reveals a list of elements. There are
textelements (containing βshh, this is a secret!!β) and manyfreedrawelements. The flag is likely hidden within thesefreedrawstrokes, which are just arrays of coordinate points.Since the flag is drawn manually (freedraw) rather than typed as text, we cannot simply
grepfor it. We must reconstruct the image by plotting these points. -
Rendering Script: We wrote a Python script to parse the JSON and render the
freedrawelements using thePillowlibrary.import json import zlib from PIL import Image, ImageDraw def render_drawing(): try: with open("file.png", "rb") as f: data = f.read() # Find Excalidraw JSON keyword = b"application/vnd.excalidraw+json" idx = data.find(keyword) if idx == -1: print("Excalidraw data not found") return # Extract and parse outer JSON start = idx + len(keyword) + 1 # Simplified partial extraction for robustness chunk = data[start:] text = chunk.decode('latin-1') start_json = text.find('{') dec = json.JSONDecoder() obj, _ = dec.raw_decode(text[start_json:]) # Decompress inner data encoded_str = obj["encoded"] compressed = bytes([ord(c) for c in encoded_str]) decompressed = zlib.decompress(compressed) drawing_data = json.loads(decompressed) elements = drawing_data.get("elements", []) # Determine canvas bounds min_x, min_y = 10000, 10000 max_x, max_y = 0, 0 for el in elements: if el["type"] == "freedraw": x = el["x"] y = el["y"] min_x = min(min_x, x) min_y = min(min_y, y) # Estimate bounds (points are relative) for p in el.get("points", []): max_x = max(max_x, x + p[0]) max_y = max(max_y, y + p[1]) # Pad and create image width = int(max_x - min_x + 100) height = int(max_y - min_y + 100) img = Image.new('RGB', (width, height), color='white') draw = ImageDraw.Draw(img) offset_x = -min_x + 50 offset_y = -min_y + 50 # Draw strokes for el in elements: if el["type"] == "freedraw": points = el.get("points", []) if len(points) > 1: abs_points = [(p[0] + el["x"] + offset_x, p[1] + el["y"] + offset_y) for p in points] draw.line(abs_points, fill='black', width=2) img.save("reconstructed.png") print("Flag image saved to reconstructed.png") except Exception as e: print(f"Error: {e}") if __name__ == "__main__": render_drawing() -
Result: Running the script generates
reconstructed.png, which visually displays the flag drawn with strokes.
Flag
(Read explicitly from reconstructed.png)
π Crypto
A Fortune Teller
Challenge Overview
Predict the next 5 full 64-bit internal states of a Linear Congruential Generator (LCG) given three consecutive 32-bit truncated outputs (βglimpsesβ).
Analysis
The provided fortune.py reveals the LCG parameters:
- Modulus ():
- Multiplier ():
- Increment ():
The state update is standard:
However, the output function truncates the lower 32 bits: This means for each glimpse , the full state is , where is the unknown lower 32 bits ().
Solution: Lattice Attack
Since we know the high bits, we can formulate this as a lattice problem (specifically, a Closest Vector Problem or CVP) to recover the unknown low bits .
From the LCG relation , we substitute the known parts:
Rearranging to group the unknowns ():
Let be the known constant on the right side:
We are looking for small integers satisfying this equation. We construct a 2D lattice spanned by the basis vectors:
Any vector in this lattice is of the form:
If we choose and , we get . We want this vector to be βcloseβ to a target vector . The difference vector is:
Since are small relative to , the vector is short. We can use Gaussian Lattice Reduction (the 2D equivalent of LLL) to find a reduced basis, then solve the CVP to find the lattice vector closest to .
Script Logic
- Data Collection: Retrieve glimpses from the server.
- Lattice Reduction: Apply Gaussian reduction to the basis .
- CVP Solving: Express in the reduced basis, round coefficients to the nearest integers to find the closest lattice point.
- Recovery & Verification: Calculate from the difference, reconstruct , and verify it generates and . (A small search radius around the rounded coefficients handles boundary cases).
- Prediction: Once is verified, simulate the LCG to generate the next 5 full states.
Flag
0xfun{trunc4t3d_lcg_f4lls_t0_lll}
The Fortune Tellerβs Revenge
Challenge Overview
Predict the next 5 full 64-bit states from three 32-bit leaked βglimpsesβ with large jumps between leaks.
The service gives:
g1 = high32(s1)- jump 100000 steps, then one normal step β
g2 = high32(s2) - jump 100000 steps, then one normal step β
g3 = high32(s3)
and asks for the next 5 full 64-bit states.
Given RNG
- jump constants:
Normal step:
Jump step:
Effective relation between leaked full states
Each leak happens after jump() then next() (except the first, which is just first next()).
So the relation between consecutive leaked full states is:
where
Let:
From : Define: then for some integer .
This is solved as a 2D CVP/lattice problem with basis: Target is ; the closest lattice point gives , then reconstruct . Check consistency with .
Important gotcha
The challenge asks for the next 5 full states after the third glimpse, using normal RNG progression (next()), not additional jump-transitions.
So once is recovered:
for 5 steps.
Solver
#!/usr/bin/env python3
from __future__ import annotations
import re
import socket
from dataclasses import dataclass
M = 2**64
A = 2862933555777941757
C = 3037000493
JUMP = 100000
A_JUMP = pow(A, JUMP, M)
C_JUMP = 8391006422427229792
# Effective recurrence between leaked full states:
# s2 = B*s1 + D (mod M), s3 = B*s2 + D (mod M)
B = (A * A_JUMP) % M
D = (A * C_JUMP + C) % M
@dataclass
class RecoverResult:
s1: int
s2: int
s3: int
def round_div(num: int, den: int) -> int:
if den == 0:
raise ZeroDivisionError("denominator is zero")
if den < 0:
num = -num
den = -den
if num >= 0:
return (num + den // 2) // den
return -(((-num) + den // 2) // den)
def gauss_reduce(v1: tuple[int, int], v2: tuple[int, int]) -> tuple[tuple[int, int], tuple[int, int]]:
b1 = list(v1)
b2 = list(v2)
while True:
n1 = b1[0] * b1[0] + b1[1] * b1[1]
n2 = b2[0] * b2[0] + b2[1] * b2[1]
if n2 < n1:
b1, b2 = b2, b1
n1, n2 = n2, n1
dot = b1[0] * b2[0] + b1[1] * b2[1]
mu = round_div(dot, n1)
if mu == 0:
break
b2[0] -= mu * b1[0]
b2[1] -= mu * b1[1]
return (b1[0], b1[1]), (b2[0], b2[1])
def solve_2x2_closest(v1: tuple[int, int], v2: tuple[int, int], target: tuple[int, int]) -> tuple[tuple[int, int], tuple[int, int]]:
a, c = v1
b, d = v2
det = a * d - b * c
if det == 0:
raise ValueError("Degenerate basis")
tx, ty = target
alpha_num = tx * d - b * ty
beta_num = a * ty - tx * c
return (alpha_num, det), (beta_num, det)
def recover_state(g1: int, g2: int, g3: int) -> RecoverResult:
# s_i = g_i*2^32 + x_i, with x_i in [0, 2^32)
k = (B * (g1 << 32) + D - (g2 << 32)) % M
# Lattice of vectors (x1, B*x1 + t*M). We want close to target (0, -k)
v1 = (1, B)
v2 = (0, M)
r1, r2 = gauss_reduce(v1, v2)
alpha_frac, beta_frac = solve_2x2_closest(r1, r2, (0, -k))
alpha0 = round_div(alpha_frac[0], alpha_frac[1])
beta0 = round_div(beta_frac[0], beta_frac[1])
best: RecoverResult | None = None
for da in range(-64, 65):
for db in range(-64, 65):
a_int = alpha0 + da
b_int = beta0 + db
vx = a_int * r1[0] + b_int * r2[0]
vy = a_int * r1[1] + b_int * r2[1]
x1 = vx
x2 = vy + k
if not (0 <= x1 < (1 << 32) and 0 <= x2 < (1 << 32)):
continue
s1 = (g1 << 32) | x1
s2 = (B * s1 + D) % M
s3 = (B * s2 + D) % M
if (s2 >> 32) == g2 and (s3 >> 32) == g3:
best = RecoverResult(s1=s1, s2=s2, s3=s3)
return best
raise ValueError("Failed to recover state")
def predict_next_5(s3: int) -> list[int]:
out = []
cur = s3
for _ in range(5):
cur = (A * cur + C) % M
out.append(cur)
return out
def parse_three_ints(text: str) -> list[int]:
nums = re.findall(r"\d+", text)
return [int(x) for x in nums]
def solve_remote(host: str = "chall.0xfun.org", port: int = 45348) -> str:
with socket.create_connection((host, port), timeout=10) as sock:
sock.settimeout(2)
chunks: list[str] = []
while True:
try:
part = sock.recv(4096)
except socket.timeout:
break
if not part:
break
chunks.append(part.decode(errors="ignore"))
joined = "".join(chunks)
values = parse_three_ints(joined)
if len(values) >= 3 and "Predict the next 5 full 64-bit states" in joined:
break
data = "".join(chunks)
values = parse_three_ints(data)
if len(values) < 3:
raise ValueError(f"Expected 3 glimpses, got: {data!r}")
g1, g2, g3 = values[:3]
rec = recover_state(g1, g2, g3)
preds = predict_next_5(rec.s3)
payload = " ".join(str(x) for x in preds) + "\n"
sock.sendall(payload.encode())
response_chunks: list[str] = []
sock.settimeout(2)
while True:
try:
part = sock.recv(4096)
except socket.timeout:
break
if not part:
break
response_chunks.append(part.decode(errors="ignore"))
response = "".join(response_chunks)
return data + response
def main() -> None:
result = solve_remote()
print(result, end="")
if __name__ == "__main__":
main()Flag
0xfun{r3v3ng3_0f_th3_f0rtun3_t3ll3r}
βͺ Reverse
Pharaohβs Curse
Overview
Weβre given a single binary tomb_guardian. The challenge is a three-stage reverse engineering puzzle themed around ancient Egypt; each stage unlocks the next.
Stage 1: tomb_guardian
Running file on the binary shows a stripped, 64-bit ELF PIE executable. It prompts for a password.
$ file tomb_guardian
tomb_guardian: ELF 64-bit LSB pie executable, x86-64, ... strippedAnti-Debugging
The binary calls ptrace(PTRACE_TRACEME) on itself to detect debuggers. We patch the conditional jump to bypass this:
# Patch JNE β JMP at offset 0x10db
data[0x10db] = 0xeb # 0x75 (jne) β 0xeb (jmp)Stack-Based VM
Disassembly reveals a stack-based virtual machine interpreter at the core. The bytecode lives at file offset 0x3040 and performs XOR-based character validation. The repeating pattern in the bytecode is:
40 01 XX 12 01 YY 20 31 91 02
This translates to: if input[i] XOR YY != XX then fail. So the password is recovered by XORing each pair:
password = ''.join(chr(xx ^ yy) for xx, yy in pairs)
# β "0p3n_s3s4m3"$ ./tomb_guardian
Enter password: 0p3n_s3s4m3
You have spoken the old tongue well, my child...
The sacred chamber awaits: Kh3ops_Pyr4m1d
Stage 2: Sacred Chamber
The output gives us the password for the next stage β a 7z archive:
$ 7z x sacred_chamber.7z -p"Kh3ops_Pyr4m1d"This extracts:
hiero_vmβ A Rust-compiled VM interpreterchallenge.hieroβ 275 lines of hieroglyphic bytecode
Stage 3: Hieroglyphic VM
This is where it gets interesting. challenge.hiero uses actual Unicode Egyptian Hieroglyphs (U+13000 range) as opcodes and Cuneiform characters (U+12000 range) as numeric operands.
Instruction Set
| Hieroglyph | Opcode | Description |
|---|---|---|
| π΄ | READ | Read a character from stdin |
| πΉ X | PUSH | Push value/index X |
| π | STORE | Store to register |
| π | POP | Pop from stack |
| π | PUSH_IMM | Push immediate value |
| π | OP | Binary operation on two values |
| π | CJMP | Conditional jump (fail if β ) |
| π³ | SUCCESS | Accept input |
| π― | HALT | End execution |
Cuneiform values decode as: value = ord(char) - 0x12000.
For example, π = U+120D8 β 216.
Bytecode Structure
Phase 1 (lines 1β81): Read 27 characters into registers 0β26.
π΄ # READ
πΉ π # PUSH index 0
π # STORE
Phase 2 (lines 82β273): 24 constraint checks, each following the pattern:
πΉ π # PUSH Input[6]
π # POP
πΉ π # PUSH Input[7]
π # POP
π # PUSH_IMM
πΉ π # PUSH 216
π # COMPARE
π π₯ π # CJMP (fail if not equal)
The Trap: Itβs Not XOR
The natural assumption for π is XOR β itβs the most common operation in VM crackmes. But when we check the cross-constraints against the chain constraints, theyβre inconsistent under XOR:
Chain: Input[6] β Input[10] = 216 β 156 β 166 β 166 = 68
Cross: Input[6] β Input[10] = 164
Mismatch! β π is NOT XOR
The breakthrough: π performs modular addition β (a + b) mod 256.
Solving with Z3
With the correct operation identified, we use Z3 with the known flag format 0xfun{...} (27 characters total):
from z3 import *
inputs = [BitVec(f'input_{i}', 8) for i in range(27)]
solver = Solver()
# Known: flag format is 0xfun{...}
for i, ch in enumerate("0xfun{"):
solver.add(inputs[i] == ord(ch))
solver.add(inputs[26] == ord('}'))
# Printable ASCII
for i in range(27):
solver.add(inputs[i] >= 0x20)
solver.add(inputs[i] <= 0x7E)
# All 24 constraints as modular addition
constraints = [
(6, 7, 216), (7, 8, 156), (8, 9, 166), (9, 10, 166),
(10, 11, 100), (11, 12, 152), (12, 13, 199), (13, 14, 213),
(14, 15, 227), (15, 16, 204), (16, 17, 144), (17, 18, 159),
(18, 19, 209), (19, 20, 150), (20, 21, 163), (21, 22, 228),
(22, 23, 165), (23, 24, 97), (24, 25, 158),
# Cross constraints
(6, 10, 164), (8, 15, 161), (12, 20, 155), (15, 23, 158),
(7, 18, 214),
]
for a, b, val in constraints:
solver.add((inputs[a] + inputs[b]) & 0xFF == val)
assert solver.check() == sat
model = solver.model()
flag = ''.join(chr(model[inputs[i]].as_long()) for i in range(27))
print(flag)0xfun{ph4r40h_vm_1nc3pt10n}
Verification
$ echo -n "0xfun{ph4r40h_vm_1nc3pt10n}" | ./hiero_vm challenge.hiero
The ancient seals accept your offeringFlag
0xfun{ph4r40h_vm_1nc3pt10n}