Files
HullaBrian-ttd-capa/ttd-timeline.py
T
Xusheng 19dc1581ce Sync metadata-driven parameter decoding build from author
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.
2026-07-30 15:06:07 -04:00

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:]))