#!/usr/bin/env python3 """ correlate.py - turn the CSVs from parse_strace.py into an analyst's summary. correlate.py OUTDIR --binary ./prog [--exclude PREFIX]... Reads files.csv / processes.csv / network.csv (real CSV parsing - the detail columns contain commas), compares the loaded libraries against ldd(1), and prints the body of ANALYSIS.md on stdout. """ import argparse import csv import os import re import subprocess import sys SO_RE = re.compile(r"\.so($|\.\d)") # paths every dynamically linked program touches - not interesting on their own BORING = re.compile( r"^(/etc/ld\.so\.(cache|preload)|/usr/lib/locale/|/usr/lib64/gconv/|" r"/sys/devices/system/cpu|/proc/self/(maps|task)|/usr/share/locale/)") SENSITIVE = [ (re.compile(r"/etc/(shadow|gshadow|sudoers)"), "reads the local password/sudo database"), (re.compile(r"/\.ssh/|/etc/ssh/ssh_host_\w+_key"), "reads SSH key material"), (re.compile(r"\.(pem|key|p12|pfx|jks|keytab)$"), "reads certificate or key files"), (re.compile(r"/etc/(passwd|group)$"), "enumerates local accounts"), (re.compile(r"^/proc/\d+/(mem|environ|cmdline)"), "inspects other processes' memory or environment"), (re.compile(r"/var/log/"), "touches system logs"), (re.compile(r"^/(etc|usr|bin|sbin|boot)/"), "writes into a system directory"), (re.compile(r"/(bash_history|\.netrc|\.aws/|\.docker/config)"), "reads credential caches"), ] SHELLS = re.compile(r"/(ba|da|k|z|tc|c)?sh$|/usr/bin/env$") def read_csv(path): if not os.path.exists(path): return [] with open(path, newline="") as fh: return list(csv.DictReader(fh)) def code(lines, empty="(none)"): body = "\n".join(lines) if lines else empty return "```\n%s\n```" % body def main(): ap = argparse.ArgumentParser() ap.add_argument("outdir") ap.add_argument("--binary", required=True) ap.add_argument("--exclude", action="append", default=[], help="ignore paths under this prefix (e.g. the report dir itself)") a = ap.parse_args() files = read_csv(os.path.join(a.outdir, "files.csv")) procs = read_csv(os.path.join(a.outdir, "processes.csv")) net = read_csv(os.path.join(a.outdir, "network.csv")) def keep(p): return not any(p.startswith(x) for x in a.exclude) files = [f for f in files if keep(f["path"])] # ---- libraries: declared (ldd) vs actually mapped ---------------------- declared = set() try: out = subprocess.run(["ldd", a.binary], capture_output=True, text=True, timeout=20).stdout for m in re.finditer(r"(/\S+\.so[^\s,)]*)", out): declared.add(os.path.realpath(m.group(1))) except Exception: pass loaded = set() for f in files: if SO_RE.search(f["path"]) and "mmap" in f["access"]: try: loaded.add(os.path.realpath(f["path"])) except OSError: loaded.add(f["path"]) runtime_only = sorted(loaded - declared) written = [f for f in files if re.search(r"write|create|delete", f["access"])] missing = [f for f in files if "ENOENT" in (f["errors"] or "")] interesting_missing = [f for f in missing if not BORING.search(f["path"])] print("## Declared vs actually loaded shared libraries\n") print("`ldd` lists %d libraries; %d distinct .so files were mapped at runtime." % (len(declared), len(loaded))) print("Anything below was pulled in by `dlopen()` or by NSS/PAM plugins - " "static inspection alone would have missed it.\n") print(code(runtime_only)) print("\n## Files written, created or deleted\n") print(code(["%-60s [%s]" % (f["path"], f["access"]) for f in written])) print("\n## Programs executed\n") rows = [f for f in procs if f.get("image")] exec_lines = [] for r in rows: imgs = (r.get("all_images") or r["image"]).split(";") for i, img in enumerate(imgs): argv = (r["argv"] or "")[:70] if i == len(imgs) - 1 else "(then re-exec'd)" exec_lines.append("pid %-8s %-40s %s" % (r["pid"], img, argv)) print(code(exec_lines)) forked = [r for r in procs if r.get("parent") and r.get("kind", "process") != "thread"] threads = [r for r in procs if r.get("kind") == "thread"] print("\n%d process(es) were created by the target (fork/clone)." % len(forked)) if threads: print("%d additional task(s) were threads (CLONE_THREAD), not processes - " "normal for a threaded server." % len(threads)) print("\n## Paths probed but absent (ENOENT)\n") print("These are the config, plugin and $PATH locations the program would have " "honoured had they existed.\n") print(code([f["path"] for f in interesting_missing])) print("\n## Network activity\n") eps = ["%-10s %s -> %s" % (n["call"], n["detail"][:90], n["result"]) for n in net if n["call"] in ("connect", "bind", "sendto", "listen")] print(code(eps)) print("\n## Automatic flags\n") flags = [] for f in files: for rx, why in SENSITIVE: if rx.search(f["path"]): if why == "writes into a system directory" and not re.search( r"write|create|delete", f["access"]): continue flags.append("%-58s %s" % (f["path"], why)) break for r in procs: for img in (r.get("all_images") or r.get("image") or "").split(";"): if img and SHELLS.search(img): flags.append("%-58s %s" % (img, "a shell was exec'd - check where the command string comes from")) for lib in runtime_only: d = os.path.dirname(lib) try: st = os.stat(d) if st.st_mode & 0o002: flags.append("%-58s %s" % (lib, "dlopen from a world-writable directory - hijackable")) except OSError: pass print(code(sorted(set(flags)), "no automatic flags raised for this run")) print("\n_Flags are heuristics over one run. Absence of a flag is not a clean bill " "of health: you only see the code paths your arguments exercised._") if __name__ == "__main__": main()