reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
procmon_summary.py - condense a Procmon CSV export into a behavior summary.
|
||||
|
||||
Runs on the host against evidence exported from the analysis VM
|
||||
(Procmon: File -> Save -> All events -> CSV). Stdlib only.
|
||||
|
||||
Usage:
|
||||
python3 procmon_summary.py procmon.csv --process sample.exe
|
||||
python3 procmon_summary.py procmon.csv --all # no process filter
|
||||
python3 procmon_summary.py --self-test
|
||||
|
||||
--process takes a comma list of image names (case-insensitive). The summary
|
||||
covers those processes plus every descendant found via "Process Create".
|
||||
Without --process (or with --all) every process in the log is included.
|
||||
|
||||
Output sections: process tree, files written/renamed/deleted, registry writes
|
||||
(persistence keys flagged), network endpoints, non-system image loads.
|
||||
Pipe the output through scripts/ioc_extract.py to get defanged IOCs.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict, OrderedDict
|
||||
|
||||
PERSISTENCE_RE = re.compile(
|
||||
r"CurrentVersion\\(Run|RunOnce|RunServices|Explorer\\(Shell Folders|User Shell Folders))"
|
||||
r"|CurrentControlSet\\Services\\"
|
||||
r"|Winlogon\\(Shell|Userinit|Notify)"
|
||||
r"|Image File Execution Options"
|
||||
r"|Windows\\CurrentVersion\\Policies\\Explorer\\Run"
|
||||
r"|Schedule\\TaskCache"
|
||||
r"|Office\\.*\\Security"
|
||||
r"|AppInit_DLLs"
|
||||
r"|Start Menu\\Programs\\Startup",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SYSTEM_DIR_RE = re.compile(r"^[A-Z]:\\Windows\\", re.IGNORECASE)
|
||||
PID_RE = re.compile(r"PID:\s*(\d+)")
|
||||
CMD_RE = re.compile(r"Command line:\s*(.*?)(?:,\s*Current directory:|$)", re.DOTALL)
|
||||
NEWFILE_RE = re.compile(r"OpenResult:\s*(Created|Overwritten|Superseded)")
|
||||
|
||||
|
||||
def read_text(path):
|
||||
"""Read exported evidence regardless of PowerShell's default encoding (UTF-16LE with BOM)."""
|
||||
raw = open(path, "rb").read()
|
||||
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
||||
return raw.decode("utf-16", errors="replace")
|
||||
return raw.decode("utf-8-sig", errors="replace")
|
||||
|
||||
|
||||
def load(path):
|
||||
return list(csv.DictReader(io.StringIO(read_text(path))))
|
||||
|
||||
|
||||
def field(row, *names):
|
||||
for n in names:
|
||||
if n in row and row[n] is not None:
|
||||
return row[n]
|
||||
return ""
|
||||
|
||||
|
||||
def build_tree(rows):
|
||||
"""Return (children: pid -> [pid], info: pid -> (name, cmdline))."""
|
||||
children = defaultdict(list)
|
||||
info = {}
|
||||
for r in rows:
|
||||
op = field(r, "Operation")
|
||||
if op == "Process Create":
|
||||
m = PID_RE.search(field(r, "Detail"))
|
||||
if not m:
|
||||
continue
|
||||
child = m.group(1)
|
||||
parent = field(r, "PID")
|
||||
children[parent].append(child)
|
||||
cmd = CMD_RE.search(field(r, "Detail"))
|
||||
# name the child from the created image path (the actor row's name is the parent)
|
||||
created = field(r, "Path")
|
||||
child_name = created.rsplit("\\", 1)[-1] if created else "?"
|
||||
info.setdefault(child, [child_name or "?", cmd.group(1).strip() if cmd else ""])
|
||||
elif op == "Process Start":
|
||||
pid = field(r, "PID")
|
||||
cmd = CMD_RE.search(field(r, "Detail"))
|
||||
info[pid] = [field(r, "Process Name"), cmd.group(1).strip() if cmd else ""]
|
||||
for r in rows: # fill names for processes seen only as actors
|
||||
pid = field(r, "PID")
|
||||
if pid and pid not in info:
|
||||
info[pid] = [field(r, "Process Name"), ""]
|
||||
elif pid in info and info[pid][0] == "?":
|
||||
info[pid][0] = field(r, "Process Name")
|
||||
return children, info
|
||||
|
||||
|
||||
def descendants(seeds, children):
|
||||
seen, stack = set(), list(seeds)
|
||||
while stack:
|
||||
p = stack.pop()
|
||||
if p in seen:
|
||||
continue
|
||||
seen.add(p)
|
||||
stack.extend(children.get(p, []))
|
||||
return seen
|
||||
|
||||
|
||||
def print_tree(pid, children, info, depth, out):
|
||||
name, cmd = info.get(pid, ("?", ""))
|
||||
out.append(f"{' ' * depth}{name} (PID {pid})" + (f" {cmd}" if cmd else ""))
|
||||
for c in children.get(pid, []):
|
||||
print_tree(c, children, info, depth + 1, out)
|
||||
|
||||
|
||||
def summarize(rows, process_names=None):
|
||||
children, info = build_tree(rows)
|
||||
if process_names:
|
||||
wanted = {n.lower() for n in process_names}
|
||||
seeds = [pid for pid, (name, _) in info.items() if name.lower() in wanted]
|
||||
scope = descendants(seeds, children)
|
||||
roots = seeds
|
||||
else:
|
||||
scope = set(info)
|
||||
all_children = {c for cs in children.values() for c in cs}
|
||||
roots = [p for p in info if p not in all_children]
|
||||
|
||||
files = OrderedDict()
|
||||
renames, deletes = OrderedDict(), OrderedDict()
|
||||
reg = OrderedDict()
|
||||
net = OrderedDict()
|
||||
loads = OrderedDict()
|
||||
counts = defaultdict(int)
|
||||
|
||||
for r in rows:
|
||||
pid = field(r, "PID")
|
||||
if pid not in scope:
|
||||
continue
|
||||
op, path, detail = field(r, "Operation"), field(r, "Path"), field(r, "Detail")
|
||||
who = f"{field(r, 'Process Name')}:{pid}"
|
||||
counts[who] += 1
|
||||
if op == "CreateFile" and NEWFILE_RE.search(detail):
|
||||
files.setdefault(path, who)
|
||||
elif op == "WriteFile":
|
||||
files.setdefault(path, who)
|
||||
elif op == "SetRenameInformationFile":
|
||||
m = re.search(r"FileName:\s*(.*)$", detail)
|
||||
renames.setdefault(f"{path} -> {m.group(1) if m else '?'}", who)
|
||||
elif op in ("SetDispositionInformationFile", "SetDispositionInformationEx") and "True" in detail:
|
||||
deletes.setdefault(path, who)
|
||||
elif op in ("RegSetValue", "RegDeleteValue", "RegDeleteKey") or (
|
||||
op == "RegCreateKey" and "REG_CREATED_NEW_KEY" in detail
|
||||
):
|
||||
data = ""
|
||||
m = re.search(r"Data:\s*(.*)$", detail)
|
||||
if m:
|
||||
data = m.group(1)
|
||||
reg.setdefault(f"{op} {path}" + (f" = {data}" if data else ""), who)
|
||||
elif op.startswith(("TCP", "UDP")) and "->" in path:
|
||||
dst = path.split("->", 1)[1].strip()
|
||||
net.setdefault(f"{op.split()[0]} {dst}", who)
|
||||
elif op == "Load Image" and not SYSTEM_DIR_RE.match(path):
|
||||
loads.setdefault(path, who)
|
||||
|
||||
out = [f"# Procmon summary ({len(rows)} events, {len(scope)} processes in scope)", "", "## Process tree"]
|
||||
for root in roots:
|
||||
print_tree(root, children, info, 0, out)
|
||||
|
||||
def section(title, items, limit=200):
|
||||
out.extend(["", f"## {title} ({len(items)})"])
|
||||
for i, (k, who) in enumerate(items.items()):
|
||||
if i >= limit:
|
||||
out.append(f"... {len(items) - limit} more")
|
||||
break
|
||||
out.append(f"- {k} [{who}]")
|
||||
|
||||
section("Files created/written", files)
|
||||
section("Files renamed", renames)
|
||||
section("Files deleted", deletes)
|
||||
out.extend(["", f"## Registry writes ({len(reg)})"])
|
||||
for k, who in reg.items():
|
||||
flag = " <-- PERSISTENCE" if PERSISTENCE_RE.search(k) else ""
|
||||
out.append(f"- {k} [{who}]{flag}")
|
||||
section("Network endpoints", net)
|
||||
section("Image loads outside C:\\Windows", loads)
|
||||
out.extend(["", "## Event count per process"])
|
||||
for who, n in sorted(counts.items(), key=lambda x: -x[1])[:20]:
|
||||
out.append(f"- {who}: {n}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
SELF_TEST_CSV = '''"Time of Day","Process Name","PID","Operation","Path","Result","Detail"
|
||||
"1","explorer.exe","100","Process Create","C:\\Users\\Public\\sample.exe","SUCCESS","PID: 200, Command line: ""C:\\Users\\Public\\sample.exe"" /install"
|
||||
"2","sample.exe","200","Process Start","","SUCCESS","Parent PID: 100, Command line: ""C:\\Users\\Public\\sample.exe"" /install, Current directory: C:\\Users\\Public\\"
|
||||
"3","sample.exe","200","CreateFile","C:\\Users\\Bob\\AppData\\Roaming\\svc.exe","SUCCESS","Desired Access: Generic Write, OpenResult: Created"
|
||||
"4","sample.exe","200","RegSetValue","HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\svc","SUCCESS","Type: REG_SZ, Length: 60, Data: C:\\Users\\Bob\\AppData\\Roaming\\svc.exe"
|
||||
"5","sample.exe","200","TCP Connect","host.local:49700 -> 203.0.113.5:443","SUCCESS","Length: 0"
|
||||
"6","sample.exe","200","Process Create","C:\\Windows\\System32\\cmd.exe","SUCCESS","PID: 300, Command line: cmd.exe /c del sample.exe"
|
||||
"7","cmd.exe","300","SetDispositionInformationFile","C:\\Users\\Public\\sample.exe","SUCCESS","Delete: True"
|
||||
"8","chrome.exe","999","WriteFile","C:\\Users\\Bob\\cache.dat","SUCCESS","Offset: 0"
|
||||
'''
|
||||
|
||||
|
||||
def self_test():
|
||||
rows = list(csv.DictReader(io.StringIO(SELF_TEST_CSV)))
|
||||
out = summarize(rows, ["sample.exe"])
|
||||
assert "cmd.exe (PID 300)" in out and " cmd.exe" in out, "child not nested under sample"
|
||||
assert "svc.exe" in out and "PERSISTENCE" in out, "persistence run key not flagged"
|
||||
assert "203.0.113.5:443" in out, "network endpoint missing"
|
||||
assert "cache.dat" not in out, "unrelated process leaked into scope"
|
||||
assert "Files deleted (1)" in out, "deletion missing"
|
||||
print("self-test OK")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("csv", nargs="?")
|
||||
ap.add_argument("--process", help="comma list of image names to scope on (plus descendants)")
|
||||
ap.add_argument("--all", action="store_true", help="include every process")
|
||||
ap.add_argument("--self-test", action="store_true")
|
||||
a = ap.parse_args()
|
||||
if a.self_test:
|
||||
return self_test()
|
||||
if not a.csv:
|
||||
ap.error("csv path required")
|
||||
names = None if a.all or not a.process else [n.strip() for n in a.process.split(",")]
|
||||
print(summarize(load(a.csv), names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user