105 lines
3.3 KiB
Python
Executable File
105 lines
3.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Extract a Cobalt Strike Beacon configuration by brute-forcing the XOR key.
|
|
|
|
Cobalt Strike Beacon stores its settings as a single-byte XOR-encoded table of TLV entries.
|
|
This tool scans candidate keys, locates the decoded table, and parses well-known setting
|
|
indices into a readable config. It reads bytes only and never executes the beacon.
|
|
|
|
This is original code based on the publicly documented config-block layout. It decodes a
|
|
limited, well-known subset of settings for analyst triage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Subset of well-known Beacon setting indices (type catalog is public).
|
|
SETTINGS = {
|
|
1: "BeaconType",
|
|
2: "Port",
|
|
3: "SleepTime",
|
|
4: "MaxGetSize",
|
|
5: "Jitter",
|
|
7: "PublicKey",
|
|
8: "C2Server",
|
|
9: "UserAgent",
|
|
10: "HttpPostUri",
|
|
12: "HttpGet_Metadata",
|
|
13: "HttpPost_Metadata",
|
|
19: "SpawnTo",
|
|
26: "PipeName",
|
|
37: "Watermark",
|
|
43: "DNS_Idle",
|
|
}
|
|
|
|
TYPE_SHORT = 1
|
|
TYPE_INT = 2
|
|
TYPE_STR = 3
|
|
|
|
|
|
def _parse_table(buf: bytes) -> dict | None:
|
|
"""Parse a decoded settings table. Returns dict or None if it does not look valid."""
|
|
out: dict = {}
|
|
off = 0
|
|
parsed = 0
|
|
while off + 6 <= len(buf):
|
|
index, vtype, length = struct.unpack_from(">HHH", buf, off)
|
|
off += 6
|
|
if index == 0 and vtype == 0 and length == 0:
|
|
break
|
|
if vtype == TYPE_SHORT and length == 2:
|
|
(val,) = struct.unpack_from(">H", buf, off)
|
|
elif vtype == TYPE_INT and length == 4:
|
|
(val,) = struct.unpack_from(">I", buf, off)
|
|
elif vtype == TYPE_STR and 0 < length <= 4096:
|
|
val = buf[off:off + length].rstrip(b"\x00").decode("latin-1", "replace")
|
|
else:
|
|
return None # malformed -> wrong key
|
|
off += length
|
|
parsed += 1
|
|
name = SETTINGS.get(index, f"Setting_{index}")
|
|
out[name] = val
|
|
if parsed > 128:
|
|
break
|
|
return out if parsed >= 5 else None
|
|
|
|
|
|
def extract_config(data: bytes) -> dict | None:
|
|
"""Brute-force the single-byte XOR key and parse the first valid-looking table."""
|
|
for key in range(1, 256):
|
|
decoded = bytes(b ^ key for b in data)
|
|
# the table begins with index=1 (BeaconType), type=1 (short), len=2
|
|
marker = struct.pack(">HHH", 1, TYPE_SHORT, 2)
|
|
pos = decoded.find(marker)
|
|
if pos == -1:
|
|
continue
|
|
cfg = _parse_table(decoded[pos:])
|
|
if cfg:
|
|
cfg["_xor_key"] = key
|
|
return cfg
|
|
return None
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Extract Cobalt Strike beacon config")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
p = sub.add_parser("parse")
|
|
p.add_argument("file")
|
|
args = parser.parse_args(argv)
|
|
|
|
data = Path(args.file).read_bytes()
|
|
cfg = extract_config(data)
|
|
if cfg is None:
|
|
print(json.dumps({"found": False,
|
|
"note": "no XOR-decoded config table located"}, indent=2))
|
|
return 1
|
|
print(json.dumps({"found": True, "config": cfg}, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|