Flash CTF – Residual

Overview

The challenge provides a partial Windows filesystem from a compromised host. The important trail is:

  1. a downloaded payload cached by Windows Cryptnet URL cache,
  2. an obfuscated batch loader,
  3. a custom ransomware binary named Fusion Software Group,
  4. encrypted files under C:\Users\Public, and
  5. an $MFT resident ADS that reveals where one encrypted PDF originally came from.

The solve is a known-plaintext attack against a reused ransomware keystream. Once the keystream is recovered from VESTIGE.enc.pdf, it decrypts Flag.enc.pdf, which contains a QR code with the final flag.

Initial Triage

A deeper examination of the forensic artifacts revealed that a file had been downloaded using certutil, as both its metadata and content were recorded in:

Users\admin\AppData\LocalLow\Microsoft\CryptnetUrlCache\MetaData\9D6F390AF749577790ACA34686A5E68C and Users\admin\AppData\LocalLow\Microsoft\CryptnetUrlCache\Content\9D6F390AF749577790ACA34686A5E68C

The metadata points to:

http://skill.windows-update.bit:8484/windows-update.bat

The cached content is a heavily obfuscated batch script.

After deobfuscating the batch loader, it drops and executes a second-stage executable.

Reverse engineering the second stage shows that it is a custom ransomware sample branded as Fusion Software Group.

Ransomware Behavior

The ransomware only runs on the intended victim host. It checks:

MD5(Environment.MachineName) == 037e39a5c57380ea9357167684ca4dd7

Its file encryption behavior is:

  • target directory: C:\Users\Public
  • random 9-character secret generated with RandomNumberGenerator
  • 32-byte AES key derived through chained hashes and PBKDF2-HMAC-SHA512 with 100,000 iterations
  • one random 12-byte nonce generated per ransomware run
  • a 4 MiB keystream generated by AES-ECB over nonce || counter
  • file bytes XORed with that keystream
  • the nonce written at the beginning of each encrypted file
  • 16 random bytes appended after each encrypted chunk

The critical weakness is that the same AES key, nonce, and 4 MiB keystream are reused for all files encrypted in the same run. For full 4 MiB chunks, the keystream starts at offset 0, so one known plaintext file can recover enough keystream to decrypt other small encrypted files from the same run.

Finding Known Plaintext

Among the encrypted files, VESTIGE.enc.pdf is larger than 4 MiB. The $MFT also preserves a resident Zone.Identifier alternate data stream for the original VESTIGE.pdf.

The ADS contains:

[ZoneTransfer]
ZoneId=3
HostUrl=https://arxiv.org/pdf/2606.20006#pdfjs.action=download

That URL identifies the original PDF, so we can recover the plaintext VESTIGE.pdf and XOR it with VESTIGE.enc.pdf to recover the first 4 MiB of keystream.

Decryption Script

#!/usr/bin/env python3
import hashlib
import os
import sys

BLOCK_SIZE = 0x400000
LABEL = b"Fusion Software Group"


def plain_name_from_enc(path):
    return os.path.basename(path).replace(".enc.", ".")


def select_window(machine_name, file_name, length, nonce):
    if length >= BLOCK_SIZE:
        return 0

    material = (
        machine_name.encode("utf-8")
        + file_name.encode("utf-8")
        + length.to_bytes(4, "little")
        + nonce
        + LABEL
    )
    value = int.from_bytes(hashlib.sha256(material).digest()[:4], "little")
    return value % (BLOCK_SIZE - length + 1)


def recover_keystream(known_enc_path, known_plain_path, machine_name):
    plain_name = os.path.basename(known_plain_path)

    with open(known_plain_path, "rb") as f:
        known = f.read(BLOCK_SIZE)

    with open(known_enc_path, "rb") as f:
        nonce = f.read(12)
        first_ct = f.read(len(known))

    offset = select_window(machine_name, plain_name, len(known), nonce)
    keystream = bytearray(BLOCK_SIZE)
    known_mask = bytearray(BLOCK_SIZE)

    recovered = bytes(a ^ b for a, b in zip(first_ct, known))
    keystream[offset : offset + len(recovered)] = recovered
    known_mask[offset : offset + len(recovered)] = b"\x01" * len(recovered)

    print(f"[*] MachineName : {machine_name}")
    print(f"[*] Nonce       : {nonce.hex()}")
    print(f"[*] Plain file  : {plain_name}")
    print(f"[*] Offset      : {offset}")
    print(f"[*] Keystream   : {len(recovered)} bytes recovered")
    return bytes(keystream), bytes(known_mask)


def decrypt_file(enc_path, keystream, known_mask, machine_name):
    dst_path = enc_path.replace(".enc.", ".")
    plain_name = plain_name_from_enc(enc_path)
    chunks = []

    with open(enc_path, "rb") as f:
        nonce = f.read(12)
        while True:
            ct_with_tag = f.read(BLOCK_SIZE + 16)
            if not ct_with_tag:
                break
            if len(ct_with_tag) < 16:
                raise ValueError("truncated block: missing 16-byte tag")

            ct = ct_with_tag[:-16]
            offset = select_window(machine_name, plain_name, len(ct), nonce)
            if 0 in known_mask[offset : offset + len(ct)]:
                raise ValueError(
                    f"missing keystream coverage for {plain_name}: "
                    f"need offset={offset} len={len(ct)}"
                )

            chunks.append(bytes(a ^ b for a, b in zip(ct, keystream[offset : offset + len(ct)])))

    with open(dst_path, "wb") as f:
        for chunk in chunks:
            f.write(chunk)

    print(f"[+] Decrypted: {os.path.basename(enc_path)} -> {os.path.basename(dst_path)}")


if __name__ == "__main__":
    if len(sys.argv) != 5:
        print("Usage: python dec.py <known.enc> <known.plain> <target.enc> <machine_name>")
        sys.exit(1)

    print(f"[*] Recovering keystream from: {os.path.basename(sys.argv[1])}")
    keystream, known_mask = recover_keystream(sys.argv[1], sys.argv[2], sys.argv[4])
    decrypt_file(sys.argv[3], keystream, known_mask, sys.argv[4])
    print("[*] Done.")

Run it from the solve directory:

python .\dec.py .\VESTIGE.enc.pdf .\VESTIGE.pdf .\Flag.enc.pdf WIN10

Expected output:

[*] Recovering keystream from: VESTIGE.enc.pdf
[*] MachineName : WIN10
[*] Nonce       : c08d0e0058fe9a76d6471d68
[*] Plain file  : VESTIGE.pdf
[*] Offset      : 0
[*] Keystream   : 4194304 bytes recovered
[+] Decrypted: Flag.enc.pdf -> Flag.pdf
[*] Done.

Flag.pdf contains a QR code. Decoding it gives the flag.