Fetch and verify the four MAME indy_4610 PROM dumps, wrap the newest dump in an ELF32 BE MIPS image for Ghidra/REA, and record the resulting structural map: image layout, exception vector table, boot flow, function anchors and firmware feature clusters.
246 lines
7.4 KiB
Python
Executable File
246 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build a structural map of an SGI Indy IP24 PROM image.
|
|
|
|
Combines byte-level layout facts (padding, printable density, MIPS jump
|
|
vectors) with procedure and string inventories exported from a REA session.
|
|
|
|
Usage:
|
|
tools/rom-map.py roms/ip24prom.070-9101-011.bin \
|
|
--evidence evidence/rea-bundle.json \
|
|
--output docs/rom-map.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import struct
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
BASE = 0x1FC00000
|
|
PAGE = 0x1000
|
|
VECTOR_STRIDE = 0x10
|
|
# 60 entries of 16 bytes. The reset handler itself starts at 0x1FC003C0 and
|
|
# overwrites what would otherwise be the final four entries.
|
|
VECTOR_TABLE_SIZE = 0x3C0
|
|
|
|
|
|
def sha1(data: bytes) -> str:
|
|
return hashlib.sha1(data).hexdigest()
|
|
|
|
|
|
def sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def printable_ratio(chunk: bytes) -> float:
|
|
if not chunk:
|
|
return 0.0
|
|
printable = sum(
|
|
1 for b in chunk if 0x20 <= b <= 0x7E or b in (0x09, 0x0A, 0x0D)
|
|
)
|
|
return round(printable / len(chunk), 4)
|
|
|
|
|
|
def zero_ratio(chunk: bytes) -> float:
|
|
return round(chunk.count(0) / len(chunk), 4) if chunk else 0.0
|
|
|
|
|
|
def ff_ratio(chunk: bytes) -> float:
|
|
return round(chunk.count(0xFF) / len(chunk), 4) if chunk else 0.0
|
|
|
|
|
|
def j_target(word: int, pc: int) -> int | None:
|
|
"""Decode a MIPS J/JAL absolute target, or None for other opcodes."""
|
|
if (word >> 26) not in (0x02, 0x03):
|
|
return None
|
|
return ((pc + 4) & 0xF0000000) | ((word & 0x03FFFFFF) << 2)
|
|
|
|
|
|
def vector_table(rom: bytes) -> list[dict]:
|
|
rows = []
|
|
for off in range(0, min(VECTOR_TABLE_SIZE, len(rom)), VECTOR_STRIDE):
|
|
word = struct.unpack(">I", rom[off : off + 4])[0]
|
|
target = j_target(word, BASE + off)
|
|
rows.append(
|
|
{
|
|
"offset": off,
|
|
"address": f"0x{BASE + off:08x}",
|
|
"word": f"0x{word:08x}",
|
|
"target": f"0x{target:08x}" if target is not None else None,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def load_inventory(path: Path | None) -> tuple[list[dict], list[dict]]:
|
|
if path is None or not path.exists():
|
|
return [], []
|
|
bundle = json.loads(path.read_text())
|
|
procedures: list[dict] = []
|
|
strings: list[dict] = []
|
|
for record in bundle.get("records", []):
|
|
operation = record.get("operation")
|
|
if operation == "list_procedures":
|
|
procedures = record.get("normalized_result", [])
|
|
elif operation == "list_strings":
|
|
strings = record.get("normalized_result", [])
|
|
return procedures, strings
|
|
|
|
|
|
def classify(page: dict, last_non_ff: int) -> str:
|
|
if page["offset"] >= last_non_ff or page["ff_ratio"] > 0.9:
|
|
return "padding"
|
|
if page["address"] < BASE + VECTOR_TABLE_SIZE:
|
|
return "exception-vectors"
|
|
if page["procedures"]:
|
|
return "code"
|
|
if page["strings"]:
|
|
return "strings"
|
|
if page["printable_ratio"] > 0.6:
|
|
return "text-data"
|
|
if page["zero_ratio"] > 0.5:
|
|
return "zero-data"
|
|
return "binary-data"
|
|
|
|
|
|
def build(rom: bytes, procedures: list[dict], strings: list[dict]) -> dict:
|
|
proc_addrs = sorted(int(p["address"], 16) for p in procedures)
|
|
str_addrs = sorted(int(s["address"], 16) for s in strings)
|
|
|
|
last_non_ff = max((i for i, b in enumerate(rom) if b != 0xFF), default=-1)
|
|
|
|
pages = []
|
|
for off in range(0, len(rom), PAGE):
|
|
chunk = rom[off : off + PAGE]
|
|
addr = BASE + off
|
|
page = {
|
|
"offset": off,
|
|
"address": addr,
|
|
"procedures": sum(1 for a in proc_addrs if addr <= a < addr + PAGE),
|
|
"strings": sum(1 for a in str_addrs if addr <= a < addr + PAGE),
|
|
"printable_ratio": printable_ratio(chunk),
|
|
"zero_ratio": zero_ratio(chunk),
|
|
"ff_ratio": ff_ratio(chunk),
|
|
}
|
|
page["kind"] = classify(page, last_non_ff)
|
|
pages.append(page)
|
|
|
|
# Collapse equal neighbouring classifications into ranges.
|
|
regions = []
|
|
for page in pages:
|
|
if regions and regions[-1]["kind"] == page["kind"]:
|
|
regions[-1]["end"] = page["address"] + PAGE
|
|
regions[-1]["procedures"] += page["procedures"]
|
|
regions[-1]["strings"] += page["strings"]
|
|
else:
|
|
regions.append(
|
|
{
|
|
"kind": page["kind"],
|
|
"start": page["address"],
|
|
"end": page["address"] + PAGE,
|
|
"procedures": page["procedures"],
|
|
"strings": page["strings"],
|
|
}
|
|
)
|
|
|
|
version = next(
|
|
(s["value"] for s in strings if s["value"].startswith("SGI Version")), None
|
|
)
|
|
|
|
code_span = None
|
|
if proc_addrs:
|
|
code_span = {
|
|
"start": f"0x{min(proc_addrs):08x}",
|
|
"last_function": f"0x{max(proc_addrs):08x}",
|
|
"count": len(proc_addrs),
|
|
}
|
|
|
|
string_span = None
|
|
if str_addrs:
|
|
string_span = {
|
|
"start": f"0x{min(str_addrs):08x}",
|
|
"end": f"0x{max(str_addrs):08x}",
|
|
"count": len(str_addrs),
|
|
}
|
|
|
|
histogram = Counter(p["kind"] for p in pages)
|
|
|
|
return {
|
|
"source": {
|
|
"bytes": len(rom),
|
|
"sha1": sha1(rom),
|
|
"sha256": sha256(rom),
|
|
"version_string": version,
|
|
"last_non_ff_offset": last_non_ff,
|
|
"last_non_ff_address": (
|
|
f"0x{BASE + last_non_ff:08x}" if last_non_ff >= 0 else None
|
|
),
|
|
},
|
|
"image": {
|
|
"base": f"0x{BASE:08x}",
|
|
"end": f"0x{BASE + len(rom):08x}",
|
|
"page_size": PAGE,
|
|
},
|
|
"code": code_span,
|
|
"strings": string_span,
|
|
"page_kinds": dict(histogram),
|
|
"regions": [
|
|
{
|
|
**r,
|
|
"start": f"0x{r['start']:08x}",
|
|
"end": f"0x{r['end']:08x}",
|
|
}
|
|
for r in regions
|
|
],
|
|
"exception_vectors": vector_table(rom),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("rom")
|
|
parser.add_argument("--evidence", type=Path, default=None)
|
|
parser.add_argument("--output", type=Path, default=None)
|
|
parser.add_argument(
|
|
"--procedures-csv",
|
|
type=Path,
|
|
default=None,
|
|
help="write the analysed function start addresses as CSV",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
rom = Path(args.rom).read_bytes()
|
|
procedures, strings = load_inventory(args.evidence)
|
|
doc = build(rom, procedures, strings)
|
|
|
|
if args.procedures_csv and procedures:
|
|
args.procedures_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
rows = ["address,name,thunk_target"]
|
|
for proc in sorted(procedures, key=lambda p: int(p["address"], 16)):
|
|
rows.append(
|
|
"{address},{value},{target}".format(
|
|
address=proc["address"],
|
|
value=proc["value"],
|
|
target=proc.get("procedure", {}).get("thunk_target") or "",
|
|
)
|
|
)
|
|
args.procedures_csv.write_text("\n".join(rows) + "\n")
|
|
print(args.procedures_csv)
|
|
|
|
text = json.dumps(doc, indent=2)
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(text + "\n")
|
|
print(args.output)
|
|
else:
|
|
print(text)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|