mirror of
https://github.com/HullaBrian/ttd-capa
synced 2026-08-09 12:08:34 +00:00
Win32 metadata index (win32json) drives per-call signature lookup so arguments are captured at their true arity and type, [Out] params are re-read at the call's return position, and buffers are dumped as bytes.
259 lines
9.9 KiB
Python
259 lines
9.9 KiB
Python
"""
|
|
Print a chronological "timeline" of what the TTD extractor uncovered, ordered by
|
|
TTD trace position so you can step through it in WinDbg.
|
|
|
|
Two modes:
|
|
|
|
* capability timeline (default, needs a rules dir) -- runs capa's matcher over the
|
|
report and lists every capability that fired at call / span-of-calls scope, in
|
|
trace order, each tagged with its TTD position (Sequence:Steps) and thread id:
|
|
|
|
python ttd-timeline.py report.ttd.json -r capa-9.4.0/rules
|
|
|
|
* call timeline (--calls, no rules needed) -- lists every resolved API call the
|
|
extractor recorded, in trace order, with its TTD position, tid, args and retval:
|
|
|
|
python ttd-timeline.py report.ttd.json --calls
|
|
|
|
The TTD position is exactly what WinDbg shows: paste "Sequence:Steps" into the
|
|
time-travel position box, or use `!tt <Sequence>:<Steps>`, to jump to the event.
|
|
|
|
Requires the capa fork to be installed in the active virtual environment.
|
|
"""
|
|
import sys
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
# Recovered guest strings are arbitrary text -- non-Latin paths, HTTP bodies, the
|
|
# occasional lone surrogate from a half-written UTF-16 buffer. When stdout is a
|
|
# console Python uses UTF-8, but when it is redirected to a file it falls back to
|
|
# the locale encoding (cp1252 here), which cannot encode any of that and aborts the
|
|
# whole run. Pin UTF-8 and degrade unencodable characters instead of dying.
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
if hasattr(_stream, "reconfigure"):
|
|
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
|
|
# Python puts this script's directory first on sys.path, and the capa fork lives in
|
|
# a `capa/` subdirectory of it -- which shadows the installed `capa` package with a
|
|
# namespace package that has no submodules. Drop it; nothing here is imported locally.
|
|
sys.path[:] = [p for p in sys.path if p and Path(p).resolve() != HERE]
|
|
|
|
from capa.features.address import ProcessAddress, ThreadAddress, DynamicCallAddress # noqa: E402
|
|
from capa.features.extractors.ttd.models import TtdCall # noqa: E402
|
|
from capa.features.extractors.ttd.extractor import TtdExtractor # noqa: E402
|
|
|
|
|
|
# a position past anything real, so calls without a TTD position sort last
|
|
_NO_POS_KEY = (1 << 64, 1 << 64)
|
|
|
|
|
|
def position_key(call: TtdCall) -> tuple[int, int]:
|
|
"""Sort key from the navigable TTD position 'Sequence:Steps' (hex).
|
|
|
|
Falls back to the synthetic `seq` counter for reports produced before the
|
|
extractor learned to emit positions.
|
|
"""
|
|
if call.position and ":" in call.position:
|
|
seq, _, steps = call.position.partition(":")
|
|
try:
|
|
return (int(seq, 16), int(steps, 16))
|
|
except ValueError:
|
|
pass
|
|
return (call.seq, 0)
|
|
|
|
|
|
def position_label(call: TtdCall) -> str:
|
|
return call.position if call.position else f"seq={call.seq}"
|
|
|
|
|
|
def fmt_arg(a) -> str:
|
|
"""Integers as hex (pointers/handles read naturally); strings quoted."""
|
|
if isinstance(a, bool):
|
|
return repr(a)
|
|
if isinstance(a, int):
|
|
# show negatives as their 64-bit two's-complement form, e.g. -2 -> 0xfffffffffffffffe
|
|
return f"0x{a & 0xFFFFFFFFFFFFFFFF:x}" if a < 0 else f"0x{a:x}"
|
|
return repr(a)
|
|
|
|
|
|
def fmt_param(p) -> str:
|
|
"""One metadata-decoded parameter as `name=value`.
|
|
|
|
Prefers the most informative rendering the extractor managed: decoded text,
|
|
then symbolic flag names, then a dereferenced pointee, then the raw value.
|
|
"""
|
|
if p.str_ is not None:
|
|
value = repr(p.str_)
|
|
elif p.flags:
|
|
value = "|".join(p.flags)
|
|
elif p.float_ is not None:
|
|
value = repr(p.float_)
|
|
else:
|
|
value = fmt_arg(p.value)
|
|
if p.deref is not None:
|
|
value += f"->{fmt_arg(p.deref)}"
|
|
if p.at_return:
|
|
value += "@ret"
|
|
return f"{p.name}={value}" if p.name else value
|
|
|
|
|
|
def format_call(call: TtdCall) -> str:
|
|
api = f"{call.module}.{call.api}" if call.module else call.api
|
|
if call.params:
|
|
args = ", ".join(fmt_param(p) for p in call.params)
|
|
else:
|
|
args = ", ".join(fmt_arg(a) for a in call.args)
|
|
ret = "" if call.ret is None else f" -> 0x{call.ret & 0xFFFFFFFFFFFFFFFF:x}"
|
|
return f"{api}({args}){ret}"
|
|
|
|
|
|
def resolve_call(extractor: TtdExtractor, addr: DynamicCallAddress) -> Optional[TtdCall]:
|
|
"""Map a call/span match address back to the TtdCall the extractor indexed."""
|
|
try:
|
|
return extractor.sorted_calls[addr.thread.process][addr.thread][addr.id]
|
|
except (KeyError, IndexError):
|
|
return None
|
|
|
|
|
|
def iter_all_calls(extractor: TtdExtractor):
|
|
"""Yield (ProcessAddress, ThreadAddress, TtdCall) for every recorded call."""
|
|
for proc_addr, threads in extractor.sorted_calls.items():
|
|
for thread_addr, calls in threads.items():
|
|
for call in calls:
|
|
yield proc_addr, thread_addr, call
|
|
|
|
|
|
def render_call_timeline(extractor: TtdExtractor, limit: Optional[int]) -> int:
|
|
events = [
|
|
(position_key(call), proc.pid, thread.tid, call)
|
|
for proc, thread, call in iter_all_calls(extractor)
|
|
]
|
|
events.sort(key=lambda e: e[0])
|
|
if limit:
|
|
events = events[:limit]
|
|
|
|
posw = max((len(position_label(c)) for _, _, _, c in events), default=8)
|
|
print(f"{'TTD POS':<{posw}} {'PID':>6} {'TID':>6} CALL")
|
|
print("-" * (posw + 2 + 6 + 2 + 6 + 2 + 4))
|
|
for _, pid, tid, call in events:
|
|
print(f"{position_label(call):<{posw}} {pid:>6} {tid:>6} {format_call(call)}")
|
|
sys.stdout.flush()
|
|
print(f"\n{len(events)} calls.", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
def render_capability_timeline(extractor: TtdExtractor, rules_path: Path, limit: Optional[int]) -> int:
|
|
import capa.rules
|
|
import capa.rules.cache # noqa: F401 (registers capa.rules.cache used by get_rules)
|
|
from capa.capabilities.common import find_capabilities
|
|
|
|
print(f"[ttd-timeline] loading rules from {rules_path} ...", file=sys.stderr)
|
|
ruleset = capa.rules.get_rules([rules_path])
|
|
|
|
print("[ttd-timeline] matching ...", file=sys.stderr)
|
|
capabilities = find_capabilities(ruleset, extractor, disable_progress=True)
|
|
|
|
timeline = [] # (poskey, pos_label, tid, namespace, rule, call_name)
|
|
unpositioned: dict[str, str] = {} # rule -> namespace, for process/thread/file scope
|
|
|
|
for rule_name, results in capabilities.matches.items():
|
|
rule = ruleset[rule_name]
|
|
if rule.is_subscope_rule():
|
|
# synthetic helper rules, not user-facing capabilities
|
|
continue
|
|
namespace = rule.meta.get("namespace", "") or ""
|
|
if namespace.startswith("internal/") or rule.meta.get("lib"):
|
|
# capa uses these internally; they aren't user-facing capabilities
|
|
continue
|
|
|
|
for addr, _ in results:
|
|
if isinstance(addr, DynamicCallAddress):
|
|
call = resolve_call(extractor, addr)
|
|
if call is None:
|
|
continue
|
|
timeline.append(
|
|
(
|
|
position_key(call),
|
|
position_label(call),
|
|
addr.thread.tid,
|
|
namespace,
|
|
rule_name,
|
|
format_call(call),
|
|
)
|
|
)
|
|
else:
|
|
# process / thread / file scope: no single navigable position
|
|
unpositioned[rule_name] = namespace
|
|
|
|
timeline.sort(key=lambda e: e[0])
|
|
if limit:
|
|
timeline = timeline[:limit]
|
|
|
|
if timeline:
|
|
posw = max(len("TTD POS"), max(len(e[1]) for e in timeline))
|
|
rulew = min(38, max(len(e[4]) for e in timeline))
|
|
nsw = min(34, max(len(e[3]) for e in timeline))
|
|
header = f"{'TTD POS':<{posw}} {'TID':>6} {'CAPABILITY':<{rulew}} {'NAMESPACE':<{nsw}} TRIGGERING CALL"
|
|
print(header)
|
|
print("-" * len(header))
|
|
for _, pos, tid, namespace, rule_name, call_name in timeline:
|
|
print(f"{pos:<{posw}} {tid:>6} {rule_name:<{rulew}} {namespace:<{nsw}} {call_name}")
|
|
|
|
if unpositioned:
|
|
print("\n# capabilities matched at process/thread/file scope (no single TTD position):")
|
|
for rule_name, namespace in sorted(unpositioned.items()):
|
|
print(f" {rule_name} [{namespace}]")
|
|
|
|
sys.stdout.flush()
|
|
print(
|
|
f"\n{len(timeline)} positioned capability matches; "
|
|
f"{len(unpositioned)} scope-level capabilities.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Print a TTD-position-ordered timeline of capabilities (or raw API calls) "
|
|
"uncovered in a TTD report.",
|
|
)
|
|
parser.add_argument("report", help="path to a TTD report (.ttd.json) from ttdcapa-extract")
|
|
parser.add_argument("-r", "--rules", help="capa rules directory (enables the capability timeline)")
|
|
parser.add_argument(
|
|
"--calls",
|
|
action="store_true",
|
|
help="list every recorded API call instead of capa capabilities (no rules needed)",
|
|
)
|
|
parser.add_argument("--limit", type=int, help="show only the first N timeline rows")
|
|
args = parser.parse_args(argv)
|
|
|
|
report_path = Path(args.report)
|
|
if not report_path.is_file():
|
|
sys.exit(f"report not found: {report_path}")
|
|
|
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
extractor = TtdExtractor.from_report(report)
|
|
|
|
if args.calls or not args.rules:
|
|
if not args.calls and not args.rules:
|
|
print(
|
|
"[ttd-timeline] no --rules given; showing the API-call timeline. "
|
|
"pass -r <rules-dir> for a capability timeline.",
|
|
file=sys.stderr,
|
|
)
|
|
return render_call_timeline(extractor, args.limit)
|
|
|
|
rules_path = Path(args.rules)
|
|
if not rules_path.exists():
|
|
sys.exit(f"rules path not found: {rules_path}")
|
|
return render_capability_timeline(extractor, rules_path, args.limit)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|