Write-up: baby serial
Category: Forensics
Author: Anonimbus
Challenge Overview
The challenge provided a serial capture file named babyserial.sal with the following description:
Joe was trying to sniff the data over a serial communication. Was he successful?
The objective was to recover a flag in EH4X{...} format.
Evidence Collected
- Primary file:
baby serial/babyserial.sal - The
.salfile was actually an archive (ZIP container), not a single binary file. - After extraction, it contained:
meta.jsondigital-0.bins.d.digital-7.bin
- From
meta.json:- Sample rate digital: 1,000,000 Sa/s
- No analyzer was configured (UART was not auto-decoded).
- Real signal activity only appeared on channel 0 (
digital-0.binwas large; other channels were nearly empty).
Analysis Path
- Renamed/copied
babyserial.salto.zip, then extracted it. - Parse format internal
digital-0.bin(Saleae run-length encoded digital transitions). - Reconstructed logic levels over time from run durations.
- Detected start-bit edges (high -> low), then brute-forced UART parameters:
- 8 data bits, no parity, 1 stop bit (8N1)
- baud around 115200 (effective sampling around 8.4 samples/bit)
- UART decoding produced text that was mostly Base64.
- Decoding the Base64 produced an image payload (PNG header/trailer were partial, but the content was still readable).
- The image revealed the flag.
Recovery
The decoded payload displayed:
EH4X{baby_U4rt}
Validation
- The flag format matched the challenge requirement:
EH4X{...} - The flag content was consistent with the serial/UART challenge theme.
- Alternative decode modes or incorrect baud parameters produced corrupted/garbled output.
Flag
EH4X{baby_U4rt}
Takeaway
.sal forensics often requires manual raw-signal decoding when no analyzer is configured. For serial cases:
- check the sample rate,
- reconstruct transition timing,
- then brute-force UART parameters (baud/phase/invert) until the payload becomes readable.
Write-up: let-the-penguin-live
Category: Forensics
Author: mahekfr
Challenge Overview
The challenge provided the video file let-the-penguin-live/challenge.mkv with this hint:
In a colony of many, one penguin’s path is an anomaly. Silence the crowd to hear the individual.
This hint pointed to processing the difference between audio tracks to reveal a hidden signal.
Evidence Collected
challenge.mkvhad two primary audio tracks (stereo and surround).- When analyzed separately, both tracks contained very similar ambient crowd-like audio.
- The string
EH4X{k33p_try1ng}appeared in another artifact, but it was a decoy.
Analysis Path
- Extracted both audio tracks from MKV to WAV (
stereo.wavandsurround.wav). - Converted to mono and aligned sample lengths.
- Performed crowd cancellation:
diff = stereo - surround
- From the difference result, focused on the signal burst around 25.35s - 27.25s.
- Rendered a burst spectrogram (large NFFT, high contrast) to read embedded text.
- The spectrogram text revealed the final flag.
Recovery
The real flag was read from the spectrogram generated from the audio difference:
EH4X{0n3_tr4ck_m1nd_tw0_tr4ck_F1les}
Validation
- The format matched
EH4X{...}. - It was consistent with the challenge hint:
- “silence the crowd” -> canceled shared components across both tracks.
- “one penguin’s path” -> left only the anomalous signal in the difference.
- The final read was clearest on spectrogram grid
let-the-penguin-live/grid_n1024_h16.png. - The string
EH4X{k33p_try1ng}was confirmed as a decoy.
Flag
EH4X{0n3_tr4ck_m1nd_tw0_tr4ck_F1les}
Solver
Python solver:
#!/usr/bin/env python3
"""
Solver for EHAX CTF - let-the-penguin-live
Strategy:
1. Use two audio tracks from challenge.mkv (or existing stereo.wav/surround.wav).
2. Subtract one track from the other to isolate the anomaly ("one penguin").
3. Render spectrogram around the burst window where text is embedded.
4. Read the flag from generated image.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import wave
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def extract_track_if_needed(mkv_path: Path, out_wav: Path, track_idx: int) -> None:
if out_wav.exists():
return
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError(
f"{out_wav.name} not found and ffmpeg is not available in PATH."
)
cmd = [
ffmpeg,
"-y",
"-i",
str(mkv_path),
"-map",
f"0:a:{track_idx}",
"-ac",
"1",
"-ar",
"48000",
"-c:a",
"pcm_s16le",
str(out_wav),
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def read_wav_mono(path: Path) -> tuple[np.ndarray, int]:
with wave.open(str(path), "rb") as wf:
channels = wf.getnchannels()
sample_rate = wf.getframerate()
sample_width = wf.getsampwidth()
raw = wf.readframes(wf.getnframes())
if sample_width != 2:
raise ValueError(f"Unsupported sample width in {path.name}: {sample_width * 8}-bit")
data = np.frombuffer(raw, dtype=np.int16).astype(np.float32)
if channels > 1:
data = data.reshape(-1, channels).mean(axis=1)
data /= 32768.0
return data, sample_rate
def write_wav_mono(path: Path, data: np.ndarray, sample_rate: int) -> None:
x = np.clip(data, -1.0, 1.0)
pcm = (x * 32767.0).astype(np.int16)
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm.tobytes())
def main() -> None:
parser = argparse.ArgumentParser(description="Solve let-the-penguin-live from audio track difference.")
parser.add_argument("--input", default="challenge.mkv", help="Input MKV file")
parser.add_argument("--start", type=float, default=25.35, help="Burst start time (seconds)")
parser.add_argument("--end", type=float, default=27.25, help="Burst end time (seconds)")
parser.add_argument("--nfft", type=int, default=4096, help="FFT size for spectrogram")
args = parser.parse_args()
base = Path(__file__).resolve().parent
mkv = base / args.input
stereo_wav = base / "stereo.wav"
surround_wav = base / "surround.wav"
if not stereo_wav.exists() or not surround_wav.exists():
if not mkv.exists():
raise FileNotFoundError("Need challenge.mkv or pre-extracted stereo.wav/surround.wav")
extract_track_if_needed(mkv, stereo_wav, 0)
extract_track_if_needed(mkv, surround_wav, 1)
stereo, sr0 = read_wav_mono(stereo_wav)
surround, sr1 = read_wav_mono(surround_wav)
if sr0 != sr1:
raise ValueError(f"Sample rates differ: {sr0} vs {sr1}")
n = min(len(stereo), len(surround))
stereo = stereo[:n]
surround = surround[:n]
# Crowd cancellation: isolate hidden signal by track difference.
diff = stereo - surround
peak = np.max(np.abs(diff))
if peak > 0:
diff /= peak
diff_wav = base / "solver_diff.wav"
write_wav_mono(diff_wav, diff, sr0)
i0 = max(0, int(args.start * sr0))
i1 = min(len(diff), int(args.end * sr0))
burst = diff[i0:i1]
if burst.size == 0:
raise ValueError("Empty burst segment. Check --start/--end values.")
spec_path = base / "solver_spec.png"
plt.figure(figsize=(20, 8))
plt.specgram(burst, NFFT=args.nfft, Fs=sr0, noverlap=args.nfft - args.nfft // 8, cmap="gray_r")
plt.ylim(0, 6500)
plt.xlabel("Time (s)")
plt.ylabel("Frequency (Hz)")
plt.title("let-the-penguin-live: diff-track burst spectrogram")
plt.tight_layout()
plt.savefig(spec_path, dpi=220)
plt.close()
print(f"[+] Wrote diff audio: {diff_wav}")
print(f"[+] Wrote spectrogram: {spec_path}")
if __name__ == "__main__":
main()
Run from the challenge folder:
python solver.py
Main outputs:
solver_diff.wav(difference audio)solver_spec.png(spectrogram for reading)
Write-up: painter
Category: Forensics
Author: stapat
Challenge Overview
The challenge provided a network capture file painter/pref.pcap with a hint about a “painter” trying to deceive an employee, and a target flag format of EH4X{...}.
Evidence Collected
pref.pcapwas not regular TCP/HTTP traffic, but USB capture traffic (usbmon).- All packets were USB interrupt transfers with fixed-length
usb.capdatapayloads (7 bytes). - The report fields looked like:
- byte state/pen (
0,1,2) - signed 16-bit little-endian X and Y coordinate deltas
- byte state/pen (
Analysis Path
- Parsed packets with
tshark(in WSL), extractingusb.capdata. - Decoded each report:
state = b[1]dx = int16_le(b[2:4])dy = int16_le(b[4:6])
- Reconstructed pointer paths via accumulation:
x += dxy += dy
- Plotted paths by state to separate strokes.
- The resulting plot formed readable handwritten flag text.
Recovery
The reconstructed handwriting produced:
EH4X{wh4t_c0l0ur_15_th3_fl4g}
Validation
- The format matched
EH4X{...}. - The string remained consistent after separating paths by state and removing transition-line noise.
- The flag fit the “painter” challenge context (stylus/mouse movement data forming text).
Flag
EH4X{wh4t_c0l0ur_15_th3_fl4g}
Solver
Python solver:
import subprocess
import numpy as np
import matplotlib.pyplot as plt
out = subprocess.check_output([
'wsl','-e','bash','-lc',
'tshark -r /mnt/e/ctf/ehax/painter/pref.pcap -T fields -e usb.capdata'
], text=True)
rows = []
for s in out.splitlines():
s = s.strip()
if not s:
continue
b = bytes.fromhex(s)
st = b[1]
dx = int.from_bytes(b[2:4], 'little', signed=True)
dy = int.from_bytes(b[4:6], 'little', signed=True)
rows.append((st, dx, dy))
pts = []
x = 0
y = 0
for st, dx, dy in rows:
x += dx
y += dy
pts.append((x, y, st))
arr = np.array(pts)
plt.figure(figsize=(14, 6))
for st, c in [(0, '#999999'), (1, '#ff0066'), (2, '#00aaff')]:
m = arr[:, 2] == st
plt.scatter(arr[m, 0], -arr[m, 1], s=1, c=c, label=f'st{st}', alpha=0.6)
plt.legend()
plt.axis('equal')
plt.tight_layout()
plt.savefig(r'e:/ctf/ehax/painter/path_scatter.png', dpi=200)
plt.close()
plt.figure(figsize=(14, 6))
for st, c in [(1, '#ff0066'), (2, '#00aaff')]:
m = arr[:, 2] == st
plt.plot(arr[m, 0], -arr[m, 1], '.', ms=1, c=c, alpha=0.8)
plt.axis('equal')
plt.tight_layout()
plt.savefig(r'e:/ctf/ehax/painter/path_draw_states12.png', dpi=200)
plt.close()
print('done', arr.shape)
Write-up: Lost in Waves
Category: Forensics
Author: Anonimbus
Challenge Overview
The challenge provided Lost in Waves/chall.zip with the hint:
Taru got an intel that a package containing classified documents is being moved in covert channels.
This clue indicated hidden data passed through staged channels (not a single direct file).
Evidence Collected
chall.zipcontained:0.saldata(long ASCII hex stream)
- From
0.sal(Saleae Logic capture), the Async Serial analyzer led to a message containing a password:ohblimey**ehax
datawas a hex stream that converted into an encrypted archive:data.rar
- Extracting
data.rar(with the password above) produced:1.wav,2.wav,3.wav,4.wav
Analysis Path
- Extracted
0.sal, read serial communication, and recovered passwordohblimey**ehax. - Converted
data(hex text) to binary and identified it as RAR. - Extracted
data.rarwith that password and obtained four WAV files. - Checked spectrograms and observed structured bursts rather than natural audio.
- Tested radio/modem decoders against the extracted WAV files.
- Successfully decoded with
POCSAG1200usingmultimon-ng.
Final decode command used:
sox concat.wav -t raw -e signed-integer -b 16 -r 22050 -c 1 - | multimon-ng -t raw -a POCSAG1200 -
Recovery
The POCSAG1200 output contained:
Passwordz EH4X{P4g3d_lik3_a_b00k}. Keep it hush, yeah?
Validation
- The format matched
EH4X{...}. - The flag appeared consistently in POCSAG message output.
- The method aligned with the “covert channels” hint:
- serial channel -> password,
- password-protected archive -> audio channel,
- pager payload (POCSAG) -> flag.
Flag
EH4X{P4g3d_lik3_a_b00k}
Write-up: Power Leak
Category: Forensic
Author: tanishfr
Challenge Overview
The challenge provided power_traces.csv with the hint:
Power reveals the secret.
EHAX{SHA256(secret)}
This clue pointed to a power side-channel attack: extracting a secret by analyzing device power consumption during computation.
Evidence Collected
power_traces.csv: power-trace dataset with columns:position(0–5): digit position in the secret (6 total digits)guess(0–9): tested digit candidatetrace_num(0–19): repeated trace indexsample(0–49): sample time pointpower_mW: power consumption in milliwatts
Total: 60,000 rows (6 × 10 × 20 × 50)
Analysis Path
1. Understanding the Data Structure
The dataset simulated a scenario where the device validated the secret digit by digit. For each position, 10 digit candidates (0–9) were tested 20 times, with 50 sampling points per trace.
6 positions × 10 guesses × 20 traces × 50 samples = 60,000 rows
2. Identifying the Attack Method
This was Simple Power Analysis (SPA), not complex statistical correlation. The key was to find obvious power spikes at specific sample points.
Hypothesis: when a guess was correct, the device performed extra work (for example, a successful comparison and continuation), creating a significant power increase at certain samples.
3. Finding the Spike
By computing average power per (position, guess, sample) and searching for outliers:
import csv, numpy as np
from collections import defaultdict
data = []
with open('power_traces.csv') as f:
reader = csv.DictReader(f)
for row in reader:
data.append((int(row['position']), int(row['guess']),
int(row['trace_num']), int(row['sample']), float(row['power_mW'])))
power_matrix = defaultdict(lambda: defaultdict(lambda: np.zeros((20, 50))))
for pos, guess, trace, sample, pw in data:
power_matrix[pos][guess][trace][sample] = pw
At samples 20 and 21, one guess per position had noticeably higher power (~78–80 mW vs ~73–77 mW for incorrect guesses):
| Position | Winner Guess | Power (mW) | Runner-up (mW) | Margin |
|---|---|---|---|---|
| 0 | 7 | 79.20 | 76.92 | +2.28 |
| 1 | 9 | 79.44 | 76.24 | +3.20 |
| 2 | 2 | 78.13 | 75.38 | +2.75 |
| 3 | 9 | 78.67 | 76.32 | +2.35 |
| 4 | 6 | 78.92 | 77.20 | +1.72 |
| 5 | 3 | 79.98 | 76.44 | +3.54 |
4. Secret Extraction
The highest-spike digit at each position produced:
secret = "792963"
5. Flag Construction
import hashlib
secret = "792963"
h = hashlib.sha256(secret.encode()).hexdigest()
print(f"EHAX{{{h}}}")
Recovery
Secret : 792963
SHA256 : 5bec84ad039e23fcd51d331e662e27be15542ca83fd8ef4d6c5e5a8ad614a54d
Flag
EHAX{5bec84ad039e23fcd51d331e662e27be15542ca83fd8ef4d6c5e5a8ad614a54d}
Key Takeaway
Power side-channel attacks work because devices processing secret data leak information through power consumption. In this challenge, correct guesses produced spikes about 3–5 mW higher than incorrect guesses, enough to extract visually from averaged traces.
Method used: Simple Power Analysis (SPA) with direct observation of per-sample power distributions.
Write-up: Quantum Message
Category: Forensics
Author: stapat
Challenge Overview
The challenge provided audio file Quantum Message/challenge.wav with the clue:
my quantum physics professor was teaching us about 1-D harmonic oscillator , he gave me a problem in which the angular frequency was 57 1035 (weird number) and it asked to calculate the energy eigenvalues, then he called someone , who did he call?
The final objective was to recover a flag in EH4X{...} format.
Evidence Collected
- Primary file:
Quantum Message/challenge.wav - Audio format: mono, 44.1 kHz, duration ~81.6 seconds.
- The spectrogram showed repeating discrete tones, not natural audio.
- In the frequency domain, dominant components appeared in groups:
- Low group:
300, 900, 1500, 2100Hz - High group:
2700, 3300, 3900Hz
- Low group:
Analysis Path
- Split the audio into 80 equal time slots (~1.02 s/slot).
- For each slot, locate the strongest frequency peaks in the low and high groups.
- Mapped each (low, high) pair to a custom DTMF-like keypad layout:
low\high 2700 3300 3900
300 1 2 3
900 4 5 6
1500 7 8 9
2100 * 0 #
- This mapping produced the following digit stream:
69725288123113117521101161171099511210412111549995395495395534895539952114121125
- Parsed the stream as concatenated decimal ASCII codes (2 or 3 digits), producing a unique flag-formatted string.
Recovery
ASCII parsing yielded:
EH4X{qu4ntum_phys1c5_15_50_5c4ry}
Validation
- The format matched
EH4X{...}. - Decoding remained consistent when the pipeline was rerun.
- The “57 1035” number acted as thematic misdirection, while the real payload was hidden in tone-frequency mapping.
Flag
EH4X{qu4ntum_phys1c5_15_50_5c4ry}
Solver
Python solver:
from __future__ import annotations
import numpy as np
from pathlib import Path
from scipy.io import wavfile
def decode_quantum_message(path: Path) -> str:
sr, data = wavfile.read(path)
if data.ndim > 1:
data = data[:, 0]
x = data.astype(np.float64)
# The signal is 80 equal tone windows (81.6s total -> 1.02s each).
n_sym = 80
sym_len = len(x) // n_sym
x = x[: n_sym * sym_len].reshape(n_sym, sym_len)
freqs = np.fft.rfftfreq(sym_len, d=1.0 / sr)
window = np.hanning(sym_len)
low_group = [300, 900, 1500, 2100]
high_group = [2700, 3300, 3900]
# Keypad layout (rows=low freq, cols=high freq)
keypad = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
["*", "0", "#"],
]
digit_stream = []
for i in range(n_sym):
spectrum = np.abs(np.fft.rfft(x[i] * window))
low_idx = int(
np.argmax([spectrum[np.argmin(np.abs(freqs - f))] for f in low_group])
)
high_idx = int(
np.argmax([spectrum[np.argmin(np.abs(freqs - f))] for f in high_group])
)
digit_stream.append(keypad[low_idx][high_idx])
digits = "".join(digit_stream)
# Parse concatenated ASCII decimal codes (2- or 3-digit), unique in this file.
from functools import lru_cache
@lru_cache(None)
def parse(pos: int) -> list[str]:
if pos == len(digits):
return [""]
out: list[str] = []
for width in (2, 3):
if pos + width > len(digits):
continue
code = int(digits[pos : pos + width])
if 32 <= code <= 126:
ch = chr(code)
if ch.isalnum() or ch in "{}_":
for tail in parse(pos + width):
out.append(ch + tail)
return out
parsed = parse(0)
if len(parsed) != 1:
raise RuntimeError(f"Expected unique parse, got {len(parsed)} candidates")
return parsed[0]
if __name__ == "__main__":
wav_path = Path(__file__).with_name("challenge.wav")
flag = decode_quantum_message(wav_path)
print(flag)
Run:
python "solver.py"
Write-up: Jpeg Soul
Category: Forensics
Author: tanish_fr
Challenge Overview
The challenge provided image file Jpeg Soul/soul.jpg with the clue:
My soul will guide you, even through what seems insignificant.
The required flag format was EHAX{...}.
Evidence Collected
- Target file:
Jpeg Soul/soul.jpg - The JPEG was valid, with no interesting metadata and no appended payload at EOF.
steghide infoshowed embed capacity, but common passphrase brute-force produced no extraction.- The keyword insignificant pointed to least-significant-bit (LSB) data.
Analysis Path
- Parsed the JPEG structure manually and extracted DQT segments (
0xFFDB/ quantization tables). - For each quantization table, extracted LSBs from coefficient values.
- Read coefficients in JPEG zig-zag order.
- Packed bits into bytes (MSB-first), then checked for printable text/flag content.
- Valid output appeared when using:
- table index
2->EHAX{jp3 - table index
3->g_s3crt}
- table index
- Combined both fragments into the final flag.
Recovery
The payload recovered from DQT LSB data was:
EHAX{jp3g_s3crt}
Validation
- The format matched
EHAX{...}. - Results were consistent when the script was rerun.
- The clue was accurate: data was hidden in “insignificant” parts (LSB quantization coefficients), not in visible image content.
Flag
EHAX{jp3g_s3crt}
Solver
Python solver:
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
JPEG_SOI = b"\xFF\xD8"
JPEG_SOS = 0xDA
ZIGZAG = [
0, 1, 5, 6, 14, 15, 27, 28,
2, 4, 7, 13, 16, 26, 29, 42,
3, 8, 12, 17, 25, 30, 41, 43,
9, 11, 18, 24, 31, 40, 44, 53,
10, 19, 23, 32, 39, 45, 52, 54,
20, 22, 33, 38, 46, 51, 55, 60,
21, 34, 37, 47, 50, 56, 59, 61,
35, 36, 48, 49, 57, 58, 62, 63,
]
FLAG_RE = re.compile(r"EHAX\{[A-Za-z0-9_\-!@#$%^&*().:+,=/?]{3,80}\}")
@dataclass(frozen=True)
class TableVariant:
table_idx: int
bit: int
order: str
reverse_bits: bool
bit_order: str
payload: bytes
def parse_dqt_tables(jpeg_bytes: bytes) -> list[list[int]]:
if not jpeg_bytes.startswith(JPEG_SOI):
raise ValueError("Not a valid JPEG (missing SOI marker)")
pos = 2
tables: list[list[int]] = []
while pos + 1 < len(jpeg_bytes):
if jpeg_bytes[pos] != 0xFF:
pos += 1
continue
while pos < len(jpeg_bytes) and jpeg_bytes[pos] == 0xFF:
pos += 1
if pos >= len(jpeg_bytes):
break
marker = jpeg_bytes[pos]
pos += 1
if marker == JPEG_SOS:
break
if marker in (0xD8, 0xD9, 0x01) or (0xD0 <= marker <= 0xD7):
continue
if pos + 2 > len(jpeg_bytes):
break
seg_len = (jpeg_bytes[pos] << 8) | jpeg_bytes[pos + 1]
seg = jpeg_bytes[pos + 2 : pos + seg_len]
pos += seg_len
if marker != 0xDB:
continue
i = 0
while i < len(seg):
pq_tq = seg[i]
i += 1
pq = (pq_tq >> 4) & 0x0F
if pq == 0:
vals = list(seg[i : i + 64])
i += 64
else:
vals = []
for _ in range(64):
vals.append((seg[i] << 8) | seg[i + 1])
i += 2
tables.append(vals)
if not tables:
raise RuntimeError("No DQT tables found")
return tables
def bits_to_bytes(bits: Iterable[int], bit_order: str) -> bytes:
out = bytearray()
chunk: list[int] = []
for b in bits:
chunk.append(int(b) & 1)
if len(chunk) == 8:
if bit_order == "msb":
val = 0
for x in chunk:
val = (val << 1) | x
else:
val = 0
for i, x in enumerate(chunk):
val |= x << i
out.append(val)
chunk.clear()
return bytes(out)
def generate_variants(tables: list[list[int]]) -> list[TableVariant]:
variants: list[TableVariant] = []
for idx, vals in enumerate(tables):
for bit in range(4):
for order in ("natural", "zigzag"):
ordered = vals if order == "natural" else [vals[i] for i in ZIGZAG]
base_bits = [(x >> bit) & 1 for x in ordered]
for reverse_bits in (False, True):
bits = base_bits[::-1] if reverse_bits else base_bits
for bit_order in ("msb", "lsb"):
payload = bits_to_bytes(bits, bit_order)
variants.append(
TableVariant(
table_idx=idx,
bit=bit,
order=order,
reverse_bits=reverse_bits,
bit_order=bit_order,
payload=payload,
)
)
return variants
def solve(jpeg_path: Path) -> tuple[str, tuple[TableVariant, TableVariant]]:
data = jpeg_path.read_bytes()
tables = parse_dqt_tables(data)
variants = generate_variants(tables)
for a in variants:
for b in variants:
if a.table_idx == b.table_idx:
continue
combined = a.payload + b.payload
text = combined.decode("latin1", errors="ignore")
m = FLAG_RE.search(text)
if m:
return m.group(0), (a, b)
raise RuntimeError("Flag not found from DQT variants")
def main() -> None:
jpeg_path = Path(__file__).with_name("soul.jpg")
flag, (v1, v2) = solve(jpeg_path)
print(flag)
print(
f"[debug] from tables {v1.table_idx}+{v2.table_idx}, "
f"bit={v1.bit}, order={v1.order}, reverse={v1.reverse_bits}, bit_order={v1.bit_order}"
)
if __name__ == "__main__":
main()
Run:
python "solver.py"
Write-up: Kaje
Category: Reverse Engineering
Author: Anonimbus
Challenge Overview
We are given a single ELF binary kaje and an SSH host to run it.
At first glance, running the binary only prints unreadable bytes, so the task is to reverse the generation logic and recover the intended plaintext (flag).
Evidence Collected
- Binary type:
ELF 64-bit PIE, not stripped - Interesting symbols:
gen_entropygen_keystream
- Main flow (from disassembly):
seed = gen_entropy()gen_keystream(buf, seed)for 32 bytes- XOR keystream with two 16-byte constants from
.rodata - Print result via
puts
Analysis Path
- Disassemble binary:
objdump -d -Mintel kaje objdump -s -j .rodata kaje gen_entropylogic:- Uses one of two base constants depending on
access("/.dockerenv", 0). - Opens
/proc/self/mountinfo. - If a line contains
"overlay", XOR with0xabcdef1234567890. - Final value is mixed with MurmurHash-style
fmix64.
- Uses one of two base constants depending on
gen_keystream:- Generates 32 bytes by repeatedly applying
fmix64to evolving state.
- Generates 32 bytes by repeatedly applying
- Output is:
cipher_const ^ keystream- So we can reconstruct offline by reimplementing the same logic.
Recovery
Reimplemented the two functions (gen_entropy + gen_keystream) in Python and tested all 4 environment combinations:
dockerenv ∈ {0,1}overlay ∈ {0,1}
Only one combination produced readable flag text:
dockerenv=1,overlay=1
Validation
Recovered plaintext matched CTF flag format:
- Starts with
EH4X{ - Ends with
}
Flag
EH4X{dUnn0_wh4tt_1z_1nt3NTenD3d}
Takeaway
This challenge is an environment-dependent stream-XOR obfuscation.
Even when runtime output looks random, static reversing of seed generation + keystream derivation is enough to fully recover the hidden plaintext.
Write-up: compute it
Category: Reverse / Misc
Author: martin
Challenge Overview
Provided artifacts:
validator(ELF x64, not stripped)signal_data.txt(2600 pasanganreal,imag)
Prompt: “the computation prof gave me some data and a executable, what does he want from me?”
From binary strings:
Usage: ./validator <real> <imag>AUTHORIZATION ACCEPTED: Node Valid.AUTHORIZATION DENIED: Node Invalid.
The validator was not a direct flag endpoint. It functioned as an oracle to generate a hidden bitmap from the dataset.
Reverse Engineering
main only accepted 2 float arguments (atof(argv[1]), atof(argv[2])) and then ran Newton iterations on complex numbers.
Equation used (with z = x + iy):
f(z) = z^3 - 1
Real/imag components:
f_re = x^3 - 3xy^2 - 1f_im = 3x^2y - y^3
Jacobian:
j00 = 3(x^2 - y^2)j01 = 6xyden = j00^2 + j01^2
Update Newton:
dx = (f_re*j00 + f_im*j01) / dendy = (f_im*j00 - f_re*j01) / denx -= dx,y -= dy
Key constants from .rodata:
3.0,1.0,6.0- singularity threshold:
den < 1e-9-> stop - convergence threshold:
abs(x-1.0) < 1e-6 && abs(y) < 1e-6
Final validation condition per point:
- iteration counter must be exactly 12 (
count == 12)
Solution
- Scanned all rows in
signal_data.txtand ran the validator model for each(x,y). - Converted results into a bitstream:
1ifcount == 12(Node Valid)0otherwise
- Reshaped the bitstream into a
20 x 130matrix.
Active rows formed a 5-row dot-matrix text pattern. - Segmented by character (non-empty columns separated by empty columns), then decoded 5-row glyphs.
Solver script:
#!/usr/bin/env python3
"""Solve script for 'compute it'.
It reconstructs validator logic from reverse engineering and scans signal_data.txt
for telemetry points that pass AUTHORIZATION.
"""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
def validator_model(x: float, y: float) -> tuple[bool, int, float, float]:
"""Return (is_valid, iteration_count, final_x, final_y)."""
count = 0
fail = 0
while fail <= 0x31: # 49
# f(z) = z^3 - 1 where z = x + i y
f_re = x * x * x - 3.0 * x * y * y - 1.0
f_im = 3.0 * x * x * y - y * y * y
# Jacobian for Newton step
j00 = 3.0 * (x * x - y * y)
j01 = 6.0 * x * y
den = j00 * j00 + j01 * j01
if den < 1e-9:
break
dx = (f_re * j00 + f_im * j01) / den
dy = (f_im * j00 - f_re * j01) / den
x -= dx
y -= dy
count += 1
# converge target: 1 + 0i with eps 1e-6
if abs(x - 1.0) < 1e-6 and abs(y) < 1e-6:
break
fail += 1
return (count == 12), count, x, y
def parse_signal_data(path: Path) -> list[tuple[int, str, str, float, float]]:
rows: list[tuple[int, str, str, float, float]] = []
for idx, raw in enumerate(path.read_text().splitlines(), 1):
raw = raw.strip()
if not raw:
continue
re_s, im_s = raw.split(",")
rows.append((idx, re_s, im_s, float(re_s), float(im_s)))
return rows
def bits_to_flag(bits: list[int], width: int = 130) -> str:
"""Decode the hidden 5-row dot-matrix flag from valid/invalid bits."""
if len(bits) % width != 0:
raise ValueError("bitstream length is not divisible by width")
height = len(bits) // width
rows = [bits[r * width : (r + 1) * width] for r in range(height)]
active_rows = [i for i, row in enumerate(rows) if any(row)]
if not active_rows:
raise ValueError("no active rows found in bitmap")
top, bottom = active_rows[0], active_rows[-1]
matrix = rows[top : bottom + 1]
if len(matrix) != 5:
raise ValueError(f"expected 5 active rows, got {len(matrix)}")
col_sums = [sum(matrix[r][c] for r in range(5)) for c in range(width)]
runs: list[tuple[int, int]] = []
in_run = False
start = 0
for c, v in enumerate(col_sums):
if v > 0 and not in_run:
start = c
in_run = True
elif v == 0 and in_run:
runs.append((start, c - 1))
in_run = False
if in_run:
runs.append((start, width - 1))
# Glyph map extracted from challenge's own bitmap font (5 rows, variable width).
glyph_map = {
("####", "#...", "###.", "#...", "####"): "E",
("#..#", "#..#", "####", "#..#", "#..#"): "H",
("#..#", "#..#", "####", "...#", "...#"): "4",
("#..#", ".##.", "..#.", ".##.", "#..#"): "X",
(".##", "##.", "#..", "##.", ".##"): "{",
("###.", "#..#", "#..#", "#..#", "#..#"): "N",
("####", "...#", ".###", "...#", "####"): "3",
("#...#", "#...#", "#.#.#", "##.##", "#...#"): "W",
("###", ".#.", ".#.", ".#.", ".#."): "T",
(".##.", "#..#", "#..#", "#..#", ".##."): "O",
("....", "....", "####", "....", "...."): "_",
("####", "#..#", "###.", "#.#.", "#..#"): "R",
("..#.", ".##.", "..#.", "..#.", "####"): "1",
("####", "#...", "#.##", "#..#", "####"): "G",
("####", "#...", "###.", "...#", "####"): "5",
}
out = []
for i, (a, b) in enumerate(runs):
glyph = tuple(
"".join("#" if matrix[r][c] else "." for c in range(a, b + 1))
for r in range(5)
)
ch = glyph_map.get(glyph)
if ch is None:
raise ValueError(f"unknown glyph at cols {a}-{b}: {glyph}")
# In this challenge font, opening/closing brace share the same 3x5 glyph.
if ch == "{" and i == len(runs) - 1:
ch = "}"
out.append(ch)
return "".join(out)
def generate_ambiguous_candidates(flag: str) -> list[str]:
"""Generate plausible alternatives for ambiguous OCR glyphs."""
alts = {flag}
# Common ambiguities in this bitmap font / leetspeak style.
swaps = [
("O", "0"),
("0", "O"),
("S", "5"),
("5", "S"),
]
changed = True
while changed:
changed = False
cur = list(alts)
for s in cur:
for a, b in swaps:
if a in s:
t = s.replace(a, b)
if t not in alts:
alts.add(t)
changed = True
return sorted(alts)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--data", default="/Users/zuy/Documents/New project 2/ctf/signal_data.txt")
ap.add_argument("--validator", default=None, help="optional path to validator binary for direct verification")
args = ap.parse_args()
rows = parse_signal_data(Path(args.data))
valid_rows: list[tuple[int, str, str, int]] = []
for idx, re_s, im_s, x, y in rows:
ok, count, _, _ = validator_model(x, y)
if ok:
valid_rows.append((idx, re_s, im_s, count))
print(f"total_rows={len(rows)}")
print(f"valid_count={len(valid_rows)}")
if not valid_rows:
print("no valid telemetry found")
return
print("first_valid_index={}, real={}, imag={}, iter={}".format(*valid_rows[0]))
print("last_valid_index={}, real={}, imag={}, iter={}".format(*valid_rows[-1]))
bitstream = [1 if validator_model(x, y)[0] else 0 for _, _, _, x, y in rows]
flag = bits_to_flag(bitstream, width=130)
print(f"decoded_flag={flag}")
print("candidate_flags:")
for c in generate_ambiguous_candidates(flag):
print(f"- {c}")
if args.validator:
idx, re_s, im_s, _ = valid_rows[0]
out = subprocess.check_output([args.validator, re_s, im_s], text=True)
print(f"\nvalidator_check_for_index_{idx}:")
print(out.strip())
if __name__ == "__main__":
main()
Run:
python3 "solve_compute_it.py"
Key solver output:
valid_count = 226decoded_flag = EH4X{N3WTON_W45_R1GHT}
Oracle verification against the binary (example of one valid node):
./validator 0.689743 1.844815
Output:
AUTHORIZATION ACCEPTED: Node Valid.
Flag
EH4X{N3WTON_W45_R1GHT}
Write-up: ghostKey
Category: Reverse / Crypto
Author: benzo
Challenge Overview
The challenge provided binary ghost with the hint: “recover the key and flag”. The binary expected a 32-character key argument.
From quick runtime checks:
Usage: /home/zuy/ghost <32-char key>- A wrong key reported failing checks (for example,
lfsr,nibble, and others).
This indicated a challenge built around a set of constraints on a 32-byte key.
Reverse Engineering Findings
Static reversing revealed the following validation components:
- Printable ASCII: each key character must be in range
0x20..0x7E. - LFSR check: final state must be
0x4358(initial seed0xACE1, polynomial0xB400). - Nibble XOR per 8-byte row:
- target:
[8, 8, 4, 7]
- target:
- Column sum mod 97 on a 4x8 matrix:
- target:
[12, 39, 8, 0, 55, 33, 50, 96]
- target:
- Pair modular constraints (12 index pairs):
(0,31) mod 127 = 104(3,28) mod 131 = 17(7,24) mod 113 = 53(11,20) mod 109 = 58(1,15) mod 103 = 52(5,27) mod 97 = 88(9,22) mod 107 = 20(13,18) mod 101 = 64(2,29) mod 127 = 81(6,25) mod 131 = 118(10,21) mod 113 = 40(14,17) mod 109 = 83
- XOR tag per 4-byte block:
[0x6c, 0x75, 0x3a, 0x01, 0x7e, 0x2f, 0x34, 0x00]
- AES S-box parity on even indices:
xor(SBOX[key[0]], SBOX[key[2]], ..., SBOX[key[30]]) == 0x66
These constraints were well-suited for SMT modeling (Z3).
Solver Approach
I built an 8-bit bit-vector model for the 32 key characters, then encoded all constraints directly.
- Solver:
SolverFor('QF_BV') - Domain: 32 byte printable
- All checks were modeled directly, including LFSR and AES-SBOX lookups via
Store/Selectarrays.
Final solver script:
#!/usr/bin/env python3
"""
ghostKey solver (Z3)
Usage:
python3 solve_ghostkey_z3.py
python3 solve_ghostkey_z3.py --run-binary /home/zuy/ghost
Notes:
- This is the full-constraint model (no hardcoded key).
- Depending on machine/SMT performance, solve time can be long.
"""
from __future__ import annotations
import argparse
import subprocess
import time
from z3 import (
BitVec,
BitVecSort,
BitVecVal,
K,
LShR,
SolverFor,
Store,
Select,
UGE,
ULE,
URem,
ZeroExt,
sat,
set_param,
)
AES_SBOX = [
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
]
def build_solver():
# Parallel params help on multi-core machines.
set_param("parallel.enable", True)
set_param("smt.threads", 8)
set_param("sat.threads", 8)
k = [BitVec(f"k{i}", 8) for i in range(32)]
s = SolverFor("QF_BV")
# printable ASCII
for x in k:
s.add(UGE(x, 32), ULE(x, 126))
# LFSR check
l = BitVecVal(0xACE1, 16)
for i in range(32):
b = k[i]
for _ in range(8):
fb = (ZeroExt(8, b) ^ l) & 1
l = LShR(l, 1) ^ (fb * BitVecVal(0xB400, 16))
b = LShR(b, 1)
s.add(l == BitVecVal(0x4358, 16))
# nibble-xor per row
for r, t in enumerate([8, 8, 4, 7]):
acc = BitVecVal(0, 8)
for c in range(8):
x = k[r * 8 + c]
acc = acc ^ (LShR(x, 4) ^ (x & 0x0F))
s.add(acc == t)
# column sums modulo 97
for c, t in enumerate([12, 39, 8, 0, 55, 33, 50, 96]):
sm = BitVecVal(0, 16)
for r in range(4):
sm = sm + ZeroExt(8, k[c + 8 * r])
s.add(URem(sm, BitVecVal(97, 16)) == BitVecVal(t, 16))
# pair modular constraints
pairs = [
(0, 31, 127, 104),
(3, 28, 131, 17),
(7, 24, 113, 53),
(11, 20, 109, 58),
(1, 15, 103, 52),
(5, 27, 97, 88),
(9, 22, 107, 20),
(13, 18, 101, 64),
(2, 29, 127, 81),
(6, 25, 131, 118),
(10, 21, 113, 40),
(14, 17, 109, 83),
]
for a, b, m, r in pairs:
sm = ZeroExt(8, k[a]) + ZeroExt(8, k[b])
s.add(URem(sm, BitVecVal(m, 16)) == BitVecVal(r, 16))
# xor tag per group of 4
for i, v in enumerate([0x6C, 0x75, 0x3A, 0x01, 0x7E, 0x2F, 0x34, 0x00]):
s.add((k[4 * i] ^ k[4 * i + 1] ^ k[4 * i + 2] ^ k[4 * i + 3]) == v)
# AES-SBOX parity on even positions
arr = K(BitVecSort(8), BitVecVal(0, 8))
for i, v in enumerate(AES_SBOX):
arr = Store(arr, BitVecVal(i, 8), BitVecVal(v, 8))
px = BitVecVal(0, 8)
for i in range(0, 32, 2):
px = px ^ Select(arr, k[i])
s.add(px == BitVecVal(0x66, 8))
return s, k
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--run-binary", help="optional path to challenge binary for direct verification")
args = parser.parse_args()
s, k = build_solver()
print("[+] solving...")
t0 = time.time()
res = s.check()
dt = time.time() - t0
print(f"[+] solver result: {res} (time={dt:.2f}s)")
if res != sat:
return
m = s.model()
key = "".join(chr(m.eval(k[i]).as_long()) for i in range(32))
print(f"[+] key: {key}")
if args.run_binary:
out = subprocess.check_output([args.run_binary, key], text=True)
print("[+] binary output:")
print(out)
if __name__ == "__main__":
main()
Run:
python3 solve_ghostkey_z3.py
Optional direct verification against the binary:
python3 solve_ghostkey_z3.py --run-binary /home/zuy/ghost
Result
The solver produced key:
Gh0stK3y-R3v3rs3-M3-1f-U-C4n!!!!
Binary verification:
[+] All checks passed![+] Flag: crackme{AES_gh0stk3y_r3v3rs3d!!}
Flag
crackme{AES_gh0stk3y_r3v3rs3d!!}
Notes
- Solve time can be long (depends on machine performance and SMT threading).
- The script does not hardcode the key; it is found entirely through constraint solving.