Skip to content

String and Artifact Extraction

String and artifact extraction is the systematic recovery of human-readable and structurally meaningful data from a binary or document: embedded URLs, domains, IPs, mutex names, registry paths, file paths, commands, configuration blobs, error messages, and metadata residue such as PDB paths and compile timestamps. Strings are the raw output of scanning a file for contiguous character sequences; artifacts are the classified and interpreted subset that means something operationally. Extraction is the first hour of every analysis — before you debug anything, you inventory what the sample itself tells you. The discipline is equal parts harvesting (getting all the strings, including the obfuscated ones M4 warned you about) and filtering (throwing away the thousands of false positives that compiler runtimes and import names generate).

Extracted artifacts are the currency of threat intelligence: a C2 domain becomes a blocking rule the same day, a mutex name becomes a host-based detection that survives every rebuild of the binary, and a PDB path like C:\Users\dev\source\repos\loader\x64\Release\loader.pdb tells you the developer’s username and project layout. For IR teams, config extraction from a stealer turns thousands of samples into a single campaign view — the wallet address, the Telegram bot token, the webhooks — which clusters samples better than any hash. For triage, strings are the fastest possible signal: thirty seconds of extraction often answers “what family is this” without a single debug session. Skipping or rushing this stage is how analysts miss the cheap wins and end up writing elaborate detection logic for things a single registry-path string would have caught. And on the defensive side, knowing which artifacts a family leaks tells you exactly what to hunt for in Sysmon event data — mutex creation (EID 17/18), DNS queries (EID 22), file writes (EID 11).

Strings exist in several encodings and concealment layers, and each needs a different tool. ASCII strings are the default strings view — one byte per character, NUL-terminated. UTF-16LE strings (Windows API native) appear as H\0e\0l\0l\0o\0 and are missed by plain strings — hence strings -el. Stack-built strings never appear contiguous in the file: the code mov dword [rsp], 'cmd.' pieces them at runtime, which is why FLOSS emulates the code with Unicorn to materialize them. Encrypted/encoded strings (XOR, Base64, ROT, custom alphabets) are invisible until decrypted — see M4. Compression: a packed binary yields no payload strings at all, only stub strings.

Artifacts worth classifying, roughly in order of detection value:

HIGH mutex names (family-unique, survive rebuilds)
registry Run keys / persistence paths
hardcoded C2: domain, IP:port, URI paths
ransom note filenames, extension lists
MEDIUM PDB paths (build-machine leak, clustering gold)
embedded user agents, HTTP header sets
service names, scheduled task names
LOW compiler/runtime noise: "mscoree.dll", assert texts
API names, GUIDs, standard dialogs
NOISE everything in a Go/Rust binary's standard library

The structured view beats the flat list. A stealer config is typically a length-prefixed table of encrypted fields (C2 list, campaign ID, wallet, webhook) at a fixed offset — recover the key (often a 1-byte XOR or a simple RC4 [verify per family]) and the whole config decodes in one shot. A ransom note template or a kill-switch domain list is similarly block-structured. The extraction workflow: run multi-encoding string pass → classify with regexes → anchor on high-value classes (mutex/registry/URL) → locate the config block by finding the encrypted blob adjacent to known plaintext → decode → verify every artifact against the code path that references it. An artifact you cannot tie to code is a guess, not a finding.

TechniqueCommand / toolCatchesNotes
ASCII stringsstrings -a -n 6 sample.exeplain textFast but misses wide strings
UTF-16LE stringsstrings -a -el -n 6Windows-native textAlways run both passes
Stack-string recoveryFLOSS (floss sample.exe)runtime-assembled stringsEmulates stub code; slow on big files
XOR brute forcexortool -x sample.exe, CyberChefsingle/multi-byte XOR blobsLook for “http” as crib
PE debug directorypefile / PE-bearPDB path, GUID+ageBuild-machine leak, M15 clustering
Rich header parserich_header pefile attrcompiler chain, countsObfuscated by some crypters
Office macro extractionolevba sample.docVBA macros, shellcodeAlso oledump.py [verify flags]
Base64 detectionregex [A-Za-z0-9+/]{40,}={0,2}encoded blobs/powershellDecode then re-classify
Certificate tablesigcheck -a / pefilestolen cert CN, signerReputation-gate abuse evidence
Config pattern searchcustom YARA + offsetsstealer/ransom configsSee tool below

artifact_harvester.py — stdlib-only extraction and classification: multi-encoding string pass, regex classification (URL, domain, IPv4, registry, file path, mutex-like, base64 candidates), PDB path from the debug directory, and a deduplicated report. Runs on any file type, not just PE.

#!/usr/bin/env python3
"""artifact_harvester.py — multi-encoding string extraction + IOC classification.
Usage: python artifact_harvester.py <file> [--min 6] [--json out.json]
No third-party deps. PE-aware if the file happens to be a PE (PDB path).
"""
import argparse, json, os, re, struct, sys
from collections import OrderedDict
PRINTABLE = set(range(0x20, 0x7F))
def ascii_strings(data: bytes, min_len: int):
"""Yield ASCII runs >= min_len printable chars."""
out, start = [], None
for i, b in enumerate(data):
if b in PRINTABLE:
if start is None:
start = i
else:
if start is not None and i - start >= min_len:
out.append((start, data[start:i].decode("ascii")))
start = None
if start is not None and len(data) - start >= min_len:
out.append((start, data[start:].decode("ascii")))
return out
def utf16_strings(data: bytes, min_len: int):
"""Yield UTF-16LE runs: printable byte followed by 0x00."""
out, buf, start = [], bytearray(), None
for i in range(0, len(data) - 1, 2):
lo, hi = data[i], data[i + 1]
if lo in PRINTABLE and hi == 0x00:
if start is None:
start = i
buf.append(lo)
else:
if len(buf) >= min_len:
out.append((start, buf.decode("ascii")))
buf, start = bytearray(), None
if len(buf) >= min_len:
out.append((start, buf.decode("ascii")))
return out
URL_RE = re.compile(rb"https?://[!-~]{4,200}")
DOMAIN_RE = re.compile(
r"\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)"
r"{2,6}(?:com|net|org|io|ru|cn|xyz|top|info|biz|su|cc|me|onion)\b",
re.I)
IPV4_RE = re.compile(
r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}"
r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b")
REG_RE = re.compile(
r"\b(?:HKEY_[A-Z_]+|HKLM\\|HKCU\\)[\w\\\s\.\-]{2,120}", re.I)
PATH_RE = re.compile(
r"\b(?:[A-Z]:\\|\\\\)[\w\.\-\\ ]{3,150}", re.I)
MUTEX_RE = re.compile(
r"(?:Global\\|Local\\)?[A-Za-z0-9_\-{$!}@#]{8,64}")
BASE64_RE = re.compile(r"\b[A-Za-z0-9+/]{40,}={0,2}\b")
def classify(strings):
"""Bucket each string into artifact classes."""
hits = {k: OrderedDict() for k in
("url", "domain", "ipv4", "registry", "path",
"base64", "mutex_like")}
for off, s in strings:
for m in URL_RE.finditer(s.encode()):
hits["url"].setdefault(m.group().decode(), off)
for m in DOMAIN_RE.finditer(s):
hits["domain"].setdefault(m.group().lower(), off)
for m in IPV4_RE.finditer(s):
hits["ipv4"].setdefault(m.group(), off)
for m in REG_RE.finditer(s):
hits["registry"].setdefault(m.group().strip(), off)
for m in PATH_RE.finditer(s):
hits["path"].setdefault(m.group(), off)
for m in BASE64_RE.finditer(s):
tok = m.group()
if any(c.isalpha() for c in tok) and any(c.isdigit() for c in tok):
hits["base64"].setdefault(tok[:120], off)
if MUTEX_RE.fullmatch(s) and " " not in s and "." in s or \
"Global\\" in s or "Local\\" in s:
hits["mutex_like"].setdefault(s, off)
return {k: v for k, v in hits.items() if v}
def pdb_path(path):
"""Extract PDB path from PE debug directory (CodeView RSDS)."""
try:
import pefile
pe = pefile.PE(path, fast_load=True)
pe.parse_data_directories(
directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_DEBUG"]])
for dbg in getattr(pe, "DIRECTORY_ENTRY_DEBUG", []):
raw = dbg.entry if hasattr(dbg, "entry") else None
if raw and getattr(raw, "PdbFileName", None):
return raw.PdbFileName.rstrip(b"\x00").decode(errors="replace")
except Exception:
return None
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("file")
ap.add_argument("--min", type=int, default=6)
ap.add_argument("--json")
args = ap.parse_args()
data = open(args.file, "rb").read()
strs = sorted(set(ascii_strings(data, args.min)) |
set(utf16_strings(data, args.min)))
hits = classify(strs)
print(f"== {args.file} — {len(strs)} strings, artifacts: ==")
for cls, items in hits.items():
print(f"\n[{cls}] ({len(items)})")
for tok, off in list(items.items())[:25]:
print(f" 0x{off:08x} {tok[:150]}")
pp = pdb_path(args.file)
if pp:
print(f"\n[pdb] {pp}")
if args.json:
json.dump({"file": args.file, "strings": len(strs),
"artifacts": {k: list(v) for k, v in hits.items()},
"pdb": pp},
open(args.json, "w"), indent=2)
print(f"[+] wrote {args.json}")
if __name__ == "__main__":
main()
  • Lines 11-22 implement a single-pass ASCII scanner: a two-state machine (in-string / out-of-string) collecting printable runs of at least min_len. Streaming over bytes with index tracking beats re on multi-megabyte inputs and gives you exact file offsets for every string, which you will need when you correlate artifacts back to code.
  • Lines 25-40 are the UTF-16LE pass: stride 2, accepting printable-then-NUL byte pairs. Note the alignment boundary check (len(data) - 1) and that final unterminated strings are flushed after the loop (lines 38-40) — a classic off-by-one bug source.
  • Lines 42-56 define the classifiers. The domain regex restricts TLDs to a fixed list to cut false positives on random short tokens; the IPv4 regex is octet-accurate (rejects 999.1.1.1); the registry regex anchors on hive names so HKCU\Software\... classifies correctly.
  • Lines 59-77 classify every string against all patterns. OrderedDict keyed by the artifact itself deduplicates while preserving first-seen order; the base64 branch additionally requires both letters and digits (line 73) to avoid matching long alphabetic words.
  • Lines 80-94 pull the PDB path from the CodeView RSDS debug entry when pefile is installed — guarded so the tool still works on non-PE files or without the dependency (stdlib-only by default).
  • Lines 96-117 produce the human report and optional JSON. The JSON shape (artifacts keyed by class) is deliberately identical to what the batch tool below emits, so downstream tooling parses one schema.

Extraction sits at the very first contact point of the analysis pipeline. A sample lands from email triage, EDR quarantine, or an IR engagement; before any detonation the analyst runs the harvester. The artifacts flow outward to three consumers simultaneously: the SOC, which gets domains and IPs for same-day blocking; the detection engineer, who turns mutex names and Run-key paths into Sysmon hunts (EID 17/18 for mutex, EID 12/13 for registry writes); and the threat intel function, which clusters samples by PDB path and config-block content into campaigns (M15). The sample meanwhile moves to dynamic work (M14), but its extracted artifacts continue generating detections long after the detonation is forgotten. On the attacker’s side, every string they ship is a potential leak — which is why serious operators compile with string obfuscation, strip PDB paths, and generate config blocks at build time with randomized keys. The extraction discipline exists precisely because most real-world malware is written by teams that leak something on every build.

  1. Intake — sample from IR engagement, packed loader, zero readable strings
  2. Unpack — recover payload with M4 workflow; entropy drops 7.9 → 5.6
  3. Harvest — artifact_harvester.py: 1,404 strings, 23 artifacts classified
  4. Anchor — mutex Global\SyncNetTask + registry HKCU\...\Run\OneDriveSync flagged
  5. Config — encrypted blob adjacent to user-agent string; single-byte XOR recovered via crib
  6. Verify — cross-reference mutex in code path: CreateMutexW call at 0x140035210 confirms
  7. Disseminate — domains to SOC blocklist same hour; mutex + Run key into detection backlog
  8. Cluster — PDB path D:\proj\stealer-j\Release\... links 14 samples into one campaign (M15)

cfgdigger.py — a config-block digger for the most common protection pattern: XOR-encrypted config blobs containing known plaintext. It brute-forces all 256 single-byte keys against a sliding window of the file, scores candidates by IOC plausibility, and emits the decoded config as JSON with file offsets.

  • Crib-based, not format-based. Rather than hardcoding one family’s config layout, search for generic cribs (http, ://, .exe, \Users\) — the tool survives family changes and works on unknown samples.
  • Score, don’t just decode. Every 256-key pass produces garbage candidates; the scorer weights decodings by how many high-value artifacts the harvester’s own classifiers find in them.
  • Sliding window with stride. Full-file brute force at every offset is O(256·n); a stride-1 window over high-entropy regions only keeps it tractable.
  • Emit provenance. Each decoded field records file offset and XOR key so the finding is reproducible and verifiable in a hex editor.
#!/usr/bin/env python3
"""cfgdigger.py — XOR config-block digger with crib scoring.
Usage: python cfgdigger.py <file> [--window 512] [--crib http]
Deps: none (stdlib only).
"""
import argparse, base64, json, re, sys
CRIBS = [b"http://", b"https://", b".exe", b".dll", b"\\Users\\",
b"SOFTWARE\\", b"Mutex", b"Mozilla", b"cmd.exe", b"powershell"]
URL_RE = re.compile(rb"https?://[\x21-\x7e]{4,120}")
PATH_RE = re.compile(rb"[A-Za-z]:\\[\x20-\x7e]{3,100}")
DOMAIN_RE = re.compile(
rb"\b(?:[a-z0-9\-]{1,63}\.)+[a-z]{2,8}\b")
SCORE_PATTERNS = [(URL_RE, 5), (PATH_RE, 3), (DOMAIN_RE, 2),
(re.compile(rb"[ -~]{20,}"), 1)]
def shannon(data):
if not data:
return 0.0
freq = [0] * 256
for b in data:
freq[b] += 1
n = len(data)
return -sum((c / n) * (c / n and __import__("math").log2(c / n))
for c in freq if c)
def high_entropy_regions(data, window, step, thresh=7.0):
"""Yield (offset, bytes) windows whose entropy exceeds thresh."""
for off in range(0, max(1, len(data) - window), step):
chunk = data[off:off + window]
if shannon(chunk) >= thresh:
yield off, chunk
def xor(buf, key):
return bytes(b ^ key for b in buf)
def score(decoded):
"""Score decoded bytes by weighted regex hits + crib hits."""
s = 0
for pat, w in SCORE_PATTERNS:
s += w * len(pat.findall(decoded))
for crib in CRIBS:
if crib.lower() in decoded.lower():
s += 8
return s
def extract_fields(decoded):
"""Pull readable artifact fields out of decoded bytes."""
fields = {}
urls = [m.group().decode(errors="replace")
for m in URL_RE.finditer(decoded)]
paths = [m.group().decode(errors="replace")
for m in PATH_RE.finditer(decoded)]
if urls:
fields["urls"] = urls
if paths:
fields["paths"] = paths
printable = bytes(b if 32 <= b < 127 else 0 for b in decoded)
chunks = [c.strip() for c in printable.split(b"\x00")
if len(c.strip()) >= 5]
if chunks:
fields["strings"] = [c.decode() for c in chunks[:30]]
return fields
def main():
ap = argparse.ArgumentParser()
ap.add_argument("file")
ap.add_argument("--window", type=int, default=512)
ap.add_argument("--step", type=int, default=256)
ap.add_argument("--min-score", type=int, default=24)
ap.add_argument("--json")
args = ap.parse_args()
data = open(args.file, "rb").read()
results = []
for off, chunk in high_entropy_regions(
data, args.window, args.step):
for key in range(1, 256): # skip key 0 = no-op
decoded = xor(chunk, key)
sc = score(decoded)
if sc >= args.min_score:
results.append({"offset": off, "key": hex(key),
"score": sc,
"fields": extract_fields(decoded)})
# dedupe: same key+overlapping window keep highest score
results.sort(key=lambda r: (-r["score"], r["offset"]))
kept = []
for r in results:
if not any(k["key"] == r["key"]
and abs(k["offset"] - r["offset"]) < args.window
for k in kept):
kept.append(r)
if not kept:
print("[-] no config block recovered "
"(multi-byte key or not XOR?)")
return
for r in kept[:5]:
print(f"[+] offset=0x{r['offset']:x} key={r['key']} "
f"score={r['score']}")
for k, v in r["fields"].items():
print(f" {k}: {v[:8] if isinstance(v, list) else v}")
if args.json:
json.dump({"file": args.file, "findings": kept[:5]},
open(args.json, "w"), indent=2)
print(f"[+] wrote {args.json}")
if __name__ == "__main__":
main()
  • high_entropy_regions() (lines 24-31) localizes the search: config blobs are encrypted, therefore high-entropy, so candidate windows are pre-filtered by entropy before any brute forcing — typically 10-100× fewer XOR passes than naive full-file scanning.
  • score() (lines 39-47) is the ranking brain: crib hits (known plaintext like http://) weigh 8 points each, URL hits 5, paths 3. A window that decodes to a real config under the right key racks up dozens of points; the wrong keys score near zero. The --min-score cutoff (default 24) tunes precision vs recall.
  • extract_fields() (lines 50-63) splits the decoded block on NUL boundaries — the near-universal config separator — and classifies the resulting fields, giving you a ready-made IOC list rather than a wall of bytes.
  • The dedup pass (lines 100-107) keeps the highest-scoring window per (key, region) pair, since a 512-byte window over a 1 KB config produces overlapping matches with the same key.
  • Note line 17-19’s entropy helper intentionally reuses a compact implementation; in production you would import the harvester’s version — duplicated here so the tool stays single-file.

create a test config blob and XOR it with key 0x37

Section titled “create a test config blob and XOR it with key 0x37”

$ python3 -c ” import sys cfg = b’\x00http://185.220.101.4/gate.php\x00C2-BACKUP\x00C:\Users\pub\dwn.exe\x00’ open(‘/tmp/cfg.bin’,‘wb’).write(bytes(b ^ 0x37 for b in cfg)) ” $ python3 cfgdigger.py /tmp/cfg.bin —json /tmp/cfg.json [+] offset=0x0 key=0x37 score=38 urls: [‘http://185.220.101.4/gate.php’] paths: [‘C:\Users\pub\dwn.exe’]

validate against a packed real sample: unpack first (M4), then dig

Section titled “validate against a packed real sample: unpack first (M4), then dig”

$ python3 packed_hunter.py sample.exe # confirm packed $ python3 cfgdigger.py sample_unpacked.exe —min-score 30 —json r.json

Config content is your highest-quality IOC layer: a wallet address or webhook token is family-unique and rotation-resistant in a way domains are not. Feeds the M15 clustering directly. On your own infrastructure, the tool is pure file-read + CPU — no network, no process spawn — so it is invisible to endpoint telemetry, which is exactly what you want on an analysis bench. The defender-side counterpart: once you recover one config for a family, write a YARA rule (M6) on the decoder stub, not the config, so the rule keeps working when the config rotates. And register the recovered mutex/Run-key artifacts into host-based hunts (Sysmon EID 17/18, 12/13) — they survive recompilation far longer than any network IOC.

  1. Add multi-byte XOR support: try 2-4 byte repeating keys when single-byte scoring fails, using index-of-coincidence to detect key length first.
  2. Integrate RC4 and AES-ECB detectors for families beyond XOR protection.
  3. Auto-carry the harvester’s classifiers instead of reimplementing the regexes (import artifact_harvester).
  4. Add a --yara mode that emits a candidate YARA rule from the recovered decoder stub bytes.
  5. Fuzz the scorer against benign binaries (system32 collection) to calibrate the default --min-score empirically.
Artifact surfacedDefender useSource / IDNotes
Mutex nameHost-based family beaconSysmon EID 17 (create) / 18 (open)Survives rebuilds; lowest FP IOC class
Run-key / service pathPersistence huntSysmon EID 12/13; Windows 7045Pair with 4688 process line
C2 domain / IPPerimeter blockSysmon EID 22 (DNS), EID 3 (flow); firewallSame-day blocklist
URI path + User-AgentNDR/Proxy ruleProxy logs; Suricata custom sid:1000001+ (local rules)UA alone is weak; pair with URI
PDB pathCampaign clusteringOffline PE parseNever a block IOC — intel only
Ransom-note filenameCanary detectionSysmon EID 11 file-createHigh-precision, family-unique
Encrypted config blob hashContent blockYARA on EID 11 dropsRotate with builds; use decoder-stub rule

Wazuh: custom rules keyed on Sysmon EID 17/18 mutex names recovered via this workflow — verify exact rule numbering against your deployment [verify]. Suricata: no reliable generic signature for “config blob”; use local SIDs 1000001+ with http.uri/dns.query content matches built from extracted artifacts.

Malware authors attack each extraction layer in turn. Against strings: everything sensitive lives encrypted or stack-built (M4). Against FLOSS-style emulation: decrypt-on-use with keys derived from anti-debug checks, so emulation on a bench that lacks the debuggee context produces garbage. Against config diggers: per-build random keys, config split across scattered 8-byte fragments reassembled at runtime, or plaintext never stored — the config is fetched from C2 on first run, so the shipped binary contains nothing to dig. Against PDB-leak clustering: stripping the debug directory entirely; against Rich-header clustering: Rich-header obfuscation tools rewrite the header with fake compiler counts. The counter-move is structural: anchor detections on behavioral artifacts (what the sample does — mutex, registry, named pipes) rather than static strings, because behavior is what the malware cannot stop doing without also failing its own mission. Every artifact class you rely on for detection should be one the malware must create to function.

⚠️ What caught me: I trusted a “Global\” mutex string as family evidence until the third sample in the campaign didn’t contain it at runtime — a leftover development string had been sitting in the binary but the shipping build called CreateMutexW with a hash-derived name instead. Since then I verify every extracted artifact against the code path that references it before it goes anywhere near a detection; an unverified string is a hypothesis, not an IOC.

  1. Run artifact_harvester.py on both the packed and unpacked versions of your UPX lab sample from M4; diff the artifact lists and count how much the packer hid.
  2. Install FLOSS on a Windows analysis VM, run it against a real-world loader sample, and compare its stack-string output against the harvester’s static pass — measure the delta in recovered URLs.
  3. Take a public stealer sample with a published config description, extract the config manually in a hex editor first, then verify cfgdigger.py recovers the same fields — disagreement means either the family uses multi-byte keys or the config isn’t where you think.
  4. Convert one recovered mutex and one Run-key path into a Sysmon-based hunt query, and run it against a lab endpoint detonation to confirm both artifacts appear in EID 17/18 and 12/13 events.
  • Technical: Why does strings alone miss most Windows malware strings, and name two additional passes you always run.
  • Technical: How does FLOSS recover stack-built strings, and what class of string obfuscation defeats it?
  • Scenario: You extract a mutex string from a sample, but a colleague says it’s development leftover. How do you adjudicate before it goes into detections?
  • Conceptual: Rank these IOC classes by longevity: IP address, domain, mutex name, PDB path, wallet address. Justify the ordering.
  • Conceptual: Why are behavioral artifacts (mutex, registry keys) more rebuild-resistant than any static string?
  • Deep-dive: How would you design a config-block search tool that survives per-build random XOR keys and scattered config fragments?
  • Practical: A SOC wants same-day blocking from your extraction. What artifact classes do you hand over immediately, and which do you hold for verification — and why?

M4 (Unpacking and Deobfuscation) · M6 (YARA Rule Engineering) · M7 (C2 Protocol Analysis) · M15 (Threat Intelligence and Campaign Clustering) · BT8 (Malware Analysis)