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.local and metrics.cloudflare.com, both resolving to 1.2.3.4 and never contacted again.
  • DNS TXT queries: five sequentially numbered lookups, 0. through 4.s2.cdn-telemetry.net, each carrying a base32 blob.
  • HTTP: GET /portal/login?user=financebot&session=s3ac1f9b0e7d4426a8f1c05e9b3a7d2f4 to portal.internal (10.10.10.5), answered with 200 OK.
  • ICMP: traffic to 185.220.101.44 — a range known for Tor exit nodes — split across two different identifiers, 0x1234 (12 packets) and 0x4b9c (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 ping against a live host produces replies, and a normal ping process keeps one fixed identifier for its whole run.

Analysis Path

Each candidate channel was examined and eliminated in turn.

  1. DNS A queries — noise. metrics.cloudflare.com is ordinary telemetry. hanz-deadend.notes.local is literally named “deadend”. Neither IP received any follow-up traffic.

    Wireshark: DNS A queries ke hanz-deadend.notes.local dan metrics.cloudflare.com, keduanya resolve ke 1.2.3.4

  2. 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.

    Isi TXT record hasil decode base32: hanya comment banner TEROWONGAN / Stage-2 Loader, tanpa payload

    That is the entire content — a comment banner and nothing else. The dense-looking repeating pattern 2PJ5HU6T that dominates the blob is simply the character = (0x3D) put through base32, which is why a row of separator dashes looks like packed data. The name TEROWONGAN (“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.

  3. ICMP id=0x1234 — noise. All 12 packets carry an identical payload, 50494e4750494e47... decoding to PINGPINGPINGPING.... Pure filler, designed so that a quick glance at ICMP shows “PING” and moves on.

  4. ICMP id=0x4b9c — the real channel. Eight packets, and the first payload is immediately different:

    Hexdump payload ICMP id=0x4b9c paket pertama, diawali magic 4e4a3031 (NJ01)

    4e4a3031 is ASCII NJ01 — a magic header — and the last packet trails off into zero padding.

  5. 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

  1. Reassemble the covert stream. Sorting the id=0x4b9c packets by icmp.seq and concatenating their payloads yields 32 bytes per packet across 8 packets, or 256 bytes total, laid out as:
    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
    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.
  2. 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:
    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)
    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.
  3. 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 unhexlify output, 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:
    key = hashlib.sha256(b"s3ac1f9b0e7d4426a8f1c05e9b3a7d2f4").digest()[:16]
    #   = e7a0f0ba506eea66ce762a96c9751cd4
    plaintext = rc4(key, ciphertext)
    Two details decide success here. The token is used as a plain ASCII string exactly as it appears, including the leading s, rather than being unhexlified — that stray s is 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.
  4. 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.txt is 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.txt printing “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")

Spektrogram BB-2026-0453_rekaman_audio.wav: teks SAKSI-99 terbaca di pita 3-5.5 kHz antara 0.8 s dan 3.1 s

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

  1. 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())
    0050 e5e10ca39ecc28a94c13ecaf33ad4c24925f3b5919300fb7aac5897738e0d805
         17aabc736a03dd8ae820b0369a0479aa909f7750b1b70074371bd736d2f3391e
         a2fc8d120af4b5482835812a54349b55
    0000000000000000000000000000
    The first two bytes, 0x0050, 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.
  2. Combine both pieces of evidence. A random payload from the image and the key SAKSI-99 from the audio. What works is AES-256-CBC, with the key as SHA256("SAKSI-99"), the IV as the payload’s first 16 bytes, and the remaining 64 bytes as ciphertext under PKCS#7 padding (0x0e x 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}
  3. 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.

  1. City names — EXIF XPKeywords.

    exiftool -XPKeywords -GPSPosition -DateTimeOriginal ex/img/Gambar/IMG_*.jpg

    Output exiftool: XPKeywords Semarang/Bandung/Banjarmasin beserta GPSPosition dan DateTimeOriginal tiap IMG_*.jpg

    The 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.

  2. Courier code — a covert timing channel. .config/systemd/user/relay.service points at a relay binary, and strings on 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:443

    In the pcap, the stream from 10.30.7.14:51713 to 10.30.7.22:8443 carries 417 beacon packets, each with a 4-byte payload holding nothing but a sequence number from 0x00000000 to 0x000001a0. 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_epoch

    The 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) is 1. 416 deltas give 416 bits, or 52 bytes read MSB first:

    qIQayIjjSRQCHRSRq18sOXmklU86XZDsyZsj7oPcXRwdqevcWoCE

    52 characters, all alphanumeric — clearly not an accident. But base64 -d returns garbage.

  3. 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,182
    GET /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+3JjLfrskExK9pNDc6nmaR4bZUdCAtGM5B

    That 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.png

    The courier code is 052d8ac597631de9, and the decoded string doubles as confirmation that the 16 MiB “PNG” really is the target.

  4. 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 three IMG_*.jpg files each appear exactly once, and that is the signal:

    Tiga baris riwayat.log: IMG_4502 (Bandung), IMG_4471 (Semarang), IMG_4488 (Banjarmasin), masing-masing muncul tepat sekali

    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

  1. 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())"
    0x4900 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  816eee73cca532b70b04e3553e36ed2ef764e67c370a45a7a434e49f185e6d01
    One false match at 0x2513 is just the .rodata string table caught in the dump; its 32 “material” bytes are ASCII text and trivially filtered out. The rest of the heap holds only the relay.conf string, the peer list, a peer struct (drop.local:443, seq=41, peer_idx=2), and decoy blocks of repeating 0x42, 0x43, 0x44, and 0x45. The stack is empty. These six blobs are the only candidates.
  2. Decrypt the manifest. manifes_jalur.enc is 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 magic MJLR as 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-04 is the match.
  3. 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 MJLR magic 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 the polriCTF{...} 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.