Lesson 8 of 9 · 26m
Network Behavior and Config Extraction
Reading lab pcaps without fooling yourself, where malware hides its configuration, cracking simple encodings, and why an extractor beats a one-off decode.
The most valuable thing you can usually hand IR is the config: C2 list, campaign ID, encryption keys, sleep interval. Network capture shows the one C2 that answered during your run; the config shows all six — including the backups the operator fails over to the day after you block the first.
Reading the lab pcap honestly
Filters worth memorizing: dns (every name asked for), http.request (method, Host, URI, UA), tls.handshake.type == 1 (client hellos: SNI + JA3/JA4), tcp.flags.syn == 1 && tcp.flags.ack == 0 (connection attempts, including failures). A first-beaconed POST looks like:
POST /api/v2/gate.php HTTP/1.1
Host: cdn-telemetry-update[.]top
User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Win32)An IE8 user agent from a Windows 10 box is itself an indicator; beacons show as regular intervals with jitter (frame.time_delta_displayed). And remember lesson 3: behind INetSim, every IP in the capture is your sinkhole. The domains are real; the addresses are not.
Where configs hide
An RT_RCDATA resource, a .data blob sitting next to the decrypt loop your lesson-6 rule matched, the overlay, or stack strings (FLOSS recovers many). Common encodings, roughly by frequency: single-byte XOR, rolling or multi-byte XOR, base64-wrapped XOR, RC4 with the key beside the data, AES with a hardcoded key. Cobalt Strike XORs its config with 0x69 (3.x) or 0x2e (4.x) — which is why public parsers extract it in milliseconds.
Cracking single-byte XOR with a crib
You rarely know the key; you nearly always know a piece of plaintext — a JSON brace, a field name:
// brute force every key; keep the one that yields the known crib
const enc = new TextEncoder(), dec = new TextDecoder();
const plain = enc.encode('{"c2":["cdn-telemetry-update.top","203.0.113.47:8443"],"camp":"MAR24-HR","sleep":45}');
const blob = plain.map(b => b ^ 0x5a); // the blob as pulled from an RCDATA resource
function crack(buf, crib) {
for (let k = 1; k < 256; k++) {
const out = dec.decode(buf.map(b => b ^ k));
if (out.includes(crib)) return { key: "0x" + k.toString(16), config: JSON.parse(out) };
}
}
console.log(JSON.stringify(crack(blob, '"sleep"'), null, 2));Write an extractor, not a one-off
import hashlib, json, sys, pefile
data = open(sys.argv[1], "rb").read(); pe = pefile.PE(data=data)
for rt in pe.DIRECTORY_ENTRY_RESOURCE.entries:
if rt.id != pefile.RESOURCE_TYPE["RT_RCDATA"]: continue
for name in rt.directory.entries:
for lang in name.directory.entries:
blob = pe.get_data(lang.data.struct.OffsetToData, lang.data.struct.Size)
cfg = bytes(b ^ 0x5A for b in blob)
if cfg.startswith(b'{"c2"'):
print(json.dumps({"sha256": hashlib.sha256(data).hexdigest(), "config": json.loads(cfg)}))Field notes
- Label extracted C2s configured and observed ones observed — IR blocks both; hunting and confidence differ.
- Run the extractor across every sample of the family you can find — new builds mean new C2s you get before they're used.
- Legitimate-service C2 (Telegram, Discord, GitHub raw, Pastebin): the indicator is the bot token or URL path, not the domain the whole company uses.
- For DGA families, the IOC is the algorithm and seed — nobody can deploy a 10,000-domain list, and you shouldn't ask.
- Plain XOR turns bytes equal to the key into
0x00— which leaks the key — so some families skip those bytes. A decode that's perfect except a few characters is that. - Do not "check if the C2 is still alive" from your corporate network. See lesson 1.