Skip to content

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.