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 (clr, mscorlib, and mscoree) also show that this is a .NET process.

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

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 method | Wrapped API | Suggested name |
|---|---|---|
Class5.smethod_3 | Console.WriteLine | PrintLine |
Class5.smethod_4 | AppDomain.CurrentDomain | GetCurrentDomain |
Class5.smethod_5 | AppDomain.BaseDirectory | GetBaseDirectory |
Class5.smethod_6 | Path.Combine | CombinePath |
Class5.smethod_7 | File.ReadAllBytes | ReadAllBytes |
Class5.smethod_8 | Thread.Sleep | Sleep |
Class5.smethod_9 | Encoding.ASCII | GetAscii |
Class5.smethod_10 | Encoding.GetBytes | AsciiGetBytes |
Class5.smethod_17 | new TcpClient() | CreateTcpClient |
Class5.smethod_18 | TcpClient.Connect | Connect |
Class5.smethod_19 | TcpClient.GetStream | GetNetworkStream |
Class5.smethod_20 | Stream.Write | WriteStream |
Class5.smethod_21 | Stream.Flush | FlushStream |
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:
| Case | Operation |
|---|---|
| 5 | Read config.bin, then sleep for 30 seconds |
| 2 | Derive K1 and decrypt the config |
| 0 | Parse the config and generate a random flag |
| 4 | Encrypt the flag and send it over TCP |
| 1 | Return |
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:
| Method | Recovered function |
|---|---|
smethod_0 | 64-bit rotate-left |
smethod_1 | AES S-box substitution on each byte |
smethod_2 | SplitMix64-style avalanche function |
smethod_3 | Generate 20 round keys from a 16-byte key |
smethod_4 | Feistel round function |
smethod_5 | Read a big-endian UInt64 |
smethod_6 | Write a big-endian UInt64 |
smethod_7 | Encrypt one 16-byte block |
smethod_8 | Decrypt one 16-byte block |
smethod_9 | Apply PKCS#7 padding |
smethod_10 | Remove PKCS#7 padding |
smethod_11 | Encrypt a buffer |
smethod_12 | Decrypt 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}