mirror of
https://github.com/mirkobrombin/raven.git
synced 2026-10-26 21:17:40 +00:00
196 lines
10 KiB
Python
196 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
|
|
|
|
def compare(raven, llvm, path):
|
|
native = subprocess.run([str(raven), "--inspect-imports", str(path)],
|
|
capture_output=True, text=True, check=True, timeout=30)
|
|
reference = subprocess.run([str(llvm), "--coff-imports", "--file-headers", str(path)],
|
|
capture_output=True, text=True, check=True, timeout=30)
|
|
lines = native.stdout.splitlines()
|
|
header = re.fullmatch(r"PE(32|64) base=(0x[0-9a-f]+) entry=(0x[0-9a-f]+) size=(0x[0-9a-f]+) sections=(\d+) imports=(\d+)", lines[0])
|
|
if not header:
|
|
raise RuntimeError(f"invalid native inspection output: {path}")
|
|
fields = ["AddressSize", "ImageBase", "AddressOfEntryPoint", "SizeOfImage", "SectionCount"]
|
|
for index, field in enumerate(fields):
|
|
match = re.search(r"^\s*" + field + r": (0x[0-9A-Fa-f]+|\d+)", reference.stdout, re.M)
|
|
if not match or int(match[1], 0) != int(header[index + 1], 0):
|
|
raise RuntimeError(f"native/LLVM {field} mismatch: {path}")
|
|
symbols = []
|
|
for block in re.findall(r"^Import \{\n(.*?)^\}", reference.stdout, re.M | re.S):
|
|
module = re.search(r"^\s*Name: (.+)$", block, re.M)
|
|
if not module:
|
|
raise RuntimeError(f"missing LLVM import module: {path}")
|
|
for name, ordinal in re.findall(r"^\s*Symbol: (.*?) \((\d+)\)$", block, re.M):
|
|
symbols.append(module[1] + "!" + (name if name else "#" + ordinal))
|
|
if lines[1:] != symbols or int(header[6]) != len(symbols):
|
|
raise RuntimeError(f"native/LLVM import identity mismatch: {path}")
|
|
with path.open("rb") as source:
|
|
checksum = hashlib.file_digest(source, "sha256").hexdigest()
|
|
return {"path": str(path), "sha256": checksum, "bits": int(header[1]),
|
|
"imports": len(symbols), "symbols": symbols, "result": "PASS"}
|
|
|
|
|
|
def compare_images(raven, llvm, path):
|
|
reference = subprocess.run([str(llvm), "--file-headers", "--sections", "--coff-basereloc", str(path)],
|
|
capture_output=True, text=True, check=True, timeout=30).stdout
|
|
|
|
def field(text, name):
|
|
match = re.search(r"^\s*" + name + r": (0x[0-9A-Fa-f]+|\d+)", text, re.M)
|
|
if not match:
|
|
raise RuntimeError(f"missing LLVM {name}: {path}")
|
|
return int(match[1], 0)
|
|
|
|
preferred = field(reference, "ImageBase")
|
|
size = field(reference, "SizeOfImage")
|
|
headers = field(reference, "SizeOfHeaders")
|
|
file = path.read_bytes()
|
|
original = bytearray(size)
|
|
original[:headers] = file[:headers]
|
|
for section in re.findall(r"^ Section \{\n(.*?)^ \}", reference, re.M | re.S):
|
|
address = field(section, "VirtualAddress")
|
|
offset = field(section, "PointerToRawData")
|
|
length = field(section, "RawDataSize")
|
|
original[address:address + length] = file[offset:offset + length]
|
|
fixups = []
|
|
for entry in re.findall(r"^ Entry \{\n(.*?)^ \}", reference, re.M | re.S):
|
|
kind = re.search(r"^\s*Type: (\w+)$", entry, re.M)[1]
|
|
if kind == "ABSOLUTE":
|
|
continue
|
|
if kind not in ["HIGHLOW", "DIR64"]:
|
|
raise RuntimeError(f"reference has an untested relocation type {kind}: {path}")
|
|
fixups.append((field(entry, "Address"), 8 if kind == "DIR64" else 4))
|
|
relocatable = field(reference, "BaseRelocationTableSize") > 0 and "IMAGE_FILE_RELOCS_STRIPPED" not in reference
|
|
results = []
|
|
for base in ([preferred, preferred + 65536, preferred - 65536] if relocatable else [preferred]):
|
|
expected = original.copy()
|
|
if base != preferred:
|
|
for address, width in fixups:
|
|
value = int.from_bytes(expected[address:address + width], "little")
|
|
value = (value + base - preferred) % (1 << (width * 8))
|
|
expected[address:address + width] = value.to_bytes(width, "little")
|
|
actual = subprocess.run([str(raven), "--dump-image", str(path), hex(base)],
|
|
capture_output=True, check=True, timeout=30).stdout
|
|
if actual != expected:
|
|
raise RuntimeError(f"loaded image bytes differ from LLVM-based reference at {hex(base)}: {path}")
|
|
results.append({"base": hex(base), "size": len(actual),
|
|
"relocations": len(fixups) if base != preferred else 0,
|
|
"sha256": hashlib.sha256(actual).hexdigest(), "result": "PASS"})
|
|
if not relocatable:
|
|
rejected = subprocess.run([str(raven), "--dump-image", str(path), hex(preferred + 65536)],
|
|
capture_output=True, timeout=30)
|
|
if rejected.returncode != 1 or rejected.stdout:
|
|
raise RuntimeError(f"fixed-base image was silently rebased: {path}")
|
|
return results
|
|
|
|
|
|
def compare_exports(raven, llvm, path):
|
|
native = subprocess.run([str(raven), "--inspect-exports", str(path)],
|
|
capture_output=True, text=True, check=True, timeout=30).stdout
|
|
reference = subprocess.run([str(llvm), "--coff-exports", "--file-headers", "--sections", str(path)],
|
|
capture_output=True, text=True, check=True, timeout=30).stdout
|
|
actual = []
|
|
for line in native.splitlines()[1:]:
|
|
values = shlex.split(line)
|
|
if len(values) < 3:
|
|
raise RuntimeError(f"invalid native export output: {path}")
|
|
actual.append(dict(ordinal=int(values[0]), address=int(values[1], 0), forwarder=values[2], names=values[3:]))
|
|
|
|
def field(text, name):
|
|
match = re.search(r"^\s*" + name + r": (0x[0-9A-Fa-f]+|\d+)", text, re.M)
|
|
if not match:
|
|
raise RuntimeError(f"missing LLVM {name}: {path}")
|
|
return int(match[1], 0)
|
|
|
|
directory = field(reference, "ExportTableRVA")
|
|
size = field(reference, "ExportTableSize")
|
|
sections = [(field(section, "VirtualAddress"), field(section, "PointerToRawData"), field(section, "RawDataSize"))
|
|
for section in re.findall(r"^ Section \{\n(.*?)^ \}", reference, re.M | re.S)]
|
|
data = path.read_bytes()
|
|
|
|
def read_rva(address, length):
|
|
if address < field(reference, "SizeOfHeaders"):
|
|
return data[address:address + length]
|
|
for start, offset, size in sections:
|
|
if start <= address and address + length <= start + size:
|
|
position = offset + address - start
|
|
return data[position:position + length]
|
|
raise RuntimeError(f"reference RVA has no file data: {path}")
|
|
|
|
expected = []
|
|
for block in re.findall(r"^Export \{\n(.*?)^\}", reference, re.M | re.S):
|
|
ordinal = field(block, "Ordinal")
|
|
forwarded = re.search(r"^\s*ForwardedTo: (.+)$", block, re.M)
|
|
if forwarded:
|
|
base = int.from_bytes(read_rva(directory + 16, 4), "little")
|
|
table = int.from_bytes(read_rva(directory + 28, 4), "little")
|
|
address = int.from_bytes(read_rva(table + (ordinal - base) * 4, 4), "little")
|
|
else:
|
|
address = field(block, "RVA")
|
|
name = re.search(r"^\s*Name: ?(.*)$", block, re.M)[1]
|
|
forwarder = ""
|
|
if directory <= address < directory + size:
|
|
for start, offset, length in sections:
|
|
if start <= address < start + length:
|
|
position = offset + address - start
|
|
limit = min(offset + length, position + directory + size - address)
|
|
end = data.find(b"\0", position, limit)
|
|
if end < 0:
|
|
raise RuntimeError(f"unterminated reference forwarder: {path}")
|
|
forwarder = data[position:end].decode("ascii")
|
|
break
|
|
else:
|
|
raise RuntimeError(f"reference forwarder has no file section: {path}")
|
|
if forwarded and forwarder != forwarded[1]:
|
|
raise RuntimeError(f"reference forwarder string differs from LLVM: {path}")
|
|
expected.append(dict(ordinal=ordinal, address=address, forwarder=forwarder,
|
|
names=[name] if name else []))
|
|
if actual != expected:
|
|
raise RuntimeError(f"native/LLVM export identities or forwarders differ: {path}")
|
|
return actual
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Compare Raven PE inspection with LLVM")
|
|
parser.add_argument("--raven", type=Path, required=True)
|
|
parser.add_argument("--llvm-readobj", type=Path, required=True)
|
|
parser.add_argument("--graphics", type=Path, required=True)
|
|
parser.add_argument("--report", type=Path, required=True)
|
|
parser.add_argument("--fixture", type=Path, action="append", default=[], help="Additional user-supplied PE input; may be repeated")
|
|
parser.add_argument("--images", action="store_true", help="Compare every loaded byte at preferred and alternate bases")
|
|
parser.add_argument("--exports", action="store_true", help="Compare export ordinals, names, addresses and forwarders")
|
|
args = parser.parse_args()
|
|
repo = Path(__file__).resolve().parent.parent
|
|
manifest = json.loads((repo / "native/graphics/backends.json").read_text())
|
|
paths = [args.graphics / component / architecture / dll
|
|
for component, package in manifest.items()
|
|
for architecture in package["architectures"]
|
|
for dll in package["dlls"]]
|
|
paths += args.fixture
|
|
results = []
|
|
try:
|
|
for path in paths:
|
|
result = compare(args.raven, args.llvm_readobj, path)
|
|
if args.images:
|
|
result["loaded_images"] = compare_images(args.raven, args.llvm_readobj, path)
|
|
if args.exports:
|
|
result["exports"] = compare_exports(args.raven, args.llvm_readobj, path)
|
|
results.append(result)
|
|
print(f'{path.name}: PE{result["bits"]}, {result["imports"]} exact imports match LLVM')
|
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
args.report.write_text(json.dumps({"result": "FAIL", "error": str(error), "files": results}, indent=2) + "\n")
|
|
parser.exit(1, f"native PE regression failed: {error}\n")
|
|
args.report.write_text(json.dumps({"result": "PASS", "files": results}, indent=2) + "\n")
|
|
print(f"{len(results)} binaries matched; execution and rendering are outside this check")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|