RAM Vault Beacon (UNBR Quals 2026) — Linux Malware Forensics

A Linux malware sample forks into an anti-analysis tree, stores an encrypted flag in an anonymous mmap region, and beacons its C2 over DNS + HTTP. Recovering the flag took three artifacts: a PCAP, a memory dump, and the machine's /etc/machine-id — miss any one of them and the key derivation fails silently.

1. Malware overview

The binary forks into a tree immediately after launch:

main
├── fork 1.1  anti-analysis
│   ├── fork 1.1.1  process scanner + debugger check
│   │   scans /proc/*/comm for gdb, strace, ltrace, wireshark …
│   │   reads TracerPid from /proc/self/status
│   │   renames itself with PR_SET_NAME to a benign name
│   └── fork 1.1.2  env vars setup
│       exports TASK_ID, KDF_SALT, STAGE_ARGS_B64
└── fork 1.2  persistence + vault + C2
    ├── fork 1.2.1  creates the RAM vault
    │   mmap(0x800000, PROT_RW, MAP_ANON|MAP_PRIVATE)
    │   encrypts flag_bytes → stores ciphertext in the mapping
    │   zeroizes plaintext immediately after
    └── fork 1.2.2  C2 beacon
        ├── fork 1.2.2.1  DNS beacon (3 A-record queries)
        └── fork 1.2.2.2  HTTP POST to /beacon

The flag material never appears in plaintext anywhere on disk or in the network traffic. All three artifacts are needed to reconstruct the decryption key — the challenge splits them across a PCAP, a memory dump, and a disk image.

2. PCAP: extracting ts_window

The HTTP POST to /beacon contains a ts field — a windowed Unix timestamp:

ts_window = floor(epoch_seconds / 600) * 600

I read it directly from the POST body. With tshark:

# extract ts field from HTTP POST body
tshark -r capture.pcap -Y "http.request.method == POST" \
    -T fields -e http.file_data 2>/dev/null | \
    python3 -c "import sys,urllib.parse; [print(urllib.parse.unquote(l)) for l in sys.stdin]"

# or grep the raw value
tshark -r capture.pcap -Y "http.request.method == POST" \
    -T fields -e http.file_data | grep -oP 'ts=\K[0-9]+'

3. Memory dump: vault mapping and env vars

From the memory dump I needed three env vars from the malware process's /proc/PID/environ region, plus the vault mapping itself.

The env vars to extract:

  • TASK_ID — a UUID string
  • KDF_SALT — 32 hex chars (16 bytes)
  • STAGE_ARGS_B64 — base64-encoded argv blob

The vault is an 8 MiB anonymous private mapping (rw-p, no file backing, size exactly 0x800000). With Volatility 3:

# find the malware process
vol -f memory.lime linux.pslist

# list its memory maps, look for rw-p anonymous 0x800000
vol -f memory.lime linux.proc_maps --pid PID | grep "rw-p"

# dump the env vars (contains TASK_ID, KDF_SALT, STAGE_ARGS_B64)
vol -f memory.lime linux.envars --pid PID

# dump the 8 MiB vault region
vol -f memory.lime linux.proc_dump --pid PID --dump-dir /tmp/dump/

In a raw LiME dump without Volatility, scan for the vault magic directly — the 20-byte header always begins with 03 00 00 00 (version = 3, little-endian u32). That pattern is rare enough to locate the mapping without symbol support. I also pulled /etc/machine-id from the disk image — it feeds directly into key derivation.

4. Vault structure

The first 20 bytes of the 8 MiB mapping are the header, all little-endian u32:

struct vault_header {
    u32 version;   // = 3
    u32 nonce_len; // = 24
    u32 pt_len;    // = 32
    u32 ct_len;    // = 48  (32 plaintext + 16 AEAD tag)
    u32 crc32_ct;  // CRC32 of the ciphertext bytes
};

Immediately after the header: 24 bytes of random nonce, then 48 bytes of ciphertext (32 bytes payload + 16-byte Poly1305 tag).

I verified crc32_ct against the ciphertext before attempting decryption — if it doesn't match, you're looking at the wrong mapping. The flag is not stored as a readable string: flag_bytes is 32 raw bytes of CSPRNG output, presented as FLAG{hex(flag_bytes)} — 64 hex characters.

5. Key derivation chain

Everything uses plain SHA256. No HKDF, no fancy KDF — just chained hashes, which is actually quite clean to follow:

import hashlib, struct

machine_id = open("/etc/machine-id").read().strip().encode()
task_id    = b"<TASK_ID from env>"
kdf_salt   = bytes.fromhex("<KDF_SALT from env>")
stage_args = b"<STAGE_ARGS_B64 from env>"
ts_window  = <int from PCAP>

# 1. args_hash
args_hash = hashlib.sha256(stage_args).digest()

# 2. ts_window as 8-byte little-endian
ts_le8 = struct.pack("<Q", ts_window)

# 3. ikm = SHA256(machine_id || TASK_ID || ts_le8 || args_hash)
ikm = hashlib.sha256(machine_id + task_id + ts_le8 + args_hash).digest()

# 4. key = SHA256(KDF_SALT || ikm || b"rocsc/vault")
key = hashlib.sha256(kdf_salt + ikm + b"rocsc/vault").digest()

6. Decrypting the vault

The AEAD algorithm is XChaCha20-Poly1305 (libsodium's crypto_aead_xchacha20poly1305_ietf). The AAD is SHA256(machine_id || TASK_ID):

from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_decrypt

aad = hashlib.sha256(machine_id + task_id).digest()

# vault bytes: header (20) + nonce (24) + ciphertext (48)
nonce      = vault_bytes[20:44]
ciphertext = vault_bytes[44:92]  # 32 bytes payload + 16-byte Poly1305 tag

flag_bytes = crypto_aead_xchacha20poly1305_ietf_decrypt(
    ciphertext, aad, nonce, key
)

print("FLAG{" + flag_bytes.hex() + "}")
pynacl exposes XChaCha20-Poly1305 through its low-level bindings in nacl.bindings. The function expects ciphertext including the 16-byte tag, which matches the vault layout exactly. Alternatively, use libsodium directly via ctypes or pysodium.

The decryption itself came together quickly. What took time was tracking down all the pieces: ts_window from the PCAP, three env vars buried in the memory dump, and machine-id from disk.

7. Complete solve script

Takes the three artifact paths and a pre-extracted ts_window integer, then derives the key and decrypts the vault. The env vars and vault bytes must be extracted from the memory dump beforehand (Volatility or raw scan).

#!/usr/bin/env python3
"""
RAM Vault Beacon — Solve Script
Usage:
    python3 solve.py \
        --machine-id /path/to/machine-id \
        --task-id    <TASK_ID from memory dump> \
        --kdf-salt   <KDF_SALT from memory dump (hex)> \
        --stage-args <STAGE_ARGS_B64 from memory dump> \
        --ts         <ts_window from PCAP (int)> \
        --vault      <vault.bin — raw 8 MiB dump or just the header+nonce+ct>

pip install pynacl
"""

import argparse, hashlib, struct, zlib
from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_decrypt

def derive_key(machine_id, task_id, kdf_salt, stage_args, ts_window):
    ts_le8    = struct.pack("<Q", ts_window)
    args_hash = hashlib.sha256(stage_args.encode()).digest()
    ikm = hashlib.sha256(
        machine_id + task_id + ts_le8 + args_hash
    ).digest()
    return hashlib.sha256(kdf_salt + ikm + b"rocsc/vault").digest()

def parse_vault(data):
    # header: 5 x u32 little-endian = 20 bytes
    version, nonce_len, pt_len, ct_len, crc32_ct = struct.unpack_from("<5I", data, 0)
    assert version  == 3,  f"unexpected version {version}"
    assert nonce_len == 24, f"unexpected nonce_len {nonce_len}"
    assert ct_len   == 48, f"unexpected ct_len {ct_len}"

    nonce      = data[20 : 20 + nonce_len]
    ciphertext = data[20 + nonce_len : 20 + nonce_len + ct_len]

    # verify CRC32 of ciphertext before decryption attempt
    actual_crc = zlib.crc32(ciphertext) & 0xFFFFFFFF
    assert actual_crc == crc32_ct, \
        f"CRC32 mismatch: got {actual_crc:#010x}, expected {crc32_ct:#010x}"

    return nonce, ciphertext

def main():
    p = argparse.ArgumentParser()
    p.add_argument("--machine-id", required=True)
    p.add_argument("--task-id",    required=True)
    p.add_argument("--kdf-salt",   required=True, help="hex string (32 chars)")
    p.add_argument("--stage-args", required=True, help="base64 string from env")
    p.add_argument("--ts",         required=True, type=int, help="ts_window from PCAP")
    p.add_argument("--vault",      required=True, help="raw vault bytes file")
    args = p.parse_args()

    machine_id = open(args.machine_id).read().strip().encode()
    task_id    = args.task_id.encode()
    kdf_salt   = bytes.fromhex(args.kdf_salt)
    vault_data = open(args.vault, "rb").read()

    key  = derive_key(machine_id, task_id, kdf_salt, args.stage_args, args.ts)
    nonce, ciphertext = parse_vault(vault_data)
    aad  = hashlib.sha256(machine_id + task_id).digest()

    flag_bytes = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, aad, nonce, key)
    print("FLAG{" + flag_bytes.hex() + "}")

if __name__ == "__main__":
    main()