Static config extraction without detonating the sample: a walkthrough

Most analysts reach for a sandbox first when they need a config out of a sample, and for the common case that is the correct ordering. Detonate it in Triage or CAPE, read the extracted config off the report, move on. The static path is the fallback: samples the sandbox mangles, samples you are not allowed to upload because of a customer NDA, and samples where the sandbox hands you a config that looks wrong and you need to check it against the binary before you put it in a report.

This post walks the static path end to end on a representative info-stealer sample. I am deliberately not naming a family, because the point is the sequence of decisions rather than any single blob layout. By the end you should have a working mental model for the only question that matters here: the loader is decrypting something and treating the result as a config, so how do I reach the decrypted form without running the code.

I am assuming you already have an unpacked sample. Unpacking is its own problem and mostly out of scope. If the sample is still packed, the config you are looking for is not in the image yet, and every step below will waste your time. Get to the unpacked payload first, dump it, fix the section headers enough that a disassembler will load it, then start here.

Step 1: recognise the loader pattern

Open the sample in Ghidra or IDA and do not go looking for the config directly. Go looking for the decryption. The config is encrypted or obfuscated in almost every family worth writing an extractor for, so the plaintext is not sitting in the binary to be found. What is sitting in the binary is a function that turns a blob of high-entropy bytes into readable structure, and that function is easier to find than the blob.

Two signatures find it quickly. The first is a tight loop over a byte buffer that XORs, adds, or table-substitutes each byte against a key or a running state. That shape is RC4, single-byte XOR, or a rolling XOR, and it looks the same at the disassembly level regardless of which. The second is a call into a crypto primitive: an AES key schedule, a call to CryptDecrypt or BCryptDecrypt on Windows, or the constants that give away a bundled implementation. AES-128 in particular is trivial to spot because the S-box and the round constants are fixed values that every signature-scanning tool already knows.

This is where capa earns its place in the workflow. Run it over the unpacked sample before you open the disassembler. It will tell you “encrypt data using RC4”, “decode base64”, “reference AES constants”, and roughly where, and that turns the disassembler session from a hunt into a confirmation. capa is not always right about the outer wrapper, but it is right often enough that starting cold without it is a waste.

The function you want is the one whose output is consumed by string operations: something reads the decrypted buffer, splits it on a delimiter or walks it as length-prefixed fields, and hands the pieces to network setup or to a structure the rest of the loader reads from. Follow the output of your candidate decrypt routine forward. If it feeds a parser, you have found the config decryptor. If it feeds a PE loader, you found the payload decryptor instead, which is a different blob one layer out; note it and keep going.

Step 2: find the encrypted blob

The decryptor takes its ciphertext from somewhere. Trace its buffer argument backward. In the simple 2015-era layout the blob is a single embedded byte array in the .data or .rdata section, referenced by a direct pointer, and you are done in one cross-reference. Modern builds make you work slightly harder for it, in a few predictable ways.

The blob is sometimes assembled at runtime from several smaller arrays, or copied out of a resource, or pulled from an overlay appended past the end of the mapped image. If the decrypt routine reads from a heap buffer rather than a static address, walk back to whatever filled that buffer. It is usually a memcpy from a static source or a resource load. The blob does not appear from nowhere; it has a static origin somewhere in the image, and the copy chain between there and the decryptor is short.

Two sanity checks tell you the region you found is really the config. Run an entropy check on it. Encrypted or compressed data sits near 8 bits per byte; a region that reads 4 to 5 is probably not your ciphertext. And check the length against what the decrypt loop consumes. If the loop runs len iterations and len came from a field next to the blob, that field is your length and it confirms the boundary. Getting the boundary wrong by a few bytes is the most common reason a decoder produces almost-right garbage, so pin it down now rather than debugging it later.

Step 3: identify the cipher

By this point the disassembly usually tells you. A 256-byte state array initialised 0 to 255, then a key-scheduling swap loop, then a per-byte keystream generation, is RC4 and nothing else. A single XOR against a constant is single-byte XOR. A XOR against a repeating multi-byte key is Vigenere-shaped and just as easy. The presence of the AES S-box or a call to the platform crypto API is AES, and then the mode matters: look for an IV being set up (CBC) or a counter being incremented (CTR).

The one that costs people time is the custom or lightly-homebrewed cipher. RC4 with a non-standard key schedule, XOR with a key that is itself derived from the buffer, or a substitution table that is generated rather than fixed. You do not need to recognise these by name. You need to reproduce the exact arithmetic the loop performs, in order, including the parts that look pointless. The pointless-looking parts are usually load-bearing. If the loop adds the index before XORing, your decoder adds the index before XORing.

Step 4: recover the key

The key comes from one of three places, in rough order of how often you will see it in current builds.

Embedded near the blob. The oldest and still the most common. A fixed byte array a short distance from the ciphertext, sometimes with a length prefix of its own. Read it out of the section and you are done.

Derived at build time. Per-build keys are the norm for anything sold as a service, because a shared key across builds means one extraction breaks every affiliate at once. The key is still in the sample, but it is computed: a hardcoded seed run through a small PRNG, or a constant combined with a build ID that also lives in the binary. You reproduce the derivation. It is almost always short, because the developer wanted it fast at load time, and short means you can port it in a few lines.

Derived at first run from the host. The high-effort case, where the key depends on a machine fingerprint computed on the victim, so the same sample decrypts differently on different hosts. This is where the pure-static path can genuinely fail, and being honest about that is step 6. For extraction you either supply the fingerprint the target host would have produced, or you accept that this blob wants a running host and fall back.

Step 5: write the minimal decoder

Once you have blob, cipher, and key, the decoder is small. Pull the exact bytes, apply the exact routine, parse the result. The mistake to avoid is reimplementing the loader’s memory management. You do not care how it allocated the buffer or where it copied it. You care about the bytes and the arithmetic.

import base64

def rc4(key: bytes, data: bytes) -> bytes:
    s = list(range(256))
    j = 0
    for i in range(256):
        j = (j + s[i] + key[i % len(key)]) & 0xFF
        s[i], s[j] = s[j], s[i]
    out = bytearray()
    i = j = 0
    for b in data:
        i = (i + 1) & 0xFF
        j = (j + s[i]) & 0xFF
        s[i], s[j] = s[j], s[i]
        out.append(b ^ s[(s[i] + s[j]) & 0xFF])
    return bytes(out)

blob = open("config.bin", "rb").read()   # the exact bytes from step 2
key  = bytes.fromhex("....")             # from step 4
plaintext = rc4(key, blob)

# many families wrap a second layer; check before parsing
try:
    plaintext = base64.b64decode(plaintext)
except Exception:
    pass

print(plaintext[:256])

Two habits pay off here. Print the first 256 bytes before you write any parser, because the shape of the plaintext tells you whether there is another layer. A lot of current families base64 the inner config, or length-prefix it, or wrap it again in the same cipher with a different key. If the “plaintext” is still high-entropy, you are not done decrypting, you are one layer in. And keep the I/O separate from the crypto so you can point the same routine at the next sample without editing the algorithm. When you have ten samples of one family, the algorithm is stable and only the key and blob offset move.

For quick one-offs where you do not want to write anything, CyberChef will do RC4, XOR, base64, and AES in a recipe you can build by hand, and it is faster than a script when you are still guessing at the layering. For document-delivered samples where the “loader” is a macro or an embedded object rather than a PE, Philippe Lagadec’s oletools gets you to the payload, and FLARE FLOSS is worth a pass for stackstrings before you commit to a full manual session.

Step 6: when static fails, say so

The static path fails cleanly in a couple of cases and messily in one. It fails cleanly when the key is host-derived and you cannot reconstruct the host fingerprint, and when the blob is fetched from the network at runtime rather than embedded, so there is nothing in the sample to decrypt. In both of those the honest move is to detonate: CAPE will often catch the config after the runtime derivation has happened, which is exactly the point in execution where the static approach ran out of information.

The messy failure is the one to watch for. You decrypt with the wrong key or the wrong scheme and get output that is not obviously wrong: some printable bytes, something that looks like it could be a truncated C2. That is worse than a clean failure, because it can end up in a report. The guard against it is to know what a correct config for the family looks like before you trust an extraction, which means you validate the output against a known-good sample or against the parser you found in step 1, not against your own hope that the bytes look plausible. Mismatched-scheme output is quiet, and quiet wrong data is the expensive kind.

Kevin Breen’s RATDecoders remains the cleanest reference for what a per-family static extractor looks like written out in full, and the older families in that repo still decrypt current samples that never changed their layout. For test material, MalwareBazaar has enough tagged samples per family that you can build the known-good set you need for validation. Build that set first. Everything above is faster when you already know what the right answer looks like.

  • Maren / halt0p