mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
292 lines
11 KiB
Python
292 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2026 Mocktail Project Authors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Function index of one libroblox image for `roblox_abi_solver.py`.
|
|
|
|
Every function listed in `.eh_frame_hdr` is decoded once and reduced to a
|
|
normalized instruction shape, its direct calls and its RIP-relative data
|
|
references. Shapes drop branch targets, RIP displacements and immediates of
|
|
0x1000 and above, so a function moved by the linker keeps its shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bisect
|
|
import collections
|
|
from dataclasses import dataclass
|
|
import gzip
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
|
|
def _load_deriver():
|
|
name = "mocktail_host_abi_deriver_for_solver"
|
|
if name in sys.modules:
|
|
return sys.modules[name]
|
|
path = Path(__file__).resolve().with_name("derive_roblox_host_abi_profile.py")
|
|
specification = importlib.util.spec_from_file_location(name, path)
|
|
if specification is None or specification.loader is None:
|
|
raise RuntimeError("cannot load the HostAbi deriver")
|
|
module = importlib.util.module_from_spec(specification)
|
|
sys.modules[name] = module
|
|
specification.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
DERIVER = _load_deriver()
|
|
AnalyzerError = DERIVER.AnalyzerError
|
|
|
|
_RIP_OPERAND = re.compile(r"\[rip ([+-]) (0x[0-9a-f]+|\d+)\]")
|
|
_IMMEDIATE = re.compile(r"0x[0-9a-f]+|\b\d+\b")
|
|
# Smaller immediates are offsets and flags that stay put between builds.
|
|
# Larger ones are addresses and sizes that move.
|
|
_STABLE_IMMEDIATE_LIMIT = 0x1000
|
|
|
|
|
|
def _stable_immediate(match: re.Match) -> str:
|
|
return "I" if int(match.group(0), 0) >= _STABLE_IMMEDIATE_LIMIT else match.group(0)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Instruction:
|
|
address: int
|
|
shape: str
|
|
rip_targets: tuple[int, ...]
|
|
branch_target: int | None
|
|
|
|
|
|
def decode_function(
|
|
decoder: Any, code: bytes, start: int
|
|
) -> list[Instruction]:
|
|
end = start + len(code)
|
|
instructions = []
|
|
for address, size, mnemonic, operands in decoder.disasm_lite(code, start):
|
|
if (mnemonic == "call" or mnemonic.startswith("j")) and operands.startswith("0x"):
|
|
target = int(operands, 16)
|
|
if mnemonic == "call":
|
|
shape = "call"
|
|
else:
|
|
shape = mnemonic + (" in" if start <= target < end else " out")
|
|
instructions.append(Instruction(address, shape, (), target))
|
|
continue
|
|
rip_targets = []
|
|
for match in _RIP_OPERAND.finditer(operands):
|
|
displacement = int(match.group(2), 0)
|
|
rip_targets.append(
|
|
address + size + (-displacement if match.group(1) == "-" else displacement)
|
|
)
|
|
operands = _IMMEDIATE.sub(_stable_immediate, _RIP_OPERAND.sub("[rip]", operands))
|
|
instructions.append(
|
|
Instruction(address, f"{mnemonic} {operands}", tuple(rip_targets), None)
|
|
)
|
|
return instructions
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FunctionInfo:
|
|
size: int
|
|
shape_hash: bytes
|
|
calls: tuple[int, ...]
|
|
refs: tuple[int, ...]
|
|
|
|
|
|
def function_table(image: Any) -> list[tuple[int, int]]:
|
|
"""Returns (start, size) for every FDE indexed by `.eh_frame_hdr`."""
|
|
header = image.sections.get(".eh_frame_hdr")
|
|
if header is None:
|
|
raise AnalyzerError("libroblox has no .eh_frame_hdr")
|
|
raw = image.section_bytes(header)
|
|
# Version 1, a 4-byte eh_frame_ptr, udata4 count, datarel sdata4 table.
|
|
if len(raw) < 12 or raw[0] != 1 or raw[2] != 0x03 or raw[3] != 0x3B:
|
|
raise AnalyzerError(".eh_frame_hdr uses an unsupported encoding")
|
|
if raw[1] == 0xFF or raw[1] & 0x0F not in (0x03, 0x0B):
|
|
raise AnalyzerError(".eh_frame_hdr eh_frame_ptr encoding is not 4 bytes")
|
|
count = struct.unpack_from("<I", raw, 8)[0]
|
|
if 12 + count * 8 > len(raw):
|
|
raise AnalyzerError(".eh_frame_hdr table exceeds its section")
|
|
table = []
|
|
for index in range(count):
|
|
location, fde = struct.unpack_from("<ii", raw, 12 + index * 8)
|
|
start = header.address + location
|
|
fde_rva = header.address + fde
|
|
offset = image.rva_to_offset(fde_rva, 16)
|
|
length, _, begin, size = struct.unpack_from("<IIiI", image.data, offset)
|
|
# An FDE starts with its length, CIE pointer, then a pcrel sdata4
|
|
# pc_begin and its udata4 range.
|
|
if length == 0xFFFFFFFF or fde_rva + 8 + begin != start:
|
|
raise AnalyzerError(".eh_frame FDE does not match its index entry")
|
|
table.append((start, size))
|
|
table.sort()
|
|
return table
|
|
|
|
|
|
_SOURCE_DIGEST = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:16]
|
|
|
|
|
|
def cache_path(cache_dir: Path, library_sha256: str) -> Path:
|
|
"""Cache file for one library; shapes depend on this code and capstone."""
|
|
version = getattr(DERIVER.capstone, "__version__", "unknown")
|
|
return cache_dir / f"{library_sha256}-{_SOURCE_DIGEST}-capstone{version}.json.gz"
|
|
|
|
|
|
def link_functions(
|
|
functions: dict[int, FunctionInfo],
|
|
) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
|
|
"""Returns each function's callers and each data address's users, once each."""
|
|
callers: dict[int, list[int]] = collections.defaultdict(list)
|
|
data_users: dict[int, list[int]] = collections.defaultdict(list)
|
|
for start, info in functions.items():
|
|
for target in dict.fromkeys(info.calls):
|
|
if target in functions:
|
|
callers[target].append(start)
|
|
for target in dict.fromkeys(info.refs):
|
|
data_users[target].append(start)
|
|
return callers, data_users
|
|
|
|
|
|
def _int_list(value: Any) -> tuple[int, ...]:
|
|
if not isinstance(value, list) or not all(type(item) is int for item in value):
|
|
raise ValueError("cached index entry is malformed")
|
|
return tuple(value)
|
|
|
|
|
|
def _read_cache(path: Path) -> dict[int, FunctionInfo] | None:
|
|
"""Returns the cached functions, or None when the file is missing or bad."""
|
|
try:
|
|
with gzip.open(path, "rt", encoding="utf-8") as stream:
|
|
entries = json.load(stream)
|
|
if not isinstance(entries, list) or not entries:
|
|
raise ValueError("cached index is empty")
|
|
functions = {}
|
|
for entry in entries:
|
|
start, size, shape_hash, calls, refs = entry
|
|
if type(start) is not int or type(size) is not int:
|
|
raise ValueError("cached index entry is malformed")
|
|
functions[start] = FunctionInfo(
|
|
size, bytes.fromhex(shape_hash), _int_list(calls), _int_list(refs)
|
|
)
|
|
return functions
|
|
except (OSError, EOFError, ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def _write_cache(path: Path, functions: dict[int, FunctionInfo]) -> None:
|
|
# A cache that cannot be written only costs the next run a rebuild.
|
|
entries = [
|
|
[start, info.size, info.shape_hash.hex(), list(info.calls), list(info.refs)]
|
|
for start, info in functions.items()
|
|
]
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
except OSError:
|
|
return
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as raw, gzip.open(raw, "wt", encoding="utf-8") as stream:
|
|
json.dump(entries, stream, separators=(",", ":"))
|
|
os.replace(temporary, path)
|
|
except OSError:
|
|
Path(temporary).unlink(missing_ok=True)
|
|
|
|
|
|
class FunctionIndex:
|
|
"""Normalized shapes, calls and data references of every function.
|
|
|
|
With `cache_dir`, the per-function data is kept in a file named after the
|
|
library's SHA-256, this module's source and the capstone version, and read
|
|
back instead of decoding the library again.
|
|
"""
|
|
|
|
def __init__(self, image: Any, cache_dir: Path | None = None):
|
|
DERIVER.require_capstone()
|
|
self.image = image
|
|
self.decoder = DERIVER.capstone.Cs(
|
|
DERIVER.capstone.CS_ARCH_X86, DERIVER.capstone.CS_MODE_64
|
|
)
|
|
self.text = image.sections[".text"]
|
|
self._decoded: dict[int, list[Instruction]] = {}
|
|
path = None if cache_dir is None else cache_path(Path(cache_dir), image.sha256)
|
|
functions = None if path is None else _read_cache(path)
|
|
if functions is None:
|
|
functions = self._decode_all()
|
|
if path is not None:
|
|
_write_cache(path, functions)
|
|
self.functions = functions
|
|
self.starts = sorted(self.functions)
|
|
self.callers, self.data_users = link_functions(self.functions)
|
|
|
|
def _decode_all(self) -> dict[int, FunctionInfo]:
|
|
text_end = self.text.address + self.text.size
|
|
functions = {}
|
|
for start, size in function_table(self.image):
|
|
if size == 0 or not self.text.address <= start < text_end:
|
|
continue
|
|
instructions = self.instructions(start, size, cache=False)
|
|
shape_hash = hashlib.blake2b(
|
|
"\n".join(item.shape for item in instructions).encode(), digest_size=8
|
|
).digest()
|
|
calls = tuple(
|
|
item.branch_target
|
|
for item in instructions
|
|
if item.branch_target is not None
|
|
and (item.shape == "call" or item.shape.endswith(" out"))
|
|
)
|
|
refs = tuple(target for item in instructions for target in item.rip_targets)
|
|
functions[start] = FunctionInfo(size, shape_hash, calls, refs)
|
|
return functions
|
|
|
|
def instructions(
|
|
self, start: int, size: int | None = None, cache: bool = True
|
|
) -> list[Instruction]:
|
|
if cache and start in self._decoded:
|
|
return self._decoded[start]
|
|
if size is None:
|
|
size = self.functions[start].size
|
|
offset = self.text.offset + start - self.text.address
|
|
decoded = decode_function(self.decoder, self.image.data[offset : offset + size], start)
|
|
if cache:
|
|
self._decoded[start] = decoded
|
|
return decoded
|
|
|
|
def shapes(self, start: int) -> list[str]:
|
|
# Scoring reads each candidate once; only aligned functions stay cached.
|
|
return [item.shape for item in self.instructions(start, cache=start in self._decoded)]
|
|
|
|
def containing(self, rva: int) -> int | None:
|
|
index = bisect.bisect_right(self.starts, rva) - 1
|
|
if index < 0:
|
|
return None
|
|
start = self.starts[index]
|
|
return start if rva < start + self.functions[start].size else None
|
|
|
|
def string_at(self, rva: int) -> bytes | None:
|
|
segment = self.image.segment_for_rva(rva)
|
|
if segment is None or not segment.contains_file_data(rva):
|
|
return None
|
|
offset = self.image.rva_to_offset(rva)
|
|
raw = self.image.data[offset : offset + 96]
|
|
end = raw.find(b"\0")
|
|
if end < 4 or not all(32 <= byte < 127 for byte in raw[:end]):
|
|
return None
|
|
return raw[:end]
|