Flash CTF – Karsten

Challenge files

The challenge archive contains only two evidence files:

Chrome.DMP       Windows minidump of a process named Chrome
network.pcapng   Packet capture collected from the same host

There is no executable or configuration file in the archive. We therefore begin with the memory dump, recover the executable that produced it, and reverse the program before trying to interpret the network traffic.

1. Inspect the process dump

Opening Chrome.DMP in WinDbg and listing the loaded modules with lm shows a module named Chrome at 0x140000000. The CLR-related modules (clrmscorlib, and mscoree) also show that this is a .NET process.

Chrome process dump opened in WinDbg

The first module loaded into a process is normally its main executable. We can therefore carve the Chrome module from the dump.

alt text

The recovered executable is protected with ConfuserEx: symbols are renamed, control flow is flattened, constants are encrypted, and anti-tamper protection is enabled. The fact that the module came from a running process helps us here, because the CLR has already loaded and decrypted the protected method bodies in memory. Tools such as de4dot-cex and dnSpyEx can then be used to clean up the remaining obfuscation and inspect the code.

2. Reverse the program

After cleaning the assembly with de4dot and opening it in dnSpy, we find the assembly entry point at token:

0x0600003D

In the cleaned assembly, this token resolves to:

Class5.Main(string[] args)

Main contains little logic. It calls one method:

Class5.smethod_0();

This makes Class5.smethod_0 the main dispatcher.

Most of the surrounding methods are short proxies. Renaming each wrapper after the API it calls makes the dispatcher easier to read:

Cleaned methodWrapped APISuggested name
Class5.smethod_3Console.WriteLinePrintLine
Class5.smethod_4AppDomain.CurrentDomainGetCurrentDomain
Class5.smethod_5AppDomain.BaseDirectoryGetBaseDirectory
Class5.smethod_6Path.CombineCombinePath
Class5.smethod_7File.ReadAllBytesReadAllBytes
Class5.smethod_8Thread.SleepSleep
Class5.smethod_9Encoding.ASCIIGetAscii
Class5.smethod_10Encoding.GetBytesAsciiGetBytes
Class5.smethod_17new TcpClient()CreateTcpClient
Class5.smethod_18TcpClient.ConnectConnect
Class5.smethod_19TcpClient.GetStreamGetNetworkStream
Class5.smethod_20Stream.WriteWriteStream
Class5.smethod_21Stream.FlushFlushStream

The wrapper names expose the program’s I/O, but ConfuserEx still hides the execution order with a flattened state machine. Inside Class5.smethod_0, the dispatcher resembles:

state ^= constant;
switch ((uint)state % 6)
{
    // original basic blocks
}

We do not need to assign meaning to the state constants. Emulating the arithmetic as unsigned 32-bit operations and recording each selected case recovers the real path:

5 -> 2 -> 0 -> 4 -> 1

The cases map to these operations:

CaseOperation
5Read config.bin, then sleep for 30 seconds
2Derive K1 and decrypt the config
0Parse the config and generate a random flag
4Encrypt the flag and send it over TCP
1Return

After removing the dispatcher and renaming the proxy calls, the main routine becomes:

static void Run()
{
    Console.WriteLine("Google Chrome Update Service");
    Console.WriteLine("Chrome Updater v117.0.5938.132");

    string path = Path.Combine(
        AppDomain.CurrentDomain.BaseDirectory,
        "config.bin"
    );

    byte[] encryptedConfig = File.ReadAllBytes(path);

    Thread.Sleep(30000);

    byte[] k1 = MD5.HashData(
        Encoding.ASCII.GetBytes("Chrome Updater v117.0.5938.132")
    );

    byte[] configPlain = Cipher.Decrypt(
        encryptedConfig,
        k1,
        unpad: true
    );

    Config config = ParseConfig(configPlain);

    byte[] random = RandomNumberGenerator.GetBytes(16);
    string flag = "SkillBit{" +
                  Convert.ToHexString(MD5.HashData(random)).ToLower() +
                  "}";

    byte[] payload = Cipher.Encrypt(
        Encoding.ASCII.GetBytes(flag),
        config.PayloadKey,
        pad: true
    );

    Send(config.Host, config.Port, payload);
}

Class7.smethod_1 parses the decrypted config. Its cleaned code reveals this layout:

Offset  Size  Field
0x00    8     ASCII magic "SKILCFG1"
0x08    16    K2, the payload-encryption key
0x18    4     TCP port, little-endian
0x1C    1     Hostname/IP length
0x1D    N     Hostname/IP, ASCII

The program derives the config key as follows:

K1 = MD5(b"Chrome Updater v117.0.5938.132")

Its value is:

0b1248aefb11271a32a6c3dd8d8c2883

The cipher implementation resides in Class6. Renaming its methods gives us the components needed for a standalone decryptor:

MethodRecovered function
smethod_064-bit rotate-left
smethod_1AES S-box substitution on each byte
smethod_2SplitMix64-style avalanche function
smethod_3Generate 20 round keys from a 16-byte key
smethod_4Feistel round function
smethod_5Read a big-endian UInt64
smethod_6Write a big-endian UInt64
smethod_7Encrypt one 16-byte block
smethod_8Decrypt one 16-byte block
smethod_9Apply PKCS#7 padding
smethod_10Remove PKCS#7 padding
smethod_11Encrypt a buffer
smethod_12Decrypt a buffer

Together, these methods implement a custom 128-bit, 20-round Feistel cipher in ECB mode with PKCS#7 padding. We reproduce the key schedule, round function, and block decryption in solve.py.

The program uses two keys. K1 comes from the version string and decrypts the config. The config contains K2, which encrypts the flag sent over TCP. Reversing the executable therefore gives us K1, but neither K2 nor the flag.

The challenge does not provide config.bin as a separate file. However, the reversed code shows that File.ReadAllBytes copies the entire file into the managed byte array _configBlob. The program then deliberately sleeps for 30 seconds while that array still contains the encrypted config. This is why we search the dump for the encrypted config.

3. Locate the encrypted config in memory

The encrypted config resembles random heap data, so neither the filename config.bin nor the plaintext magic SKILCFG1 can be found with a normal string search. We do, however, know all of the following from reversing:

  • The block cipher and its decryption routine
  • K1, derived from the version string
  • The first eight plaintext bytes, SKILCFG1
  • The fact that ECB decrypts each 16-byte block independently

We can therefore perform a known-plaintext scan. At every 4-byte-aligned offset in the dump, decrypt one 16-byte block with K1 and test whether it begins with SKILCFG1:

K1 = hashlib.md5(b"Chrome Updater v117.0.5938.132").digest()
MAGIC = b"SKILCFG1"
rks = key_schedule(K1)

for off in range(0, len(dump) - 16, 4):
    plaintext_block = decrypt_block(dump[off:off + 16], rks)
    if plaintext_block.startswith(MAGIC):
        print(f"possible config at {off:#x}")
        break

This is a brute-force search for the config’s memory offset, not a brute-force attack against the encryption key. The key and expected plaintext prefix are already known; only the location of the ciphertext is unknown.

A magic match gives us a candidate location. We then decrypt the full 48-byte encrypted blob and validate the parsed fields instead of accepting the match blindly:

config_plaintext = decrypt_ecb(dump[off:off + 48], K1)
config = parse_cfg(config_plaintext)

assert config_plaintext.startswith(b"SKILCFG1")
assert 1 <= config["port"] <= 65535

For the supplied dump, the scan finds:

config @ 0x5dbebc
ip     = 172.22.53.221
port   = 8443
K2     = 4096779362de3e310c16f80d5b8ff8f3

4. Recover the flag from the packet capture

Following the TCP stream in network.pcapng shows a 48-byte payload sent to the IP and port recovered from the config:

76272d094c3d5e23d8a43ad094d6b9d38a80eca10884a765
9b1718c11ae6b9e17f2d6644a149bd77ea5ddc774449f386

This payload is the flag ciphertext. Decrypting it with K2 recovered from the memory-resident config completes the chain:

flag_ct = bytes.fromhex(
    "76272d094c3d5e23d8a43ad094d6b9d38a80eca10884a765"
    "9b1718c11ae6b9e17f2d6644a149bd77ea5ddc774449f386"
)

flag = decrypt_ecb(flag_ct, config["k2"]).decode()
print(flag)

Output:

SkillBit{d232462e9efefa9ac017b211c3564989}

The complete, self-contained implementation of the reversed cipher and the recovery process, solve.py:

#!/usr/bin/env python3
"""
Karsten - reference solver (self-contained answer key).

Usage:
    python3 solve.py Chrome.DMP <flag_ciphertext_hex_from_pcap>

Intended path:
    reverse the binary  -> cipher + K1 = MD5("Chrome Updater v117.0.5938.132")
    scan the dump with K1, match the known plaintext prefix "SKILCFG1" -> locate the config
    decrypt the config  -> ip, port, K2
    decrypt the pcap ciphertext with K2 -> flag
"""
import hashlib, sys

# ---- reversed cipher: 20-round Feistel, 128-bit block, ECB, PKCS#7 ---------
MASK = (1 << 64) - 1
ROUNDS = 20
GOLDEN = 0x9E3779B97F4A7C15
ODD    = 0xD1B54A32D192ED03
SBOX = bytes.fromhex(
    "637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
    "b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
    "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
    "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
    "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
    "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
    "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
    "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16")

def rotl(x, r):
    r &= 63
    return ((x << r) | (x >> (64 - r))) & MASK if r else x

def sbox64(x):
    o = 0
    for i in range(8):
        o |= SBOX[(x >> (8*i)) & 0xFF] << (8*i)
    return o & MASK

def mix(x):
    x = (x ^ (x >> 30)) & MASK; x = (x * 0xBF58476D1CE4E5B9) & MASK
    x = (x ^ (x >> 27)) & MASK; x = (x * 0x94D049BB133111EB) & MASK
    return x ^ (x >> 31)

def key_schedule(key):
    k0 = int.from_bytes(key[:8], "big"); k1 = int.from_bytes(key[8:], "big")
    rks, s = [], (int.from_bytes(key[:8], "big") ^ GOLDEN)
    for i in range(ROUNDS):
        s = (s + (GOLDEN ^ rotl(k1, i))) & MASK
        rks.append(mix(s))
    return rks

def F(r, rk):
    x = (r + rk) & MASK; x = sbox64(x)
    x = x ^ rotl(x, 17) ^ rotl(x, 43); x = (x * ODD) & MASK
    return x ^ (x >> 29)

def decrypt_block(block, rks):
    L = int.from_bytes(block[:8], "big"); R = int.from_bytes(block[8:], "big")
    for i in range(ROUNDS - 1, -1, -1):
        L, R = (R ^ F(L, rks[i])) & MASK, L
    return L.to_bytes(8, "big") + R.to_bytes(8, "big")

def decrypt_ecb(ct, key, unpad=True):
    rks = key_schedule(key)
    out = b"".join(decrypt_block(ct[i:i+16], rks) for i in range(0, len(ct), 16))
    if unpad:
        out = out[:-out[-1]]
    return out

# ---- config + K1 -----------------------------------------------------------
K1 = hashlib.md5(b"Chrome Updater v117.0.5938.132").digest()
MAGIC = b"SKILCFG1"

def parse_cfg(pt):
    assert pt[:8] == MAGIC, "bad magic"
    return {"k2": pt[8:24],
            "port": int.from_bytes(pt[24:28], "little"),
            "ip": pt[29:29 + pt[28]].decode()}

def main():
    dump = open(sys.argv[1], "rb").read()
    ct   = bytes.fromhex(sys.argv[2])
    rks  = key_schedule(K1)
    print(f"[*] K1 = MD5(version tag) = {K1.hex()}")
    print(f"[*] scanning {len(dump):,} bytes for the config (magic 'SKILCFG1')...")
    off = None
    for o in range(0, len(dump) - 16, 4):      # .NET heap arrays are 4-byte aligned
        if decrypt_block(dump[o:o+16], rks)[:8] == MAGIC:
            off = o; break
    if off is None:
        sys.exit("[!] config not found")
    cfg = parse_cfg(decrypt_ecb(dump[off:off+48], K1))
    print(f"[+] config @ {hex(off)}  ip={cfg['ip']}  port={cfg['port']}  K2={cfg['k2'].hex()}")
    print(f"[+] FLAG: {decrypt_ecb(ct, cfg['k2']).decode()}")

if __name__ == "__main__":
    main()

Running it end to end:

$ python3 solve.py Chrome.DMP 76272d094c3d5e23d8a43ad094d6b9d38a80eca10884a7659b1718c11ae6b9e17f2d6644a149bd77ea5ddc774449f386
[*] K1 = MD5(version tag) = 0b1248aefb11271a32a6c3dd8d8c2883
[*] scanning 112,110,324 bytes for the config (magic 'SKILCFG1')...
[+] config @ 0x5dbebc  ip=172.22.53.221  port=8443  K2=4096779362de3e310c16f80d5b8ff8f3
[+] FLAG: SkillBit{d232462e9efefa9ac017b211c3564989}

Flag

SkillBit{d232462e9efefa9ac017b211c3564989}