Real World - A Real World Ransomware Case

real-worls Challenge: Real World
Category: Forensics / Reverse Engineering
Flag format: SK-CERT{}
Flag: SK-CERT{r4ns0mw4r3_d3f3473d}


Challenge Description

A company was hit by ransomware. They obtained the decryptor from the attacker, but many files remained unreadable even after decryption. We are given three files and must recover the contents of an encrypted PDF.

Files Provided

FileTypeSize
decoderELF 64-bit executable (self-extracting wrapper)~360 KB
most_important_company_data.pdf.attackerEncrypted PDF1,595,894 bytes
README.txtRansom note-

The ransom note contains a .onion address and a communication code (GYS-JKBWU-C0RG7-3FEDN), but these are irrelevant to the solution.

Hello!
You are very lucky to chat with real professionals!

All your files were encrypted.
These compromising company was downloaded to our servers.
Do not worry, we have no goal to destroy your business!
We understand that you value your reputation and our team will help you restore data and delete incriminating documents.

You can interior this message in this case, we will be forced to publish data.

You can contact us through Tor browser:

1. open onion site: 4p4vzt7kswq0fkqqdbejeptowspqumk4r1vjpb8tjnokiqg7qiiysxov.onion
2. enter code: GYS-JKBWU-C0RG7-3FEDN
3. write us!

Solution

Step 1 - Unwrapping the Decoder

The decoder binary is not the actual decryptor - it’s a self-extracting wrapper. Running strings and static analysis reveals it:

  1. Creates /tmp/snap-private-dbg/
  2. Drops several embedded files: dec, libcrypto.so.1.0.2, libssl.so.1.0.2, libgcc_s.so.1, libstdc++.so.6
  3. Sets LD_LIBRARY_PATH to point to the drop directory
  4. Calls execv() on the real dec binary

The real decryptor (dec) is embedded at file offset 0x1070 with size 0xCC08 (52,232 bytes). Extract it:

with open('decoder', 'rb') as f:
    f.seek(0x1070)
    dec = f.read(0xCC08)
with open('dec_extracted', 'wb') as f:
    f.write(dec)

Step 2 - Analyzing the Encrypted File Structure

The .attacker file has a specific structure. Searching for the magic marker DEAD0000BEEF reveals two metadata blocks appended to the original file data:

[0x000000 - 0x1851C5]  Original file data (1,593,798 bytes)
[0x1851C6 - 0x1855D5]  Metadata block 1 (1,040 bytes)
[0x1855D6 - 0x1855DD]  Marker: DEAD0000BEEF
[0x1855DE - 0x1859ED]  Metadata block 2 (1,040 bytes)
[0x1859EE - 0x1859F5]  Marker: DEAD0000BEEF

Each metadata block has the structure:

OffsetSizeField
0x004Flags (0)
0x044Fraction (percentage of file encrypted)
0x088Original file size
0x10512RSA-encrypted ChaCha20 key
0x210512RSA-encrypted ChaCha20 nonce

Critical insight: The file was encrypted twice - first with fraction=5 (5%), then with fraction=10 (10%). Each pass appended its own metadata block + DEAD0000BEEF marker. The official decryptor only strips one layer, which is why files remained unreadable.

Step 3 - Extracting the RSA Private Key

The decryptor binary contains a hardcoded RSA private key stored as a hex-encoded DER string in the .rodata section at virtual address 0x408D60 (file offset 0x8D60):

with open('dec_extracted', 'rb') as f:
    f.seek(0x8d60)
    raw = f.read(8192)
    hex_chars = set(b'0123456789abcdef')
    end = next(i for i, b in enumerate(raw) if b not in hex_chars)
    der_data = bytes.fromhex(raw[:end].decode('ascii'))

This yields a 4096-bit RSA private key (2,348 bytes DER, public exponent 65537).

Step 4 - Decrypting the Metadata

Using the RSA key with PKCS#1 v1.5 padding to decrypt both metadata blocks:

Outer layer (fraction=10, applied second):

  • Key: 607a12d185bfa5ba9df0fc505ada27e64a9c0a0e419834e35bd15734053f937f (32 bytes)
  • Nonce: d0629fe366b59ea0 (8 bytes)

Inner layer (fraction=5, applied first):

  • Key: 2cc479849b120d66124b236510b67115ec42eb7033df6ee8022a58601034e1fb (32 bytes)
  • Nonce: 46794038666d6c80 (8 bytes)

Step 5 - Reverse Engineering the Encryption Algorithm

Ghidra decompilation of dec_extracted reveals the cipher is a modified ChaCha20 with three key differences from standard ChaCha20:

1. Custom Constants

Standard ChaCha20 uses "expand 32-byte k"[0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]

This ransomware uses custom constants derived from the string @tt@c%$r00l0c%$r:

[0x40747440, 0x72242563, 0x306c3030, 0x72242563]

Visible in the assembly at 0x405995:

movabs rax, 0x7224256340747440   ; '@tt@c%$r'
mov    [rbp-0xab8], rax
movabs rax, 0x72242563306c3030   ; '00l0c%$r'
mov    [rbp-0xab0], rax

2. Arithmetic Right Shift (SAR) Instead of Logical (SHR)

The rotations in the quarter-round function use arithmetic right shift (sar) instead of the standard logical right shift (shr). This was the hardest part to identify. In the disassembly:

shl  edi, 0x10
sar  r12d, 0x10    ; <-- SAR not SHR!
or   r12d, edi

In Python, sar for 32-bit values extends the sign bit:

def sar32(x, n):
    if x >= 0x80000000:
        x = x - 0x100000000
    return (x >> n) & 0xFFFFFFFF

3. Nine Double-Rounds (18 total) Instead of Ten (20)

The loop counter is initialized to 9:

mov dword [rsp - 0x64], 9    ; at 0x4081a0

Standard ChaCha20 performs 10 double-rounds (20 quarter-rounds total); this variant does 9.

Step 6 - Understanding the Partial Encryption Scheme

The ransomware divides the file into fixed-size chunks and only encrypts a percentage of them, controlled by the -f flag:

Chunk size is determined by file size in MB:

File sizeChunk size
< 1 MB0x1000 (4 KB)
1–10 MB0x2000 (8 KB)
10–100 MB0x4000 (16 KB)
> 100 MB0x10000 (64 KB)

Our file is ~1.52 MB → chunk size = 0x2000 (8,192 bytes).

The encryption loop maintains a percentage counter (pct_counter) cycling 0–99. A chunk is encrypted only if pct_counter <= fraction:

for each chunk at offset:
    if pct_counter > 99: pct_counter = 0
    if pct_counter <= fraction:
        encrypt chunk with ChaCha20 keystream
    offset += chunk_size
    pct_counter += 1
    chunk_index += 1

The ChaCha20 state’s counter word (position 12) is set to the chunk index, so each chunk gets a unique keystream.

Step 7 - Writing the Decryptor

Decryption reverses both layers. Since the outer layer (fraction=10) was applied second, we decrypt it first, then the inner layer (fraction=5):

def decrypt_layer(data, key_bytes, nonce_bytes, fraction, orig_size, chunk_size):
    constants = [0x40747440, 0x72242563, 0x306c3030, 0x72242563]
    key_words = list(struct.unpack('<8I', key_bytes))
    nonce_words = list(struct.unpack('<2I', nonce_bytes))

    offset = 0
    chunk_idx = 0
    pct = 0

    while offset < orig_size:
        if pct > 99:
            pct = 0
        if pct <= fraction:
            state = constants + key_words + [chunk_idx, 0] + nonce_words
            end = min(offset + chunk_size, orig_size)
            for i in range(end - offset):
                if i % 64 == 0:
                    keystream = chacha_block(state)  # SAR-based variant
                data[offset + i] ^= keystream[i % 64]
        offset += chunk_size
        chunk_idx += 1
        pct += 1

Step 8 - Recovering the PDF

Running the decryptor produces a valid PDF starting with %PDF-1.3. Extracting text with pdftotext: flag-image

SK-CERT{r4ns0mw4r3_d3f3473d}

Why the Official Decryptor Failed

The ransomware encrypted files multiple times with different fraction values. Each encryption pass appended a new metadata block + DEAD0000BEEF marker. The official decryptor reads only the last metadata block (the outermost layer) and decrypts that single layer, then truncates the file. This successfully removes the outer encryption, but the inner encrypted layer remains - leaving the file corrupted.

To fully recover files, all layers must be decrypted in reverse order.


Tools Used

  • radare2 / Ghidra - Reverse engineering the decryptor binary
  • Python 3 + cryptography library - RSA decryption of metadata
  • Python 3 - Custom ChaCha20 decryption implementation
  • pdftotext - Extracting text from the recovered PDF

Key Takeaways

  1. Always check if a “decryptor” is a wrapper around the actual binary
  2. Ransomware may encrypt files multiple times - the decryptor might not handle all layers
  3. Custom cipher modifications (constants, shift types, round counts) require careful analysis of the actual assembly, not just pattern-matching against known algorithms
  4. The use of arithmetic right shift instead of logical right shift was the most subtle and critical difference to identify

Lesser Less

Challenge

lesser-less

I found this light weight version of less

We are given a single ELF binary called less.

Initial Analysis

$ file less
less: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, not stripped

The binary is not stripped, so symbol names are preserved. Listing the symbols reveals a functional less-style file pager alongside suspicious extras:

  • execute_phrase_command - a thin wrapper around system()
  • decode_phrase_from_file - decodes a hidden phrase from a viewed file
  • sha256_transform, sha256_init, sha256_update, sha256_final, sha256_hex - a full SHA-256 implementation
  • SHA-256 helper primitives: rotr, ch, maj, ep0, ep1, sig0, sig1

Running strings on the binary dumps 40 hex strings, each 64 characters long (SHA-256 digests), stored in a global array called TARGET_HASHES.

Reverse Engineering

main()

The main function:

  1. Takes a filename argument and loads it with file_buffer_load
  2. Calls decode_phrase_from_file(filename, output_buf, 0x80)
  3. If decoding succeeds (returns 0), passes output_buf to execute_phrase_command
  4. Enters the normal pager loop (render page, read key, navigate)

execute_phrase_command()

Trivial function - checks the input pointer is non-null and the first byte is non-zero, then calls system() on it.

decode_phrase_from_file()

This is the core of the challenge. The algorithm:

  1. Reads the entire file into memory via read_file_bytes
  2. Iterates through 40 target hashes (indices 0 to 0x27)
  3. For each target hash, slides a 2-byte window across every position in the file:
    • Extracts file[i] and file[i+1]
    • Computes SHA-256(file[i..i+2]) (hash of the 2-byte pair)
    • Compares the hex digest against TARGET_HASHES[current_index]
  4. If a match is found, both bytes are appended to the output buffer
  5. If any hash has no match in the file, the function returns -1 (failure)
  6. If all 40 hashes match, the output is null-terminated and returned (80 bytes total)

Solution

Since each target hash is the SHA-256 of just 2 bytes, there are only 65,536 (256 x 256) possible inputs. This is trivially brute-forceable.

Solve Script

import hashlib

target_hashes = [
    "1eb85f4d6a3234ce7acb8c51c75930f12e952517e2e389914a6ca8f89a881a0d",
    "a821c62e8104f8519d639b4c0948aece641b143f6601fa145993bb2e2c7299d4",
    "53eee21f3057e0fc3933f53a171ce2da0e4b4e7ad6553fe03967cf584fdc0dc8",
    "509bcf065e86b5932aa2aaa378974bb1bb987b01f328d47db2e88a0d93b1c4bf",
    "cc7e4412564ba8a761bd32ab4cc6086bac3c2c9e580367e0b0eb32a4316f9154",
    "f3401e2083d1f1d4a098c1091a3a9592d0c197a98d0f352d5f97be8c5ddf63d1",
    "fa51fd49abf67705d6a35d18218c115ff5633aec1f9ebfdc9d5d4956416f57f6",
    "dd8d69f25e9c009f1e8ec0f9febea1ea6d5eff1a52a73cd216b0c74d69eed078",
    "372f7e2fd2d01ce2a1d71dc072acbba4c6fd25a1087cd7f153f4ec0ce37e1ede",
    "f4ba4ba27509eb704c9492c2c5751a1a3e87c1ff39e32188db224889ae982bac",
    "b737ca7ee563ae80e457bb3d1dfe64edd2b4c015a8f88b6f87d5c113b68897fd",
    "2f3fb723b041289c541900b6f2b74aae4b7ffb8f162052959d4f1cbe33299e08",
    "26bf6298ad7005051b39684331a7b1593cd3b346dcb93808c38d451d134c0589",
    "ec4f8e3d1f7cafbdb86e908b6ad83e407ac36a9b3efabd8e24b56d80228be429",
    "593f2d04aab251f60c9e4b8bbc1e05a34e920980ec08351a18459b2bc7dbf2f6",
    "9aaf680776b98fd17fe63376120525cbcdffc01bc66f71df96b6e90b87f39b86",
    "3c247edb35b2ce6c5908166407e643baa9bd99edd0c45b08d900d306ec1ff5b0",
    "d7939186d51d823b7c9c8e8ba5ca31e67ea39b1a1781a94c3364c22efa9c73b2",
    "cb7f854a5192079175cec3a0ba69e9667978d6dd839cbfed07125e26b693dbaa",
    "ed454029deb6faa059964ece2051ca8707e3730a8d40f9d03a6e3dc1c16eb7ed",
    "0608350f54af8a6e7b053e85c3938b455f5246a47d6b3007bd857ccd84ffa1e7",
    "d68956cd067ac773650ef6f94387fd88662528204b65eae20165055281041e0b",
    "3165be96f5833ae07a93e8baa073df6bae2dc719814af10288f844d637d8721a",
    "b3e89a210f5dae7868727fc8330aedb08c5b63e549222ab5c674f54b6dc47ba3",
    "8c1f1046219ddd216a023f792356ddf127fce372a72ec9b4cdac989ee5b0b455",
    "6ab9f1eb8f7d3388f4f9d586f66e99fd54080df2c446f0e58668b09c08a16dd0",
    "2d8e452e1634cae42e17cc9ec974afed94029e20e98fde4444706e3efac1bf77",
    "6bda26c40fcf8754216a72f9cf9398bbaf5ed9aae1bea679b46d13fe406016a4",
    "c1818d580d8c8bc111302f4a5e6903ef2d32b11a5613efba507693de8060fb8c",
    "df047a21e1ac3c06c72c8d0ca57cd25f2056fad002af2630dcc5a12bf8f57420",
    "de3246094525b21a870fc7d2a67490d0132535c6fa5993755c549f1a9d1bd8af",
    "f451a61749c611ba0fa0e16c61831db44f38c611dff25879cf271a24c81a88b6",
    "d0aeeb52df9a031d0b8e374d811f2f8516fd68031c889f266d6afa28c459501e",
    "a4d0ef23161b5b7c6a8d5b287543fd74e16b3bf313d71aa187c24cdd728a7b1e",
    "0be477336d3e1a8d45d820aa54755092e2ec5e0751ccdf93a8fa8fa1e10bd753",
    "7f2253d7e228b22a08bda1f09c516f6fead81df6536eb02fa991a34bb38d9be8",
    "3ad4ee182e21c25db763cda6359ecc441b8ea32ea4d6631c012aac7fa7d362dc",
    "027ca1cbf735e5fa987a1e35ed2d28bc94b91d0b44a5d128f77990b09e885316",
    "4523540f1504cd17100c4835e85b7eefd49911580f8efff0599a8f283be6b9e3",
    "d80a5f6da0d466170689d9dfc58603afca3252c6b54ecc008a6d30caaa6cddde",
]

target_set = set(target_hashes)

# Brute-force SHA-256 of all 65536 two-byte combinations
hash_to_bytes = {}
for i in range(256):
    for j in range(256):
        pair = bytes([i, j])
        h = hashlib.sha256(pair).hexdigest()
        if h in target_set:
            hash_to_bytes[h] = pair

# Reconstruct the hidden command
result = b""
for th in target_hashes:
    result += hash_to_bytes[th]

print(result.decode())

Output

echo 'where is the flag?' > flag.txt # SK-CERT{l99k1n6_f0r_h1dd3n_func710n4l17y}

The decoded payload is a shell command. The flag is hidden in a trailing comment after #, meaning system() would silently carry the flag in the command string while only executing the echo portion.

Key Takeaways

  • The binary is a functional less clone with a backdoor: when opening a file, it silently tries to reconstruct and execute a hidden shell command by matching 2-byte SHA-256 hashes found in the file against a hardcoded table.
  • SHA-256 of 2-byte inputs is trivially reversible via brute-force (only 65,536 possibilities).
  • The hashes can be extracted directly from the .rodata / .data sections without needing a full decompiler — objdump and readelf are sufficient.

Flag

SK-CERT{l99k1n6_f0r_h1dd3n_func710n4l17y}

Lock Screen

Summary

droidlock The APK was a fake Android lock-screen/recovery app. Static reversing gave useful context, but the winning path was the remote /init endpoint: changing request headers changed the server keystream, and with a long enough h value the full repeating flag string appeared after XOR.

Environment

  • OS: Linux
  • Target file: droid.apk
  • Core tools used: file, unzip, strings, jadx, curl, python3

Step 1: Recon and decompilation

Commands

ls -la
file droid.apk
unzip -l droid.apk | head -n 80

Key output

droid.apk: Android package (APK), with gradle app-metadata.properties
... classes.dex ... AndroidManifest.xml ...

Decompiled with JADX:

mkdir -p /tmp/jadx_tool /tmp/jadx_out
wget -q -O /tmp/jadx.zip https://github.com/skylot/jadx/releases/download/v1.5.0/jadx-1.5.0.zip
unzip -oq /tmp/jadx.zip -d /tmp/jadx_tool
/tmp/jadx_tool/bin/jadx -d /tmp/jadx_out droid.apk

Important finding from source

MainActivity and RecoveryManager showed the app computes a decryption key from:

  • user input (hex)
  • server key from http://exp.cybergame.sk:7090/init?h=...
  • device identifier

This explained the lock workflow, but did not directly produce the final CTF flag.

Step 2: Follow the hint about headers

Challenge hint said headers matter. I tested /init with different request headers.

OpenAPI discovery

python3 - << 'PY'
import requests
for p in ['/openapi.json','/docs','/redoc']:
    r=requests.get('http://exp.cybergame.sk:7090'+p,timeout=8)
    print(p, r.status_code, r.headers.get('content-type'))
PY

Relevant output

/openapi.json 200 application/json
/docs 200 text/html; charset=utf-8
/redoc 200 text/html; charset=utf-8

/openapi.json confirmed only /init GET/POST with query parameter h, so the trick had to be in request header handling and response encoding.

Step 3: Recover the server keystream behavior

For the same h, the response changes by header profile.

I tested and confirmed this relation:

  • response bytes = h_bytes XOR constant_for_header_profile
  • so constant = response XOR h

When using normal headers, the constant decoded to:

You are not a robot :DYou are no

When using Android-style headers, the constant started with:

SK-CERT{4ndr01d_r4n50mw4r3_w1th_

That strongly indicated we were reading a repeated hidden flag stream but only seeing 32 bytes due 64-hex response length.

Step 4: Extend input length to dump more keystream and extract full flag

The endpoint output length tracks input length. So I sent a much longer h and XORed response with input.

Final solve script (complete, reproducible)

import requests
import re

headers = {
    "User-Agent": "Dalvik/2.1.0",
    "Referer": "android-app://com.example.safedroidlockctf",
}

# 512 hex chars = 256 bytes, enough to show repeated full token
h = "ab" * 256

r = requests.get(
    "http://exp.cybergame.sk:7090/init?h=" + h,
    headers=headers,
    timeout=10,
)
r.raise_for_status()

hex_out = r.text.strip().strip('"')
out = bytes.fromhex(hex_out)
hb = bytes.fromhex(h)
const = bytes(a ^ b for a, b in zip(out, hb))
text = ''.join(chr(c) if 32 <= c < 127 else '.' for c in const)

m = re.search(r"SK-CERT\{[^\}]*\}", text)
if not m:
    raise SystemExit("Flag not found")

print(m.group(0))

Input used

  • URL: http://exp.cybergame.sk:7090/init
  • Query h: ab repeated 256 times (512 hex chars)
  • Headers:
    • User-Agent: Dalvik/2.1.0
    • Referer: android-app://com.example.safedroidlockctf

Output observed

const length 256
SK-CERT{4ndr01d_r4n50mw4r3_w1th_s4f3_ch3ck}SK-CERT{4ndr01d_r4n50mw4r3_w1th_s4f3_ch3ck}...
flag_match SK-CERT{4ndr01d_r4n50mw4r3_w1th_s4f3_ch3ck}

Why this works

  • The backend uses a header-dependent XOR stream.
  • Short h only leaks the first 32 bytes of that stream.
  • Long h reveals enough bytes to include the full SK-CERT{...} token and repeat pattern.

Dead ends that were checked

  • Direct APK signing cert extraction (keytool -printcert -jarfile droid.apk) -> unsigned JAR style, not useful.
  • Looking for static embedded SK-CERT{...} in APK/DEX strings -> no complete flag.
  • Brute forcing common API headers without extending h -> only partial prefix found.

Flag

SK-CERT{4ndr01d_r4n50mw4r3_w1th_s4f3_ch3ck}

Flappy

Challenge Information

  • Goal: Analyze a Flappy Bird clone built in Rust compiled to WebAssembly, identify the credential exfiltration mechanism, and recover the flag via a known-plaintext XOR attack. flappy

Overview

The challenge presents a Flappy Bird game running in the browser via a Rust/WebAssembly module. The description hints that something is off about the game. The actual vulnerability is a fake “Score Sync” Google login overlay that XOR-encrypts and exfiltrates any credentials entered into it. The XOR key is the flag itself, which can be recovered by a known-plaintext attack once you understand the encryption scheme.


Step 1 - Page Source Reconnaissance

The first thing to do on any web challenge is read the page source.

curl http://exp.cybergame.sk:7010/

This immediately reveals a comment in the HTML:

<!-- TODO: remove before production flag{n0t_th3_r34l_fl4g_l0l} -->

This is a decoy. Note it for what it is and move on.

The page also contains the following script that loads the WASM module:

<script type="module">
    import init from './pkg/flappy_wasm.js';
    (async()=>{ await init() })();
</script>

There is also a hidden “Score Sync” overlay in the page - a fake Google login form that appears after gameplay, prompting the user for their email and password. This is immediately suspicious.


Step 2 - Downloading and Inspecting the WASM Binary

Download the binary:

curl http://exp.cybergame.sk:7010/pkg/flappy_wasm_bg.wasm -o flappy_wasm_bg.wasm
file flappy_wasm_bg.wasm

Output:

flappy_wasm_bg.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

File size is approximately 158 KB.

String Extraction

Run strings on the binary to get a quick view of embedded data:

strings flappy_wasm_bg.wasm

Key strings found:

/assets/app.conf
app
encryption_key
bad key:
oauth
client_id
redirect_uri
grant_type
authorization_code
code
userAgent
POST
/api/v1/oauth/token
Content-Type
application/json
score-sync
style
display:flex
flappy_wasm::do_exfil
btoa

What this tells us immediately:

  • The game fetches /assets/app.conf at startup to retrieve an encryption_key
  • A function named do_exfil exists - this sends data somewhere
  • The target endpoint is /api/v1/oauth/token
  • btoa is referenced, meaning data is base64-encoded before being sent
  • The hidden form reads from fields named sc-email and sc-pass

Step 3 - Disassembling the WASM to WAT

Convert the binary to WebAssembly Text format for readable disassembly:

wasm2wat flappy_wasm_bg.wasm -o flappy_wasm_bg.wat

Analyzing the init_modal closure in the WAT output confirms the full flow:

  1. On game start, fetch /assets/app.conf, parse JSON, extract encryption_key
  2. When the score sync form is submitted, read the email and password from sc-email and sc-pass
  3. Concatenate credentials as email:password
  4. XOR the concatenated string byte-by-byte with the encryption key (cycling through the key)
  5. Base64-encode the result using btoa
  6. POST it to /api/v1/oauth/token as the code field, along with client_id, redirect_uri, ua (user agent), res (screen resolution), and ts (timestamp)

Step 4 - Dynamic Analysis

Fetching app.conf

Run the following in the browser console while on the challenge page:

fetch('/assets/app.conf').then(r => r.json()).then(console.log)

Response:

{
  "oauth": {
    "provider": "google",
    "client_id": "894271536842-k3jhf9x2v7m1n5p4q6r0s8t.apps.googleusercontent.com",
    "client_secret": "GOCSPX-xK9mQ2nRghjkk8jL5dT1wY6uI0",
    "redirect_uri": "http://exp.cybergame.sk:7010/callback"
  },
  "app": {
    "name": "Flappy Bird Score Tracker",
    "encryption_key": "8849790b3c85b6ced19ab38ae4048f84219a73b84da6679ef0aa9b90f43c6facb1b07b420dcd958e9bf0d3"
  }
}

The encryption_key here is a red herring. Analysis of the WASM confirms it is never actually used as the XOR key. It is there to mislead.

Intercepting the POST Request

Play the game until the Score Sync overlay appears, then submit the form with known credentials:

  • Email: root@root.com
  • Password: roottoor

Using the browser’s network tab or a proxy (Burp Suite / mitmproxy), capture the POST request to /api/v1/oauth/token.

Captured request body:

{
  "grant_type": "authorization_code",
  "code": "ISRCNwUgOxQNHhYdMlkAXAtHGlgGRg==",
  "client_id": "894271536842-k3jhf9x2v7m1n5p4q6r0s8t.apps.googleusercontent.com",
  "redirect_uri": "http://exp.cybergame.sk:7010/callback",
  "ua": "Mozilla/5.0 ...",
  "res": "2400x1350",
  "ts": 1775286898983
}

The code field contains the base64-encoded XOR-encrypted credentials.


Step 5 - Known-Plaintext XOR Attack

Theory

XOR encryption has a fundamental weakness: if you know both the plaintext and the ciphertext, you can trivially recover the key:

key = ciphertext XOR plaintext

We know exactly what plaintext was fed into the encryption function:

root@root.com:roottoor

The WASM concatenates credentials as email:password, so the plaintext is root@root.com:roottoor (22 bytes).

First Pass - Partial Key Recovery

import base64

code = 'ISRCNwUgOxQNHhYdMlkAXAtHGlgGRg=='
encrypted = base64.b64decode(code)
plaintext = b'root@root.com:roottoor'

key = bytes([encrypted[i] ^ plaintext[i] for i in range(len(plaintext))])
print(key.decode())

Output:

SK-CERT{y0ur_cr3d3n7i4

We have 22 bytes of the key. The key is longer than our plaintext, so we can only see as many key bytes as there are ciphertext bytes. The ciphertext length equals the plaintext length, so we need a longer plaintext to expose more of the key.

Second Pass - Extending the Plaintext

Resubmit the form with a longer password to produce a longer ciphertext, exposing more key bytes:

  • Email: root@root.com
  • Password: roottoorAAAAAAAAAAAAAAAAAAAAAAAAAA

Captured code value:

ISRCNwUgOxQNHhYdMlkAXAtHGlgGRi0yHnUzch4nLXUxMSgvJh51NnU4PBIKbAIE
import base64

code = 'ISRCNwUgOxQNHhYdMlkAXAtHGlgGRi0yHnUzch4nLXUxMSgvJh51NnU4PBIKbAIE'
encrypted = base64.b64decode(code)
plaintext = b'root@root.com:roottoorAAAAAAAAAAAAAAAAAAAAAAAAAA'

key = bytes([encrypted[i] ^ plaintext[i] for i in range(len(encrypted))])
print(key.decode())

Output:

SK-CERT{y0ur_cr3d3n7i4ls_4r3_fl4pping_4w4y}SK-CE

The key repeats - the trailing SK-CE is the start of the flag cycling back to the beginning, which confirms we have recovered the full key. The XOR key cycling back on itself proves the flag is exactly 43 characters long.


Flag

SK-CERT{y0ur_cr3d3n7i4ls_4r3_fl4pping_4w4y}

Vulnerability Summary

StageDetail
HTML sourceDecoy flag in HTML comment to distract
WASM stringsdo_exfil, /assets/app.conf, btoa, /api/v1/oauth/token identified
WAT disassemblyXOR cipher + base64 encode flow confirmed in init_modal
app.confContains a red herring encryption_key; real key is the flag itself
Known-plaintext attack (pass 1)XOR of ciphertext with known 22-byte credential string recovers partial flag
Known-plaintext attack (pass 2)Longer credential string extends ciphertext, exposing the full flag

Tools Used

  • curl - download page source and WASM binary
  • strings - quick surface-level analysis of binary content
  • wasm2wat (from the WebAssembly Binary Toolkit) - disassemble WASM to readable WAT format
  • Browser DevTools / Burp Suite - intercept the POST request and capture the code field
  • Python (stdlib only) - base64 decode and XOR arithmetic

Key Takeaways

The challenge is thematically elegant. The game is literally “flapping your credentials away” - the score sync overlay is a fake phishing form, the flag is the XOR key used to encrypt the stolen credentials, and the known-plaintext attack is the intended path to recovery.

The two deliberate misdirections are worth noting:

  • The HTML comment flag (flag{n0t_th3_r34l_fl4g_l0l}) is placed at exactly the spot a lazy participant would stop looking.
  • The encryption_key in app.conf is a plausible-looking hex string that is never actually used, intended to send reverse engineers down a wrong path.

The real solution requires going one layer deeper: disassemble the WASM, understand the actual cipher used, intercept a live request with known input, and apply basic XOR cryptanalysis.

HAPPY HACKING!