forked from Keelrleek/Harbor
Add shared reverse-engineering lookups (re_disassemble, re_xrefs, re_bytes, re_lookup)
Read the project's flat image, facts.jsonl and annotations with the team's current names; exposed as MCP tools and as 'harbor re'. Configured by a [reverse] table in the project file.
This commit is contained in:
1 parent
dbc0c45ef2
commit
3f6df88f95
5 files changed
+410
-2
No files matched your search
@@ -254,6 +254,12 @@ def build_parser():
|
|||||||
p.add_argument("--file")
|
p.add_argument("--file")
|
||||||
p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)")
|
p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)")
|
||||||
p.add_argument("scope")
|
p.add_argument("scope")
|
||||||
|
p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations")
|
||||||
|
p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup"])
|
||||||
|
p.add_argument("target", help="an address (0x4AD850) or a name")
|
||||||
|
p.add_argument("--max-bytes", type=int, dest="max_bytes")
|
||||||
|
p.add_argument("--count", type=int, default=16)
|
||||||
|
p.add_argument("--as", dest="as_type", default="hex", choices=["hex", "int32", "uint32", "float", "double", "string"])
|
||||||
p = sub.add_parser("feed", help="the latest tool calls of the team (or one agent), from the hooks")
|
p = sub.add_parser("feed", help="the latest tool calls of the team (or one agent), from the hooks")
|
||||||
p.add_argument("--agent", dest="filter_agent")
|
p.add_argument("--agent", dest="filter_agent")
|
||||||
p.add_argument("--limit", type=int, default=30)
|
p.add_argument("--limit", type=int, default=30)
|
||||||
@@ -666,6 +672,21 @@ def main(argv=None):
|
|||||||
text = Path(args.file).read_text(encoding="utf-8") if args.file else " ".join(args.text)
|
text = Path(args.file).read_text(encoding="utf-8") if args.file else " ".join(args.text)
|
||||||
r = harbor.summarize(who(), args.thread, text)
|
r = harbor.summarize(who(), args.thread, text)
|
||||||
out(r, f"summary #{r['id']} pinned to {r['thread']}")
|
out(r, f"summary #{r['id']} pinned to {r['thread']}")
|
||||||
|
elif cmd == "re":
|
||||||
|
from .reverse import Reverse
|
||||||
|
r = Reverse(harbor.project.reverse)
|
||||||
|
if args.action == "disasm":
|
||||||
|
text = r.disassemble(args.target, args.max_bytes)
|
||||||
|
out({"disassembly": text}, text)
|
||||||
|
elif args.action == "xrefs":
|
||||||
|
res = r.xrefs(args.target)
|
||||||
|
out(res, json.dumps(res, indent=1))
|
||||||
|
elif args.action == "bytes":
|
||||||
|
res = r.bytes_at(args.target, args.count, args.as_type)
|
||||||
|
out(res, json.dumps(res, indent=1))
|
||||||
|
else:
|
||||||
|
res = r.lookup(args.target)
|
||||||
|
out(res, json.dumps(res, indent=1))
|
||||||
elif cmd == "who":
|
elif cmd == "who":
|
||||||
rows = harbor.experts(args.scope)
|
rows = harbor.experts(args.scope)
|
||||||
out(rows, "\n".join(f"{e['agent']:<9} " + ", ".join(f"{v} {k}" for k, v in e["detail"].items()) for e in rows)
|
out(rows, "\n".join(f"{e['agent']:<9} " + ", ".join(f"{v} {k}" for k, v in e["detail"].items()) for e in rows)
|
||||||
|
|||||||
+49
-1
@@ -33,7 +33,9 @@ review queue, presence and stop flags. Protocol:
|
|||||||
7. Put finished work in the review queue with submit(); do not announce commits in chat. A coordinator lands it.
|
7. Put finished work in the review queue with submit(); do not announce commits in chat. A coordinator lands it.
|
||||||
8. When you are waiting on someone, use wait_for_messages() instead of polling or sleeping.
|
8. When you are waiting on someone, use wait_for_messages() instead of polling or sleeping.
|
||||||
9. A correction is send(..., corrects=<id of your earlier message>).
|
9. A correction is send(..., corrects=<id of your earlier message>).
|
||||||
10. Before your session ends or your allowance runs out, write handoff(): finished, in flight, next."""
|
10. Before your session ends or your allowance runs out, write handoff(): finished, in flight, next.
|
||||||
|
11. To read the original machine code use re_disassemble, re_xrefs, re_bytes and re_lookup: they read the project's
|
||||||
|
own image with the team's current names, so do not write your own disassembler or offset maths."""
|
||||||
|
|
||||||
KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"]
|
KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"]
|
||||||
Importance = Literal["normal", "high", "urgent"]
|
Importance = Literal["normal", "high", "urgent"]
|
||||||
@@ -436,6 +438,52 @@ def resume(target: str, relay_of: int | None = None, reason: str | None = None)
|
|||||||
return run(do)
|
return run(do)
|
||||||
|
|
||||||
|
|
||||||
|
_reverse = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _re(fn):
|
||||||
|
"""Run a reverse-engineering lookup against the project's [reverse] files (read-only, no Harbor lock needed)."""
|
||||||
|
try:
|
||||||
|
h = harbor()
|
||||||
|
r = _reverse.get(h.project.name)
|
||||||
|
if r is None:
|
||||||
|
from .reverse import Reverse
|
||||||
|
r = _reverse[h.project.name] = Reverse(h.project.reverse)
|
||||||
|
return fn(r)
|
||||||
|
except (HarborError, projects.ProjectError) as error:
|
||||||
|
raise ToolError(str(error)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def re_disassemble(target: str, max_bytes: int | None = None) -> str:
|
||||||
|
"""Disassemble the whole function at an address or name (or max_bytes from the address), from the project's own
|
||||||
|
image of the executable, with call targets, imports and known globals named from the team's annotations. Use
|
||||||
|
this instead of writing your own disassembler script."""
|
||||||
|
return _re(lambda r: r.disassemble(target, max_bytes))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def re_xrefs(target: str) -> dict:
|
||||||
|
"""Callers and callees of the function at an address or name, plus the globals, imports and strings it uses,
|
||||||
|
each with its current name."""
|
||||||
|
return _re(lambda r: r.xrefs(target))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def re_bytes(target: str, count: int = 16, as_type: Literal["hex", "int32", "uint32", "float", "double",
|
||||||
|
"string"] = "hex") -> dict:
|
||||||
|
"""Bytes at an address or name, read as hex, int32, uint32, float, double or a C string (constants, tables,
|
||||||
|
initial values of globals)."""
|
||||||
|
return _re(lambda r: r.bytes_at(target, count, as_type))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def re_lookup(target: str) -> dict:
|
||||||
|
"""What the team has recorded about an address or name: the names.csv, functions.csv and globals.csv rows, the
|
||||||
|
prototype, the containing function, and the decompiled file with its status."""
|
||||||
|
return _re(lambda r: r.lookup(target))
|
||||||
|
|
||||||
|
|
||||||
def _report_in():
|
def _report_in():
|
||||||
"""Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the
|
"""Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the
|
||||||
server open (updates the connection only, not the agent's last-seen time)."""
|
server open (updates the connection only, not the agent's last-seen time)."""
|
||||||
|
|||||||
+3
-1
@@ -94,6 +94,7 @@ class Project:
|
|||||||
policy: dict = field(default_factory=dict)
|
policy: dict = field(default_factory=dict)
|
||||||
autopilot: bool = False # running agents keep working instead of ending their turns (hooks)
|
autopilot: bool = False # running agents keep working instead of ending their turns (hooks)
|
||||||
shared_files: tuple = () # glob patterns (repo-relative) every agent edits and the integrator merges: not collisions
|
shared_files: tuple = () # glob patterns (repo-relative) every agent edits and the integrator merges: not collisions
|
||||||
|
reverse: dict = field(default_factory=dict) # [reverse]: image, base, facts, imports, annotations, src (harbor.reverse)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def names(self):
|
def names(self):
|
||||||
@@ -207,7 +208,8 @@ def project_from_dict(data, source=None, home=None):
|
|||||||
source=source, git_main=str(data.get("git", {}).get("main", "main")),
|
source=source, git_main=str(data.get("git", {}).get("main", "main")),
|
||||||
templates=_templates(data.get("templates", {}), names), policy=_policy(data.get("policy"), names),
|
templates=_templates(data.get("templates", {}), names), policy=_policy(data.get("policy"), names),
|
||||||
autopilot=bool(data.get("autopilot", False)),
|
autopilot=bool(data.get("autopilot", False)),
|
||||||
shared_files=tuple(str(p).replace("\\", "/") for p in data.get("shared_files", [])))
|
shared_files=tuple(str(p).replace("\\", "/") for p in data.get("shared_files", [])),
|
||||||
|
reverse=dict(data.get("reverse", {})))
|
||||||
|
|
||||||
|
|
||||||
def load_file(path, home=None):
|
def load_file(path, home=None):
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Shared reverse-engineering lookups for the agents, backed by the project's own image and annotations.
|
||||||
|
|
||||||
|
Configured per project in a [reverse] table:
|
||||||
|
image = 'R:/.../image.bin' flat image of the executable, the first byte at `base`
|
||||||
|
base = 0x401000
|
||||||
|
facts = 'R:/.../facts.jsonl' one JSON object per function: address, size, name, calls, data, ...
|
||||||
|
imports = 'R:/.../imports.json' optional: [{module, name, slot}]
|
||||||
|
annotations = 'R:/.../annotations' names.csv, functions.csv, globals.csv, prototypes.h
|
||||||
|
src = 'R:/.../src' optional: the decompiled C files (<ADDR>_<Name>.c)
|
||||||
|
|
||||||
|
Every answer comes from those files, read fresh when they change, so the agents see the team's current names.
|
||||||
|
Nothing here writes anything."""
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .util import HarborError
|
||||||
|
|
||||||
|
MAX_BYTES = 0x4000
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value):
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
return int(text, 16) if text.lower().startswith("0x") or re.fullmatch(r"[0-9A-Fa-f]{6,8}", text) else int(text)
|
||||||
|
|
||||||
|
|
||||||
|
class Reverse:
|
||||||
|
def __init__(self, config):
|
||||||
|
if not config or not config.get("image"):
|
||||||
|
raise HarborError("this project has no [reverse] table (image, base, facts, annotations)")
|
||||||
|
self.image = Path(config["image"])
|
||||||
|
self.base = _int(config.get("base", 0x401000))
|
||||||
|
self.facts_path = Path(config["facts"]) if config.get("facts") else None
|
||||||
|
self.imports_path = Path(config["imports"]) if config.get("imports") else None
|
||||||
|
self.annotations = Path(config["annotations"]) if config.get("annotations") else None
|
||||||
|
self.src = Path(config["src"]) if config.get("src") else None
|
||||||
|
self._cache = {}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- loading (cached by file mtime)
|
||||||
|
def _load(self, key, path, loader):
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return loader(None)
|
||||||
|
stamp = path.stat().st_mtime
|
||||||
|
hit = self._cache.get(key)
|
||||||
|
if hit and hit[0] == stamp:
|
||||||
|
return hit[1]
|
||||||
|
value = loader(path)
|
||||||
|
self._cache[key] = (stamp, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _bytes(self):
|
||||||
|
return self._load("image", self.image, lambda p: p.read_bytes() if p else b"")
|
||||||
|
|
||||||
|
def _facts(self):
|
||||||
|
def load(p):
|
||||||
|
out = {}
|
||||||
|
if p:
|
||||||
|
for line in p.open(encoding="utf-8"):
|
||||||
|
if line.strip():
|
||||||
|
d = json.loads(line)
|
||||||
|
out[_int(d["address"])] = d
|
||||||
|
return out
|
||||||
|
return self._load("facts", self.facts_path, load)
|
||||||
|
|
||||||
|
def _callers(self):
|
||||||
|
facts = self._facts()
|
||||||
|
key = ("callers", id(facts))
|
||||||
|
if key not in self._cache:
|
||||||
|
callers = {}
|
||||||
|
for addr, d in facts.items():
|
||||||
|
for c in d.get("calls", []):
|
||||||
|
callers.setdefault(_int(c), []).append(addr)
|
||||||
|
self._cache[key] = callers
|
||||||
|
return self._cache[key]
|
||||||
|
|
||||||
|
def _imports(self):
|
||||||
|
return self._load("imports", self.imports_path, lambda p: {
|
||||||
|
int(d["slot"]): f"{d['module']}!{d['name']}" for d in json.load(p.open(encoding="utf-8"))} if p else {})
|
||||||
|
|
||||||
|
def _csv(self, name):
|
||||||
|
path = self.annotations / name if self.annotations else None
|
||||||
|
|
||||||
|
def load(p):
|
||||||
|
rows = {}
|
||||||
|
if p:
|
||||||
|
for row in csv.reader(p.open(encoding="utf-8")):
|
||||||
|
if row and row[0].lower().startswith("0x"):
|
||||||
|
rows[_int(row[0])] = row
|
||||||
|
return rows
|
||||||
|
return self._load(name, path, load)
|
||||||
|
|
||||||
|
def _prototypes(self):
|
||||||
|
path = self.annotations / "prototypes.h" if self.annotations else None
|
||||||
|
|
||||||
|
def load(p):
|
||||||
|
out = {}
|
||||||
|
if p:
|
||||||
|
lines = p.read_text(encoding="utf-8").split("\n")
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
m = re.match(r"^[A-Za-z_][^(;]*?\b([A-Za-z_]\w*)\s*\(", line) if not line.startswith(
|
||||||
|
("typedef", "#", "/", " ")) else None
|
||||||
|
j = i + 1
|
||||||
|
while m and j < len(lines) and lines[j].startswith(" ") and not re.match(r"^[A-Za-z_]", lines[j]):
|
||||||
|
j += 1
|
||||||
|
if m:
|
||||||
|
out[m.group(1)] = "\n".join(lines[i:j])
|
||||||
|
i = j if m else i + 1
|
||||||
|
return out
|
||||||
|
return self._load("prototypes", path, load)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- names
|
||||||
|
def function_at(self, addr):
|
||||||
|
"""The function containing addr: (start, size, name), from facts.jsonl, named from names.csv."""
|
||||||
|
facts = self._facts()
|
||||||
|
if addr in facts:
|
||||||
|
start = addr
|
||||||
|
else:
|
||||||
|
starts = [a for a in facts if a <= addr < a + int(facts[a].get("size") or 0)]
|
||||||
|
if not starts:
|
||||||
|
return None
|
||||||
|
start = max(starts)
|
||||||
|
d = facts[start]
|
||||||
|
return start, int(d.get("size") or 0), self.name_of(start) or d.get("name")
|
||||||
|
|
||||||
|
def name_of(self, addr):
|
||||||
|
row = self._csv("names.csv").get(addr)
|
||||||
|
if row and len(row) > 1 and row[1]:
|
||||||
|
return row[1]
|
||||||
|
g = self._csv("globals.csv").get(addr)
|
||||||
|
if g and len(g) > 1 and g[1]:
|
||||||
|
return g[1]
|
||||||
|
imp = self._imports().get(addr)
|
||||||
|
if imp:
|
||||||
|
return imp
|
||||||
|
d = self._facts().get(addr)
|
||||||
|
return d.get("name") if d else None
|
||||||
|
|
||||||
|
def address_of(self, name):
|
||||||
|
for table in ("names.csv", "globals.csv"):
|
||||||
|
for addr, row in self._csv(table).items():
|
||||||
|
if len(row) > 1 and row[1] == name:
|
||||||
|
return addr
|
||||||
|
for addr, d in self._facts().items():
|
||||||
|
if d.get("name") == name:
|
||||||
|
return addr
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve(self, target):
|
||||||
|
"""'0x4AD850', '4AD850', or a name -> address."""
|
||||||
|
text = str(target).strip()
|
||||||
|
if re.fullmatch(r"(0x)?[0-9A-Fa-f]{5,8}", text):
|
||||||
|
return int(text, 16)
|
||||||
|
addr = self.address_of(text)
|
||||||
|
if addr is None:
|
||||||
|
raise HarborError(f"{target!r} is neither an address nor a known name")
|
||||||
|
return addr
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- tools
|
||||||
|
def read(self, addr, count):
|
||||||
|
data = self._bytes()
|
||||||
|
offset = addr - self.base
|
||||||
|
if offset < 0 or offset >= len(data):
|
||||||
|
raise HarborError(f"0x{addr:X} is outside the image (0x{self.base:X}..0x{self.base + len(data):X})")
|
||||||
|
return data[offset:offset + count]
|
||||||
|
|
||||||
|
def disassemble(self, target, max_bytes=None):
|
||||||
|
"""The whole function (or max_bytes from an address) with call targets, jumps into other functions,
|
||||||
|
import slots and known globals named."""
|
||||||
|
try:
|
||||||
|
import capstone
|
||||||
|
except ImportError:
|
||||||
|
raise HarborError("capstone is not installed for this Python (py -3.12 -m pip install capstone)") from None
|
||||||
|
addr = self.resolve(target)
|
||||||
|
func = self.function_at(addr)
|
||||||
|
if max_bytes:
|
||||||
|
start, size = addr, min(int(max_bytes), MAX_BYTES)
|
||||||
|
elif func:
|
||||||
|
start, size = func[0], func[1]
|
||||||
|
else:
|
||||||
|
start, size = addr, 0x100
|
||||||
|
if size > MAX_BYTES:
|
||||||
|
raise HarborError(f"the function is {size} bytes; pass max_bytes (at most {MAX_BYTES}) and walk it in parts")
|
||||||
|
code = self.read(start, size)
|
||||||
|
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_32)
|
||||||
|
lines = []
|
||||||
|
if func and not max_bytes:
|
||||||
|
lines.append(f"; {func[2] or 'unnamed'} 0x{func[0]:X}..0x{func[0] + func[1]:X} ({func[1]} bytes)")
|
||||||
|
for ins in md.disasm(code, start):
|
||||||
|
note = self._annotate(ins.op_str, ins.mnemonic, start, start + size)
|
||||||
|
lines.append(f"{ins.address:08X} {ins.mnemonic:7} {ins.op_str}{(' ; ' + note) if note else ''}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _annotate(self, op_str, mnemonic, lo, hi):
|
||||||
|
notes = []
|
||||||
|
constant = re.search(r"(dword|qword) ptr \[0x([0-9a-f]{6,8})\]", op_str) if mnemonic.startswith("f") else None
|
||||||
|
if constant: # an x87 constant: show its value as the FPU reads it
|
||||||
|
size = 4 if constant.group(1) == "dword" else 8
|
||||||
|
try:
|
||||||
|
raw = self.read(int(constant.group(2), 16), size)
|
||||||
|
value = struct.unpack("<f" if size == 4 else "<d", raw)[0]
|
||||||
|
notes.append(f"= {value!r}{'f' if size == 4 else ''}")
|
||||||
|
except HarborError:
|
||||||
|
pass
|
||||||
|
for m in re.finditer(r"0x([0-9a-f]{6,8})", op_str):
|
||||||
|
value = int(m.group(1), 16)
|
||||||
|
if lo <= value < hi and not mnemonic.startswith("call"):
|
||||||
|
continue # a jump inside this function needs no name
|
||||||
|
name = self.name_of(value)
|
||||||
|
if name:
|
||||||
|
notes.append(f"0x{value:X} {name}")
|
||||||
|
elif mnemonic.startswith("call") or mnemonic.startswith("j"):
|
||||||
|
func = self.function_at(value)
|
||||||
|
if func and func[0] != value:
|
||||||
|
notes.append(f"0x{value:X} inside {func[2] or hex(func[0])}+0x{value - func[0]:X}")
|
||||||
|
return ", ".join(notes)
|
||||||
|
|
||||||
|
def xrefs(self, target):
|
||||||
|
"""Callers and callees of a function, and the data it touches, named."""
|
||||||
|
addr = self.resolve(target)
|
||||||
|
func = self.function_at(addr)
|
||||||
|
if not func:
|
||||||
|
raise HarborError(f"0x{addr:X} is not inside a known function")
|
||||||
|
d = self._facts()[func[0]]
|
||||||
|
named = lambda a: {"address": f"0x{a:X}", "name": self.name_of(a)}
|
||||||
|
return {"function": named(func[0]) | {"size": func[1]},
|
||||||
|
"callers": [named(a) for a in sorted(set(self._callers().get(func[0], [])))],
|
||||||
|
"calls": [named(_int(a)) for a in d.get("calls", [])],
|
||||||
|
"data": [named(_int(a)) for a in d.get("data", [])],
|
||||||
|
"imports": d.get("imports", []), "strings": d.get("strings", [])}
|
||||||
|
|
||||||
|
def bytes_at(self, target, count=16, as_type="hex"):
|
||||||
|
"""Raw bytes at an address, also read as hex, int32, uint32, float, double or a C string."""
|
||||||
|
addr = self.resolve(target)
|
||||||
|
count = max(1, min(int(count), 4096))
|
||||||
|
raw = self.read(addr, count if as_type != "string" else 4096)
|
||||||
|
if as_type == "string":
|
||||||
|
text = raw.split(b"\0", 1)[0]
|
||||||
|
return {"address": f"0x{addr:X}", "string": text.decode("latin-1"), "length": len(text)}
|
||||||
|
if as_type in ("int32", "uint32", "float"):
|
||||||
|
fmt = {"int32": "i", "uint32": "I", "float": "f"}[as_type]
|
||||||
|
values = list(struct.unpack_from(f"<{len(raw) // 4}{fmt}", raw))
|
||||||
|
return {"address": f"0x{addr:X}", as_type: [repr(v) if as_type == "float" else v for v in values],
|
||||||
|
"hex": raw[:len(values) * 4].hex(" ")}
|
||||||
|
if as_type == "double":
|
||||||
|
values = list(struct.unpack_from(f"<{len(raw) // 8}d", raw))
|
||||||
|
return {"address": f"0x{addr:X}", "double": [repr(v) for v in values], "hex": raw[:len(values) * 8].hex(" ")}
|
||||||
|
return {"address": f"0x{addr:X}", "hex": raw.hex(" "), "name": self.name_of(addr)}
|
||||||
|
|
||||||
|
def lookup(self, target):
|
||||||
|
"""Everything the team has written about an address or name: names.csv, functions.csv and globals.csv rows,
|
||||||
|
the prototype, the decompiled file and its status."""
|
||||||
|
addr = self.resolve(target)
|
||||||
|
out = {"address": f"0x{addr:X}"}
|
||||||
|
func = self.function_at(addr)
|
||||||
|
if func:
|
||||||
|
out["function"] = {"start": f"0x{func[0]:X}", "size": func[1], "name": func[2]}
|
||||||
|
for table, key in (("names.csv", "names_row"), ("functions.csv", "functions_row"), ("globals.csv", "globals_row")):
|
||||||
|
row = self._csv(table).get(addr)
|
||||||
|
if row:
|
||||||
|
out[key] = ",".join(row) if len(",".join(row)) < 4000 else ",".join(row)[:4000] + "..."
|
||||||
|
name = self.name_of(addr)
|
||||||
|
if name and name in self._prototypes():
|
||||||
|
out["prototype"] = self._prototypes()[name]
|
||||||
|
if self.src and self.src.exists() and func:
|
||||||
|
prefix = f"{func[0]:06X}_"
|
||||||
|
for path in self.src.rglob(f"{prefix}*.c"):
|
||||||
|
head = path.read_text(encoding="utf-8", errors="replace").split("\n")[:6]
|
||||||
|
status = next((l.split("status:", 1)[1].strip() for l in head if "status:" in l), "?")
|
||||||
|
out["file"] = {"path": str(path.relative_to(self.src.parent)).replace(os.sep, "/"), "status": status}
|
||||||
|
break
|
||||||
|
return out
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""The shared reverse-engineering lookups, against a small synthetic image and annotation set."""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from helpers import temp_dir
|
||||||
|
from harbor.reverse import Reverse
|
||||||
|
from harbor.util import HarborError
|
||||||
|
|
||||||
|
|
||||||
|
class ReverseTools(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.d = temp_dir()
|
||||||
|
base = 0x401000
|
||||||
|
image = bytearray(0x100)
|
||||||
|
# 0x401000: mov ecx,[0x401080]; call 0x401040; fmul dword [0x401090]; ret
|
||||||
|
code = bytes.fromhex("8b0d80104000") + b"\xe8" + struct.pack("<i", 0x401040 - 0x40100B) \
|
||||||
|
+ bytes.fromhex("d80d90104000") + b"\xc3"
|
||||||
|
image[0:len(code)] = code
|
||||||
|
image[0x40] = 0xC3 # 0x401040: ret
|
||||||
|
image[0x90:0x94] = struct.pack("<f", 100.0) # 0x401090: 100.0f
|
||||||
|
image[0xA0:0xA5] = b".cue\0"
|
||||||
|
(self.d / "image.bin").write_bytes(bytes(image))
|
||||||
|
facts = [{"address": "0x401000", "size": len(code), "name": "FUN_00401000", "calls": ["0x401040"],
|
||||||
|
"data": ["0x401080"], "imports": [], "strings": []},
|
||||||
|
{"address": "0x401040", "size": 1, "name": "FUN_00401040", "calls": [], "data": [],
|
||||||
|
"imports": [], "strings": []}]
|
||||||
|
(self.d / "facts.jsonl").write_text("\n".join(json.dumps(f) for f in facts), encoding="utf-8")
|
||||||
|
ann = self.d / "annotations"
|
||||||
|
ann.mkdir()
|
||||||
|
(ann / "names.csv").write_text("address,name,category,note\n0x00401000,GetLevel,game,\"Reads it.\"\n"
|
||||||
|
"0x00401040,Helper,game,x\n", encoding="utf-8")
|
||||||
|
(ann / "globals.csv").write_text("address,name,type,note\n0x00401080,g_root,int,x\n", encoding="utf-8")
|
||||||
|
(ann / "functions.csv").write_text("address,signature,note\n", encoding="utf-8")
|
||||||
|
(ann / "prototypes.h").write_text("int GetLevel(void); /* plain ret */\n", encoding="utf-8")
|
||||||
|
self.r = Reverse({"image": str(self.d / "image.bin"), "base": "0x401000", "facts": str(self.d / "facts.jsonl"),
|
||||||
|
"annotations": str(ann)})
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.d, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_disassembly_names_calls_globals_and_constants(self):
|
||||||
|
text = self.r.disassemble("GetLevel")
|
||||||
|
self.assertIn("0x401080 g_root", text)
|
||||||
|
self.assertIn("0x401040 Helper", text)
|
||||||
|
self.assertIn("= 100.0f", text)
|
||||||
|
|
||||||
|
def test_xrefs_bytes_and_lookup(self):
|
||||||
|
x = self.r.xrefs("0x401040")
|
||||||
|
self.assertEqual([c["name"] for c in x["callers"]], ["GetLevel"])
|
||||||
|
self.assertEqual(self.r.bytes_at("0x401090", 4, "float")["float"], ["100.0"])
|
||||||
|
self.assertEqual(self.r.bytes_at("0x4010A0", as_type="string")["string"], ".cue")
|
||||||
|
info = self.r.lookup("GetLevel")
|
||||||
|
self.assertIn("plain ret", info["prototype"])
|
||||||
|
with self.assertRaises(HarborError):
|
||||||
|
self.r.bytes_at("0x500000")
|
||||||
|
with self.assertRaises(HarborError):
|
||||||
|
Reverse({})
|
||||||
Reference in new issue
Block a user