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 stringKDF_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() + "}")
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()