Write-up: Whisper
Category: Forensics / Network
Points: 100 (48 solves)
Challenge Overview
The finance workstation FIN-WS-04 is suspected of leaking data. The IR team captured a single slice of network traffic during the incident: traffic.pcap, 36 packets, 4127 bytes, spanning 1.11 seconds. The briefing ends with a line that is not decoration but the actual specification of the challenge: “Not everything suspicious is malicious, and not everything malicious looks suspicious. Separate the signal from the noise.” One channel was deliberately made loud to attract attention, and another was deliberately made boring so it would be skipped. Both assumptions have to be inverted.
Evidence Collected
- Victim host:
10.10.14.37(FIN-WS-04), talking to four different destinations. - DNS A queries:
hanz-deadend.notes.localandmetrics.cloudflare.com, both resolving to1.2.3.4and never contacted again. - DNS TXT queries: five sequentially numbered lookups,
0.through4.s2.cdn-telemetry.net, each carrying a base32 blob. - HTTP:
GET /portal/login?user=financebot&session=s3ac1f9b0e7d4426a8f1c05e9b3a7d2f4toportal.internal(10.10.10.5), answered with200 OK. - ICMP: traffic to
185.220.101.44— a range known for Tor exit nodes — split across two different identifiers,0x1234(12 packets) and0x4b9c(8 packets), the second starting exactly after the first ends. - Critical anomaly: every ICMP packet is an echo request. There is not a single reply in the capture. A normal
pingagainst a live host produces replies, and a normalpingprocess keeps one fixed identifier for its whole run.
Analysis Path
Each candidate channel was examined and eliminated in turn.
-
DNS A queries — noise.
metrics.cloudflare.comis ordinary telemetry.hanz-deadend.notes.localis literally named “deadend”. Neither IP received any follow-up traffic. -
DNS TXT to
*.s2.cdn-telemetry.net— the primary lure. This is the channel that shouts the loudest: numbered TXT queries to a telemetry-flavoured domain carrying base32, exactly the shape of DNS tunnelling.That is the entire content — a comment banner and nothing else. The dense-looking repeating pattern
2PJ5HU6Tthat dominates the blob is simply the character=(0x3D) put through base32, which is why a row of separator dashes looks like packed data. The nameTEROWONGAN(“tunnel”) and the label “Stage-2 Loader” reinforce the illusion, but there is not one byte of payload here. This is the suspicious but not malicious half of the hint. -
ICMP
id=0x1234— noise. All 12 packets carry an identical payload,50494e4750494e47...decoding toPINGPINGPINGPING.... Pure filler, designed so that a quick glance at ICMP shows “PING” and moves on. -
ICMP
id=0x4b9c— the real channel. Eight packets, and the first payload is immediately different:4e4a3031is ASCIINJ01— a magic header — and the last packet trails off into zero padding. -
HTTP
/portal/login— benign-looking, and the actual key. An internal login to an RFC1918 host that succeeds and ends. Nothing is wrong with it, and that is precisely the point. The session token is the only key-length random material anywhere in the capture, and the only thing left unused once every other channel has been eliminated. This is the malicious but not suspicious half of the hint.
Recovery
- Reassemble the covert stream. Sorting the
id=0x4b9cpackets byicmp.seqand concatenating their payloads yields 32 bytes per packet across 8 packets, or 256 bytes total, laid out as:
The arithmetic checks out: 256 minus the 8-byte header leaves 248 bytes available, the declared length is 215, and the remaining 33 bytes are all zero.offset 0..3 4e 4a 30 31 magic "NJ01" offset 4..7 00 00 00 d7 length = 215 (big-endian uint32) offset 8.. ec 58 11 4c ... ciphertext (215 bytes) + zero padding - Identify the cipher before guessing the key. Two quick observations narrow it down decisively. First, 215 is 5 x 43 and therefore not a multiple of 16, ruling out AES, DES, or any block cipher in a mode that requires block padding. Second, an index-of-coincidence test for repeating-key XOR:
Every key length from 1 to 40 returns a flat 0.003 to 0.006. Natural text scores around 0.065 and uniform random data around 0.0039, so no key length stands out at all. The keystream is genuinely as long as the message, which points to RC4 — the obvious choice at this difficulty, and one that needs no separately transmitted nonce.def ic(bs): from collections import Counter c, n = Counter(bs), len(bs) return sum(v * (v - 1) for v in c.values()) / (n * (n - 1)) for kl in range(1, 41): cols = [ct[i::kl] for i in range(kl)] print(kl, sum(ic(c) for c in cols) / kl) - Derive the key. The session token is the only candidate, leaving one open question: raw or hashed? A systematic sweep covered the token as ASCII and as
unhexlifyoutput, run through MD5, SHA-1, SHA-256, and SHA-512, each in raw-digest, hex, and 16-byte-truncated form, with every result scored by its ratio of printable characters. One combination immediately scored above 0.9:
Two details decide success here. The token is used as a plain ASCII string exactly as it appears, including the leadingkey = hashlib.sha256(b"s3ac1f9b0e7d4426a8f1c05e9b3a7d2f4").digest()[:16] # = e7a0f0ba506eea66ce762a96c9751cd4 plaintext = rc4(key, ciphertext)s, rather than being unhexlified — that straysis itself the clue that the string is not pure hex. And the digest is truncated to its first 16 bytes, not used at its full 32. - Read the document.
CONFIDENTIAL - FINANCE / Q3 CLOSE Prepared for internal distribution only. Wire approvals routing key rotated 2026-07-02. Recovery seed (do not share): polriCTF26{dns_t0_1cmp_h4nd0ff_l0g1n_d3r1v3d_k3y} -- FIN-WS-04
Validation
Every channel in the capture is accounted for, and the split between signal and noise maps cleanly onto both halves of the hint:
| Channel | First impression | Reality |
|---|---|---|
DNS A hanz-deadend.notes.local |
suspicious | decoy, named “dead end” outright |
DNS A metrics.cloudflare.com |
ordinary | genuinely ordinary |
DNS TXT *.s2.cdn-telemetry.net |
very suspicious | comment banner, zero payload |
ICMP id=0x1234 (12 pkt) |
mildly suspicious | PINGPING... filler |
ICMP id=0x4b9c (8 pkt) |
mildly suspicious | the actual exfiltration |
HTTP /portal/login |
ordinary | carries the decryption key |
The flag string itself confirms the intended path: dns_t0_1cmp_h4nd0ff for the misdirection from DNS to ICMP, and l0g1n_d3r1v3d_k3y for the key coming out of the login request.
For the IR report: FIN-WS-04 (10.10.14.37) exfiltrated to 185.220.101.44 over ICMP echo requests using a custom protocol with an NJ01 magic, a 4-byte big-endian length, and an RC4 body keyed from a stolen internal session token — which implies the attacker already had a foothold inside portal.internal before exfiltration began. Detection opportunities are the unpaired echo requests, the varying payloads (unlike the fixed byte pattern a normal OS ping emits), and the ICMP identifier changing mid-session.
Flag
polriCTF26{dns_t0_1cmp_h4nd0ff_l0g1n_d3r1v3d_k3y}
Takeaway
The loudest artifact in a capture is often the one placed there to absorb your time. Before hunting for a key, spend a minute characterising the cipher: a ciphertext length that is not a multiple of 16 plus a flat index of coincidence rules out both block ciphers and repeating-key XOR in seconds, and saves you from searching for an XOR key that was never there.
Write-up: Berkas Perkara
Category: Forensics
Points: 310 (39 solves)
Challenge Overview
You are handed a case file. The team has collected digital evidence from the scene, but the key witness statement is not where it should be. The attachment is LP-2026-07-0451_paket_bukti.zip, and the briefing warns that one piece of evidence may be hidden inside another.
Evidence Collected
unzip -l LP-2026-07-0451_paket_bukti.zip
352300 BB-2026-0453_rekaman_audio.wav
14153 BB-2026-0452_screenshot_laptop.png
1113 BERKAS_ACARA_PEMERIKSAAN.txt
BERKAS_ACARA_PEMERIKSAAN.txtis the examination record, and it gives away the structure of the challenge in two lines. The suspect says “the evidence is safe, the key is only in my head and in that recording”, and the analyst notes that “the suspect’s laptop screenshot is an unusual size compared to ordinary screenshots”. Translated into CTF terms: the WAV is the key, not the flag, and the PNG is the ciphertext, padded out by something embedded in it.BB-2026-0453_rekaman_audio.wav: PCM 16-bit mono at 44100 Hz, about 4 seconds long, sounding like pure noise.BB-2026-0452_screenshot_laptop.png: 640x460 RGB, 14153 bytes. The screenshot itself repeats the same hint through a fake terminal:cat notes.txtprinting “don’t forget, the key is in that recording”.- PNG metadata makes it explicit:
tEXt Comment .. "Tersangka menyimpan sesuatu di gambar ini juga. Ukuran filenya tidak wajar untuk sekadar screenshot."
Analysis Path
Stage 1 — the WAV yields the key. The obvious approaches were tried first and all failed, which is itself informative: strings returned nothing, LSB extraction on the audio samples produced random garbage, and steghide extract with various passphrases reported that it could not extract any data. If the data is not in the time domain, check the frequency domain.
import wave, numpy as np, matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
with wave.open("BB-2026-0453_rekaman_audio.wav") as w:
rate = w.getframerate()
s = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(float)
plt.figure(figsize=(16, 8))
plt.specgram(s, NFFT=2048, Fs=rate, noverlap=1536, cmap="inferno")
plt.ylim(0, 8000)
plt.savefig("spectrogram.png", dpi=110, bbox_inches="tight")
Text is plainly readable in the 3 to 5.5 kHz band between 0.8 s and 3.1 s: SAKSI-99. Audacity or Sonic Visualiser produce the same result through their spectrogram views. The key is SAKSI-99.
Stage 2 — locating the data inside the PNG. For a synthetic screenshot made of flat colours, 14153 bytes is far too large. The usual containers were eliminated first:
| Check | Result |
|---|---|
Data after IEND |
0 bytes — no appended file |
| Decompressed IDAT vs required size (460 x (640x3 + 1) = 883,660) | exact match, no leftovers |
zsteg -a |
no plausible hit, only false-positive “OpenPGP Public Key” noise |
So the data really is in the pixels, and zsteg misses it because the payload is encrypted and therefore has no text or file signature to recognise. Checking each channel’s LSB statistics directly:
from PIL import Image
import numpy as np
a = np.array(Image.open("BB-2026-0452_screenshot_laptop.png").convert("RGB"))
for c, n in enumerate("RGB"):
print(n, (a[:, :, c] & 1).mean())
R lsb mean 0.5043 <-- random, carries data
G lsb mean 0.0069
B lsb mean 0.0063
Only the red channel has been touched. The colour histogram shows the same thing from another angle: the background colour (20, 22, 28) has a twin (21, 22, 28) appearing 323 times, which is the payload’s 1 bits.
>>> a[0, :12].tolist()
[[20,22,28], [20,22,28], [20,22,28], [20,22,28], [20,22,28], [20,22,28],
[20,22,28], [20,22,28], [20,22,28], [21,22,28], [20,22,28], [21,22,28]]
Recovery
- Read the payload from the red channel LSB in row-major order, MSB first:
stream = np.packbits(a[:, :, 0].reshape(-1) & 1).tobytes() print(stream[:96].hex())
The first two bytes,0050 e5e10ca39ecc28a94c13ecaf33ad4c24925f3b5919300fb7aac5897738e0d805 17aabc736a03dd8ae820b0369a0479aa909f7750b1b70074371bd736d2f3391e a2fc8d120af4b5482835812a54349b55 00000000000000000000000000000x0050, are 80, and exactly 80 bytes later the stream drops back to the background’s natural all-zero LSBs. The format is therefore a 16-bit big-endian length followed by an 80-byte payload — and 80 is 5 x 16, which smells like AES: one IV block plus four ciphertext blocks. - Combine both pieces of evidence. A random payload from the image and the key
SAKSI-99from the audio. What works is AES-256-CBC, with the key asSHA256("SAKSI-99"), the IV as the payload’s first 16 bytes, and the remaining 64 bytes as ciphertext under PKCS#7 padding (0x0ex 14).import hashlib from Crypto.Cipher import AES cipher = AES.new(hashlib.sha256(b"SAKSI-99").digest(), AES.MODE_CBC, payload[:16]) plain = cipher.decrypt(payload[16:]) plain = plain[:-plain[-1]] # strip PKCS#7 print(plain.decode())polriCTF26{sp3ktr0gr4m_m3ny1mp4n_suar4_s4ksi_b1su} - The full chain from unzip through spectrogram, LSB extraction, and AES is automated in
solve.py:$ python3 solve.py [*] spektrogram -> spectrogram.png (teks: SAKSI-99) [*] payload LSB: 80 byte [+] FLAG: polriCTF26{sp3ktr0gr4m_m3ny1mp4n_suar4_s4ksi_b1su}
Validation
- The explicit length header and the all-zero region immediately after it confirm the payload boundary exactly, with no guessing about where the data ends.
- Decrypting all 80 bytes with a zero IV instead of stripping the real one still produces the flag, with only the first 16 bytes turning to garbage. That is characteristic CBC behaviour and independently confirms that the first 16 bytes are indeed the IV.
- The decrypted plaintext matches the
polriCTF26{...}format exactly, with valid PKCS#7 padding.
Flag
polriCTF26{sp3ktr0gr4m_m3ny1mp4n_suar4_s4ksi_b1su}
Takeaway
Noise is not the same as absence of data — audio that sounds random should always get a spectrogram before you start pulling LSBs. Equally, zsteg staying quiet does not mean an image is clean: an encrypted payload has no signature to detect, so fall back to per-channel LSB statistics by hand. And in multi-stage cases, expect one piece of evidence to hold only the key for another, never the flag itself.
Write-up: The Manhunt
Category: Digital Forensics (Hard)
Points: 489 (5 solves)
Challenge Overview
Four pieces of evidence, each holding exactly one link in the chain, and none of them solvable alone. The briefing turns out to be entirely literal: “standard tools … come back clean” (binwalk, strings, and foremost genuinely find nothing interesting), “the location notes lead to a dead end” (the EXIF timestamps and a backup-route file are decoys), and “the packet moves, but look at WHEN it moves” (a covert timing channel).
The full chain:
pcap --(timing 45ms/145ms)--> 52 chars --(custom b64 alphabet)--> 052d8ac597631de9
img --(EXIF XPKeywords)----> Semarang / Bandung / Banjarmasin
log --(riwayat.log)--------> the correct visit order
|
+--> sha256("052d8ac597631de9|bandung|semarang|banjarmasin")
= LUKS2 passphrase --> ext4 "ARSIP" --> manifes_jalur.enc
|
core --(heap, kr-2026-04)--> 32-byte key --(ARX stream cipher)--+ --> FLAG
Evidence Collected
7z x -pinfected the-manhunt.zip -oex
barang_bukti_ws01.img 268 MB Linux ext4, volume "DATA"
relay_core.dump 775 KB ELF core, from '/home/rendi/.local/share/relay/relay session.dat'
LP-2026-08-0442_*.pcap 21 MB pcap, 28428 packets, 194 seconds
log_sistem_ws01.tar.gz 2.4 KB
| Evidence | What it actually holds |
|---|---|
LP-2026-08-0442_capture_safehouse.pcap |
courier code (covert timing channel) plus a custom Base64 alphabet |
barang_bukti_ws01.img |
a 16 MiB LUKS2 container disguised as a PNG, plus city labels in EXIF |
log_sistem_ws01.tar.gz |
the correct visit order |
relay_core.dump |
the stream cipher key that was supposedly wiped |
Inside log_sistem_ws01.tar.gz: var/log/auth.log holds 40 lines of apt-get update (pure noise), home/rendi/.bash_history holds 5 lines ending in history -c, and home/rendi/.config/galeri/riwayat.log is the one that matters.
Analysis Path
The 16 MiB “photo”. tsk_recover restores only 13 unremarkable files. The anomaly is in the inode metadata:
$ fls -r -p ex/barang_bukti_ws01.img
r/r 23: Gambar/foto_kegiatan_0812.png
r/r 28: .local/share/relay/relay
r/r 29: .config/systemd/user/relay.service
$ debugfs -R "stat <23>" ex/barang_bukti_ws01.img
Size: 16777249
EXTENTS: (0-4095):38912-43007, (4096):37005
16,777,249 bytes is 16 MiB plus 33 — a rather large screenshot.
00000000 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 .PNG........IHDR
00000010 00 00 07 80 00 00 04 38 08 02 00 00 00 67 b1 56 .......8.....g.V
00000020 14 4c 55 4b 53 ba be 00 02 ... .LUKS....
Those first 33 bytes are a PNG signature plus a fake 1920x1080 IHDR chunk, and immediately after them sits the magic LUKS\xba\xbe.
dd bs=1 skip=33 if=Gambar/foto_kegiatan_0812.png of=container.luks
cryptsetup luksDump container.luks
Version: 2
UUID: 5f3b1c92-7a4e-4d18-9c26-8b0e7f1a2d34
Data segment: offset 1048576, cipher aes-xts-plain64, sector 4096
Keyslot 0: pbkdf2 / sha256 / 10000 iterations, AF stripes 4000
A genuine LUKS2 volume with one keyslot, needing a passphrase. Grepping the raw image for the LUKS magic finds it at offset 0x9800021, which lets an automated solver skip TSK entirely.
The passphrase has to be assembled, not found. Dokumen/catatan_kurir.txt spells out the recipe: take the courier code sent over the relay channel (deliberately not written down here), then the city name from each stop photo’s label in lowercase, in visit order, joined with pipes, then SHA-256:
passphrase = sha256("<kode_kurir>|kota1|kota2|kota3")
So three things are missing: the courier code, the city names, and their order.
-
City names — EXIF
XPKeywords.exiftool -XPKeywords -GPSPosition -DateTimeOriginal ex/img/Gambar/IMG_*.jpgThe GPS coordinates agree with the labels, so the city names are honest. The photos themselves are generic skyline stock with no steganography and no trailing data after the EOI marker.
-
Courier code — a covert timing channel.
.config/systemd/user/relay.servicepoints at arelaybinary, andstringson it gives the peer list:relay.conf: mode=beacon retry=3 jitter=on tls=1 peer0=10.30.7.14:8443 peer1=10.30.7.22:8443 peer2=drop.local:443In the pcap, the stream from
10.30.7.14:51713to10.30.7.22:8443carries 417 beacon packets, each with a 4-byte payload holding nothing but a sequence number from0x00000000to0x000001a0. The payload is meaningless; the inter-packet gap is not.tshark -r *.pcap -Y "tcp.dstport==8443 && tcp.len==4" \ -T fields -e frame.time_epochThe delta histogram is cleanly bimodal with nothing at all between 70 and 120 ms:
30ms: 17 ## 40ms: 97 ############ 50ms: 86 ########### 60ms: 15 ## 70ms: 1 ---- gap ---- 120ms: 1 130ms: 15 ## 140ms: 86 ########### 150ms: 86 ########### 160ms: 12 #Short (about 45 ms) is
0, long (about 145 ms) is1. 416 deltas give 416 bits, or 52 bytes read MSB first:qIQayIjjSRQCHRSRq18sOXmklU86XZDsyZsj7oPcXRwdqevcWoCE52 characters, all alphanumeric — clearly not an accident. But
base64 -dreturns garbage. -
The custom Base64 alphabet. The answer is sitting in a plaintext HTTP request to the same peer:
tshark -r *.pcap -q -z follow,tcp,ascii,182GET /status HTTP/1.1 Host: 10.30.7.22 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) relayd/1.4; charset=wYhIVPo/vgiTqHO2z8FeQu1XSy0lW7+3JjLfrskExK9pNDc6nmaR4bZUdCAtGM5BThat
charset=value is 64 unique characters — a shuffled Base64 alphabet. Map it back onto the standard one and decode:custom = 'wYhIVPo/vgiTqHO2z8FeQu1XSy0lW7+3JjLfrskExK9pNDc6nmaR4bZUdCAtGM5B' std = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/' base64.b64decode(msg.translate(str.maketrans(custom, std)))052d8ac597631de9|foto_kegiatan_0812.pngThe courier code is
052d8ac597631de9, and the decoded string doubles as confirmation that the 16 MiB “PNG” really is the target. -
Visit order — the trap. The reflex is to sort by
DateTimeOriginal, giving semarang, bandung, banjarmasin. That is wrong, and it is exactly the “location notes leading to a dead end” the briefing mentioned. The real order is in~/.config/galeri/riwayat.log, roughly 180 lines of repeated accesses to the same decoy files (pengingat.txt,README.txt,state.json, and so on). The threeIMG_*.jpgfiles each appear exactly once, and that is the signal:Even without certainty the search space is only 3! = 6 permutations, so brute force is perfectly safe here.
Opening the container.
material = 052d8ac597631de9|bandung|semarang|banjarmasin
passphrase = f9721386ed09769e7f9c317206af613fb0e9f1ef2001ae079423bbc870ae12ce
printf '%s' "$passphrase" | cryptsetup luksOpen --test-passphrase container.luks
The master key comes back as 0634c724...59d9b9e7 (512 bit). Inside is an ext4 volume labelled ARSIP:
manifes_jalur.enc 399 bytes <- target
catatan_relay.txt 289 bytes
jalur_cadangan.txt 191 bytes
jalur_cadangan.txt holds a fake flag, polriCTF{safehouse_kosong} — note the format is polriCTF{ rather than polriCTF26{, and the file itself states the plan was cancelled. catatan_relay.txt gives the final pointer: the manifest is locked by the relay process, and its key was never written to disk — it only lived in memory for the duration of the session, unless it was captured from memory.
Reverse engineering relay. The binary is 14 KB, stripped, PIE x86-64, with four relevant functions.
gen_block at 0x12e5 is a 4x64-bit ARX permutation:
void gen_block(u64 key[4], u64 nonce, u64 counter, u8 out[32]) {
u64 a = key[0] ^ nonce;
u64 b = key[1] + counter;
u64 c = key[2] ^ 0x1badb0021dead0de; // movabs rdx, 0x1badb0021dead0de
u64 d = key[3] + nonce;
for (int i = 0; i < 8; i++) { // cmp dword [rbp-0x54], 7 ; jle
a += b;
d = rotl(d ^ a, 29); // mov esi, 0x1d
c += d;
b = rotl(b ^ c, 17); // mov esi, 0x11
a += d;
c = rotl(c, 41); // mov esi, 0x29
b += c;
}
memcpy(out, (u64[]){a, b, c, d}, 32);
}
keystream_xor at 0x1438 is the textbook wrapper: 32-byte blocks, counter incremented per block, output XORed with input.
wipe at 0x1550 is the heart of the challenge — an eraser that erases nothing:
void wipe(u8 *p, size_t n) {
for (size_t i = 0; i < n; i++)
g_accum ^= p[i]; // only XORs into a global in .bss
} // the buffer itself is never touched
The log line [23:47:11] manifes terkunci, salinan lokal dihapus is simply a lie: wipe() only reads.
main at 0x1694 ties it together:
decoy(); // keystream(key=00..1f, nonce=01..08) -> discarded
int fd = open(argv[1]); // session.dat
read(fd, buf, 0xc0); // 192 bytes
struct entry { char label[12]; u8 material[32]; }; // malloc(0x2c)
for (i = 0; i < 6; i++)
entries[i] = make_entry("kr-2026-0" + (i+1), buf + i*32);
memset(buf, 0, 0xc0); // REAL memset -> stack is clean
...
for (i = 0; i < 6; i++) wipe(entries[i], 0x2c); // heap is NOT clean
puts("relay: sesi aktif, menunggu beacon");
sleep(120);
The stack really is zeroed, but the six heap entries only get the fake wipe(), so all 192 bytes of session material survive intact in the core dump. The key and nonce in .rodata (00 01 02 ... 1f and 01 02 ... 08) are decoys — test vectors used only by decoy() to generate 16 bytes of keystream that is thrown away immediately.
Recovery
- Pull the keys out of the core dump.
$ python3 -c " import re d = open('relay_core.dump','rb').read() for m in re.finditer(rb'kr-2026-0\d\x00\x00', d): o = m.start(); print(hex(o), d[o:o+10], d[o+12:o+44].hex())"
One false match at0x4900 kr-2026-01 bbbb7461e1f54b13e255a5d434014125b468c3c5293625aba1896679a375df39 0x4980 kr-2026-02 f760de2fd010aae5bbbb85ebadd3a0b9c8c24e24f187697407bc068eb447a3ce 0x49c0 kr-2026-03 df5508f95ef8a3850608a13b1af153a974c59670d0333ed0f12b91421b1efc68 0x4a80 kr-2026-04 2e8080d8e495f0e4a530d902e93451cd73fb1c103cada510c3f3e30b0daea42e 0x4ac0 kr-2026-05 391d320fee3f9d79a0bb01242776e2f1cf441e691006cdf730c675c5e19dc79d 0x4b50 kr-2026-06 816eee73cca532b70b04e3553e36ed2ef764e67c370a45a7a434e49f185e6d010x2513is just the.rodatastring table caught in the dump; its 32 “material” bytes are ASCII text and trivially filtered out. The rest of the heap holds only therelay.confstring, the peer list, a peer struct (drop.local:443,seq=41,peer_idx=2), and decoy blocks of repeating0x42,0x43,0x44, and0x45. The stack is empty. These six blobs are the only candidates. - Decrypt the manifest.
manifes_jalur.encis 399 bytes, not a multiple of any block size, and repeating-XOR tests across key lengths 1 to 64 show no periodicity — consistent with a stream cipher and a unique keystream. The layout is an 8-byte nonce followed by ciphertext, and the plaintext begins with the magicMJLRas a validity marker.nonce = struct.unpack('<Q', enc[:8])[0] for label, key in blobs: pt = relay_xor(key, nonce, enc[8:]) if pt.startswith(b'MJLR'): print(label, pt[4:].decode())kr-2026-04is the match. - Read the manifest.
=== MANIFES JALUR - SALINAN TERENKRIPSI === Status : RAHASIA Dibuat : 2026-08-11 23:47 WIB Rute keluar (urut): 1. Bandung - titik jemput, kurir R. 2. Semarang - ganti kendaraan, dokumen kedua. 3. Banjarmasin - penyeberangan, kontak akhir. Kode verifikasi kurir: polriCTF26{r3l4y_h34p_k3y_unl0cks_th3_fug1t1v3_r0ut3} Hancurkan salinan ini setelah titik 3 tercapai.
Validation
- The
MJLRmagic in the decrypted plaintext confirms both the correct key blob and the correct nonce interpretation; the other five blobs produce garbage. - The manifest’s own route listing — Bandung, then Semarang, then Banjarmasin — independently confirms the visit order taken from
riwayat.log, and therefore confirms the passphrase construction after the fact. - The recovered flag uses the
polriCTF26{...}format, unlike thepolriCTF{...}decoy found inside the container.
This challenge is dense with decoys, and it is worth listing which ones cost time:
| Decoy | Why it convinces |
|---|---|
DateTimeOriginal in EXIF |
a chronological order that looks entirely reasonable, and is wrong |
polriCTF{safehouse_kosong} |
fake flag, distinguished only by the missing 26 |
key/nonce 00..1f and 01..08 in .rodata |
test vectors used solely by the decoy function |
| log line claiming the local copy was deleted | wipe() never deletes anything |
peer=drop.local:443 in the core dump |
the recorded active peer, while the data channel actually went to 10.30.7.22:8443 |
auth.log with 40x apt-get update |
pure noise |
heap blocks of 0x42/0x43/0x44/0x45 |
fake allocations obscuring the real entries |
jadwal_kapal.txt, daftar_kontak.txt, and friends |
ambience |
| 28,000 packets of real traffic (Tokopedia, Google, MSN) | hides the 417 beacon packets |
Tooling used: 7z, sleuthkit (fls, icat, tsk_recover), debugfs, exiftool, tshark, cryptsetup, x86_64-linux-gnu-objdump, and Python with cryptography and Pillow. The automated solve.py reimplements the pcap parser, ext4 reader, LUKS2 handling, and the cipher in pure Python.
Flag
polriCTF26{r3l4y_h34p_k3y_unl0cks_th3_fug1t1v3_r0ut3}
Takeaway
A function named wipe is not a wiped buffer. When a challenge insists in its logs that something was deleted, verify it at the instruction level — here the difference between a real memset on the stack and a read-only fake on the heap is the entire challenge. More generally, when a file size, an inter-packet gap, or a character set looks slightly off, that anomaly is the channel: 16 MiB of “screenshot”, a bimodal timing histogram with a clean gap, and a 64-character User-Agent fragment were each the only signal in an evidence item otherwise full of noise.
