mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-26 05:26:45 +01:00
The Android client drops HapticService:SetMotor on the Gamepad1 LeftHand and RightHand motors: its haptics backend only consumes Large and Small. VR games such as VR Hands drive those motors, so they never vibrated. - Interpose HapticService::SetMotor through its reflection descriptor, call the original, and forward Gamepad1 LeftHand/RightHand to OpenXR. The descriptor and the engine's intensity reader are found by pattern at load, for both the 2998 (tuple span) and 3222 (shared tuple) reader ABIs. The held level renews a 50 ms lease every presented frame. - Report the hand motors at x = -1/+1 in GetMotors. Positional HapticEffects aim at a hand there with a 0.5 radius (VOXEL DESTRUCT 2 does), so at +-0.2 every effect mixed to zero. - Play SetMotorState buffers sample by sample with trailing silence trimmed. Roblox sends 100 x 25 ms look-ahead buffers; taking the peak for the whole buffer turned a short bump into 2.5 s at full strength. - Stop vibrating from the DebugDeviceVR haptic slot. The engine pulls it every frame but with each mix buffer's first sample, held about a second, so it duplicated the buffers as a long buzz. - Enable SupportGenericHapticsOnVR and FixRegisterMotors with the other haptic flags.
1610 lines
68 KiB
Python
Executable File
1610 lines
68 KiB
Python
Executable File
#!/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.
|
|
"""Find native VR bridge offsets in an x86-64 Roblox ELF.
|
|
|
|
Usage:
|
|
python3 find_roblox_vr_offsets.py --elf libroblox.so --output offsets.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import bisect
|
|
from collections.abc import Iterator, Sequence
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
import mmap
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import stat
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
try:
|
|
import capstone
|
|
from capstone import x86 as capstone_x86
|
|
except ImportError:
|
|
capstone = None
|
|
capstone_x86 = None
|
|
|
|
MAX_ELF_BYTES = 512 * 1024 * 1024
|
|
MAX_RELA_DYN_BYTES = 256 * 1024 * 1024
|
|
MAX_RELOCATIONS = 4 * 1024 * 1024
|
|
MAX_FUNCTION_TABLE_ENTRIES = 2 * 1024 * 1024
|
|
MAX_FUNCTION_BYTES = 0x20000
|
|
MAX_JSON_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
SHA256_CHUNK_BYTES = 1024 * 1024
|
|
|
|
ELF_HEADER = struct.Struct("<16sHHIQQQIHHHHHH")
|
|
PROGRAM_HEADER = struct.Struct("<IIQQQQQQ")
|
|
SECTION_HEADER = struct.Struct("<IIQQQQIIQQ")
|
|
NOTE_HEADER = struct.Struct("<III")
|
|
|
|
ELF_CLASS_64 = 2
|
|
ELF_DATA_LITTLE_ENDIAN = 1
|
|
ELF_VERSION_CURRENT = 1
|
|
ELF_TYPE_SHARED_OBJECT = 3
|
|
ELF_MACHINE_X86_64 = 62
|
|
PT_LOAD = 1
|
|
PT_GNU_RELRO = 0x6474E552
|
|
PF_EXECUTE = 1
|
|
PF_WRITE = 2
|
|
PF_READ = 4
|
|
SHT_PROGBITS = 1
|
|
SHT_NOTE = 7
|
|
SHT_ANDROID_RELA = 0x60000002
|
|
SHT_NOBITS = 8
|
|
NT_GNU_BUILD_ID = 3
|
|
|
|
APS2_GROUPED_BY_INFO = 1
|
|
APS2_GROUPED_BY_OFFSET_DELTA = 2
|
|
APS2_GROUPED_BY_ADDEND = 4
|
|
APS2_GROUP_HAS_ADDEND = 8
|
|
|
|
R_X86_64_RELATIVE = 8
|
|
R_X86_64_IRELATIVE = 1027
|
|
|
|
# Guest ABI layout: src/vr/roblox_vr_device_bridge_internal.h.
|
|
RTTI_TYPE_NAME = b"N3RBX8Graphics13DebugDeviceVRE"
|
|
DEVICE_OBJECT_NAME = b"DebugDevice"
|
|
EYE_RESOURCE_LABEL = b"VR Eye"
|
|
EMULATOR_FLAG_NAME = b"DebugEnableVREmulator"
|
|
HAND_PITCH_FLAG_NAME = b"NewVRSystemHandPitch"
|
|
# Boolean fast flags Nightcap turns on in VR, keyed by contract field.
|
|
HAPTIC_FLAG_NAMES = {
|
|
"keep_haptics_flag_rva": b"KeepHapticsOnMobileWhenGamepadConnected",
|
|
"vr_haptics_flag_rva": b"VRHaptics",
|
|
"support_generic_haptics_flag_rva": b"SupportGenericHapticsOnVR",
|
|
"fix_register_motors_flag_rva": b"FixRegisterMotors",
|
|
}
|
|
|
|
OBJECT_TYPE_OFFSET = 0x10
|
|
OBJECT_TYPE_DEBUG_DEVICE = 6
|
|
STATE_OFFSET = 0x14
|
|
STATE_COPY_SIZE = 0x138
|
|
CONSTRUCTOR_MEMSET_SIZE = 0x134
|
|
READY_BYTE_OFFSETS = (0x94, 0x144, 0x147)
|
|
FRAMEBUFFER_SLOT_OFFSET = 0x150
|
|
TEXTURE_SLOT_OFFSET = 0x170
|
|
USERCFRAME_STRIDE = 0x30
|
|
MIN_CAMERA_HEAD_SCALE_OFFSET = 0x100
|
|
|
|
EXPECTED_STATE_GETTER_SLOT = 0x28
|
|
EXPECTED_EYE_GETTER_SLOT = 0x48
|
|
EXPECTED_EYE_INITIALIZER_SLOT = 0xD0
|
|
EXPECTED_HAPTIC_SLOT = 0x18
|
|
MAX_VTABLE_SCAN_BYTES = 0x200
|
|
|
|
# movsxd rax, [rsi+selector]; lea rax, [rax+rax*2]; shl rax, 4;
|
|
# movups xmm0, [rsi+rax+base]. The stride is fixed; the selector and base
|
|
# displacements are captured because they differ between builds.
|
|
POINTER_GETTER_PATTERN = re.compile(
|
|
rb"\x48\x63\x86(.{4})\x48\x8d\x04\x40\x48\xc1\xe0\x04"
|
|
rb"\x0f\x10\x84\x06(.{4})", re.DOTALL)
|
|
|
|
TEXT_REF_OPCODES = re.compile(
|
|
rb"[\x8d\x8b\x89\x8a\x88\x3b\x39\x3a\x38\x03\x33\x2b\x85\x80\x81\x83"
|
|
rb"\xc7\xc6\xff]")
|
|
TEXT_REF_MODRMS = re.compile(rb"[\x05\x0d\x15\x1d\x25\x2d\x35\x3d]")
|
|
|
|
|
|
class AnalyzerError(RuntimeError):
|
|
"""Raised when VR offset analysis rejects an input."""
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Segment:
|
|
offset: int
|
|
virtual_address: int
|
|
file_size: int
|
|
memory_size: int
|
|
flags: int
|
|
|
|
def contains_memory(self, rva: int, size: int = 1) -> bool:
|
|
return (size >= 0 and self.virtual_address <= rva and
|
|
rva + size <= self.virtual_address + self.memory_size)
|
|
|
|
def contains_file_data(self, rva: int, size: int = 1) -> bool:
|
|
return (size >= 0 and self.virtual_address <= rva and
|
|
rva + size <= self.virtual_address + self.file_size)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Section:
|
|
name: str
|
|
section_type: int
|
|
flags: int
|
|
address: int
|
|
offset: int
|
|
size: int
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Relocation:
|
|
offset: int
|
|
info: int
|
|
addend: int
|
|
|
|
|
|
def sha256_file(file_object: Any) -> str:
|
|
digest = hashlib.sha256()
|
|
while True:
|
|
chunk = file_object.read(SHA256_CHUNK_BYTES)
|
|
if not chunk:
|
|
return digest.hexdigest()
|
|
digest.update(chunk)
|
|
|
|
|
|
class Sleb128Reader:
|
|
|
|
def __init__(self, encoded: bytes):
|
|
self._encoded = encoded
|
|
self.position = 0
|
|
|
|
def pop(self) -> int:
|
|
value = 0
|
|
shift = 0
|
|
while True:
|
|
if self.position >= len(self._encoded) or shift >= 70:
|
|
raise AnalyzerError("invalid or truncated APS2 SLEB128 value")
|
|
byte = self._encoded[self.position]
|
|
self.position += 1
|
|
value |= (byte & 0x7F) << shift
|
|
shift += 7
|
|
if (byte & 0x80) == 0:
|
|
break
|
|
if shift < 64 and (byte & 0x40):
|
|
value -= 1 << shift
|
|
if not -(1 << 63) <= value < (1 << 64):
|
|
raise AnalyzerError("APS2 SLEB128 value exceeds 64-bit bounds")
|
|
return value
|
|
|
|
def at_end(self) -> bool:
|
|
return self.position == len(self._encoded)
|
|
|
|
|
|
def decode_aps2_relocations(encoded: bytes) -> Iterator[Relocation]:
|
|
if not encoded.startswith(b"APS2"):
|
|
raise AnalyzerError(".rela.dyn does not use the required APS2 encoding")
|
|
reader = Sleb128Reader(encoded[4:])
|
|
relocation_count = reader.pop()
|
|
relocation_offset = reader.pop()
|
|
if relocation_count < 0 or relocation_count > MAX_RELOCATIONS:
|
|
raise AnalyzerError("APS2 relocation count is invalid")
|
|
relocation_info = 0
|
|
relocation_addend = 0
|
|
emitted = 0
|
|
while emitted < relocation_count:
|
|
group_size = reader.pop()
|
|
group_flags = reader.pop()
|
|
if group_size <= 0 or group_size > relocation_count - emitted:
|
|
raise AnalyzerError("APS2 relocation group is invalid")
|
|
offset_delta = reader.pop(
|
|
) if group_flags & APS2_GROUPED_BY_OFFSET_DELTA else 0
|
|
if group_flags & APS2_GROUPED_BY_INFO:
|
|
relocation_info = reader.pop()
|
|
addend_mode = group_flags & (APS2_GROUP_HAS_ADDEND |
|
|
APS2_GROUPED_BY_ADDEND)
|
|
if addend_mode == (APS2_GROUP_HAS_ADDEND | APS2_GROUPED_BY_ADDEND):
|
|
relocation_addend += reader.pop()
|
|
elif addend_mode != APS2_GROUP_HAS_ADDEND:
|
|
relocation_addend = 0
|
|
for _ in range(group_size):
|
|
relocation_offset += (offset_delta if group_flags &
|
|
APS2_GROUPED_BY_OFFSET_DELTA else
|
|
reader.pop())
|
|
if not group_flags & APS2_GROUPED_BY_INFO:
|
|
relocation_info = reader.pop()
|
|
if addend_mode == APS2_GROUP_HAS_ADDEND:
|
|
relocation_addend += reader.pop()
|
|
if (relocation_offset < 0 or relocation_info < 0 or
|
|
relocation_addend < 0 or relocation_offset >= 1 << 64 or
|
|
relocation_info >= 1 << 64 or relocation_addend >= 1 << 64):
|
|
raise AnalyzerError(
|
|
"APS2 relocation exceeds unsigned 64-bit bounds")
|
|
emitted += 1
|
|
yield Relocation(relocation_offset, relocation_info,
|
|
relocation_addend)
|
|
if not reader.at_end():
|
|
raise AnalyzerError("APS2 relocation stream contains trailing bytes")
|
|
|
|
|
|
class ElfImage:
|
|
|
|
def __init__(self, path: pathlib.Path):
|
|
self.path = path
|
|
self._file = None
|
|
self.data = None
|
|
self.file_size = 0
|
|
self.sha256 = ""
|
|
self.sections: dict[str, Section] = {}
|
|
self.segments: tuple[Segment, ...] = ()
|
|
self.relro_ranges: tuple[tuple[int, int], ...] = ()
|
|
self.build_id = ""
|
|
self._relocations: dict[int, int] | None = None
|
|
|
|
def __enter__(self) -> "ElfImage":
|
|
try:
|
|
file_status = os.lstat(self.path)
|
|
except OSError as error:
|
|
raise AnalyzerError(f"cannot stat ELF file: {self.path}") from error
|
|
if stat.S_ISLNK(
|
|
file_status.st_mode) or not stat.S_ISREG(file_status.st_mode):
|
|
raise AnalyzerError(
|
|
f"ELF input must be a regular non-symlink: {self.path}")
|
|
if (file_status.st_size < ELF_HEADER.size or
|
|
file_status.st_size > MAX_ELF_BYTES):
|
|
raise AnalyzerError(f"ELF input has an invalid size: {self.path}")
|
|
self.file_size = file_status.st_size
|
|
try:
|
|
self._file = self.path.open("rb")
|
|
self.sha256 = sha256_file(self._file)
|
|
self._file.seek(0)
|
|
self.data = mmap.mmap(self._file.fileno(),
|
|
0,
|
|
access=mmap.ACCESS_READ)
|
|
self._parse()
|
|
except AnalyzerError:
|
|
self.__exit__(None, None, None)
|
|
raise
|
|
except (OSError, ValueError, struct.error) as error:
|
|
self.__exit__(None, None, None)
|
|
raise AnalyzerError(
|
|
f"cannot parse ELF file: {self.path}") from error
|
|
return self
|
|
|
|
def __exit__(self, _type, _value, _traceback) -> None:
|
|
if self.data is not None:
|
|
self.data.close()
|
|
self.data = None
|
|
if self._file is not None:
|
|
self._file.close()
|
|
self._file = None
|
|
|
|
def _checked_range(self, offset: int, size: int, description: str) -> None:
|
|
if offset < 0 or size < 0 or offset > self.file_size - size:
|
|
raise AnalyzerError(f"ELF {description} exceeds file bounds")
|
|
|
|
def bytes_at(self, offset: int, size: int, description: str) -> bytes:
|
|
self._checked_range(offset, size, description)
|
|
return self.data[offset:offset + size]
|
|
|
|
def section_bytes(self, section: Section) -> bytes:
|
|
return self.bytes_at(section.offset, section.size,
|
|
f"section {section.name}")
|
|
|
|
def _parse(self) -> None:
|
|
header = ELF_HEADER.unpack_from(self.data, 0)
|
|
identification = header[0]
|
|
if (identification[:4] != b"\x7fELF" or
|
|
identification[4] != ELF_CLASS_64 or
|
|
identification[5] != ELF_DATA_LITTLE_ENDIAN or
|
|
identification[6] != ELF_VERSION_CURRENT or
|
|
header[1] != ELF_TYPE_SHARED_OBJECT or
|
|
header[2] != ELF_MACHINE_X86_64 or
|
|
header[3] != ELF_VERSION_CURRENT):
|
|
raise AnalyzerError(
|
|
"candidate must be a little-endian x86-64 ET_DYN ELF")
|
|
|
|
program_offset = header[5]
|
|
section_offset = header[6]
|
|
program_entry_size = header[9]
|
|
program_count = header[10]
|
|
section_entry_size = header[11]
|
|
section_count = header[12]
|
|
section_names_index = header[13]
|
|
if (program_entry_size != PROGRAM_HEADER.size or
|
|
section_entry_size != SECTION_HEADER.size or
|
|
program_count == 0 or section_count == 0 or
|
|
section_names_index == 0 or
|
|
section_names_index >= section_count):
|
|
raise AnalyzerError("ELF header tables use an unsupported layout")
|
|
self._checked_range(program_offset, program_count * program_entry_size,
|
|
"program headers")
|
|
self._checked_range(section_offset, section_count * section_entry_size,
|
|
"section headers")
|
|
|
|
segments = []
|
|
relro_ranges = []
|
|
for index in range(program_count):
|
|
values = PROGRAM_HEADER.unpack_from(
|
|
self.data, program_offset + index * program_entry_size)
|
|
if values[0] == PT_GNU_RELRO:
|
|
if values[6] <= 0:
|
|
raise AnalyzerError("ELF PT_GNU_RELRO range is empty")
|
|
relro_ranges.append((values[3], values[6]))
|
|
if values[0] != PT_LOAD:
|
|
continue
|
|
segment = Segment(
|
|
offset=values[2],
|
|
virtual_address=values[3],
|
|
file_size=values[5],
|
|
memory_size=values[6],
|
|
flags=values[1],
|
|
)
|
|
self._checked_range(segment.offset, segment.file_size,
|
|
"PT_LOAD data")
|
|
if segment.file_size > segment.memory_size:
|
|
raise AnalyzerError("ELF PT_LOAD file size exceeds memory size")
|
|
segments.append(segment)
|
|
if not segments:
|
|
raise AnalyzerError("ELF has no PT_LOAD segments")
|
|
self.segments = tuple(segments)
|
|
self.relro_ranges = tuple(relro_ranges)
|
|
|
|
raw_sections = [
|
|
SECTION_HEADER.unpack_from(
|
|
self.data, section_offset + index * section_entry_size)
|
|
for index in range(section_count)
|
|
]
|
|
names_header = raw_sections[section_names_index]
|
|
self._checked_range(names_header[4], names_header[5],
|
|
"section-name table")
|
|
names = self.data[names_header[4]:names_header[4] + names_header[5]]
|
|
|
|
by_name: dict[str, Section] = {}
|
|
for values in raw_sections:
|
|
name_offset = values[0]
|
|
if name_offset >= len(names):
|
|
raise AnalyzerError("ELF section name exceeds string table")
|
|
name_end = names.find(b"\0", name_offset)
|
|
if name_end < 0:
|
|
raise AnalyzerError("ELF section name is not terminated")
|
|
try:
|
|
name = names[name_offset:name_end].decode("ascii")
|
|
except UnicodeDecodeError as error:
|
|
raise AnalyzerError("ELF section name is not ASCII") from error
|
|
section = Section(
|
|
name=name,
|
|
section_type=values[1],
|
|
flags=values[2],
|
|
address=values[3],
|
|
offset=values[4],
|
|
size=values[5],
|
|
)
|
|
if section.section_type != SHT_NOBITS:
|
|
self._checked_range(section.offset, section.size,
|
|
f"section {name}")
|
|
if name and name in by_name:
|
|
raise AnalyzerError(f"ELF contains duplicate section {name}")
|
|
if name:
|
|
by_name[name] = section
|
|
self.sections = by_name
|
|
|
|
text = self._require_section(".text", SHT_PROGBITS)
|
|
if not text.flags & 2 or not text.flags & 4 or text.flags & 1:
|
|
raise AnalyzerError(
|
|
"ELF .text section must be read+execute, non-writable")
|
|
self._require_section(".rela.dyn", SHT_ANDROID_RELA)
|
|
self._require_section(".eh_frame_hdr", SHT_PROGBITS)
|
|
self._require_section(".note.gnu.build-id", SHT_NOTE)
|
|
self.build_id = self._read_build_id()
|
|
if len(self.build_id) != 40:
|
|
raise AnalyzerError("ELF GNU build ID is not a 20-byte SHA-1")
|
|
|
|
def _require_section(self, name: str, section_type: int) -> Section:
|
|
section = self.sections.get(name)
|
|
if section is None or section.section_type != section_type:
|
|
raise AnalyzerError(f"ELF is missing required {name} section")
|
|
return section
|
|
|
|
def _read_build_id(self) -> str:
|
|
section = self.sections[".note.gnu.build-id"]
|
|
encoded = self.section_bytes(section)
|
|
offset = 0
|
|
while offset + NOTE_HEADER.size <= len(encoded):
|
|
namesz, descsz, note_type = NOTE_HEADER.unpack_from(encoded, offset)
|
|
offset += NOTE_HEADER.size
|
|
if offset + namesz > len(encoded):
|
|
raise AnalyzerError("GNU build-id note name is truncated")
|
|
note_name = encoded[offset:offset + namesz]
|
|
offset += namesz
|
|
offset += (-offset) % 4
|
|
if note_type == NT_GNU_BUILD_ID and note_name.rstrip(
|
|
b"\0") == b"GNU":
|
|
if offset + descsz > len(encoded):
|
|
raise AnalyzerError("GNU build-id note data is truncated")
|
|
return encoded[offset:offset + descsz].hex()
|
|
offset += descsz
|
|
offset += (-offset) % 4
|
|
raise AnalyzerError("ELF has no GNU build-id note")
|
|
|
|
def segment_for_rva(self, rva: int, size: int = 1) -> Segment | None:
|
|
matches = [
|
|
segment for segment in self.segments
|
|
if segment.contains_memory(rva, size)
|
|
]
|
|
return matches[0] if len(matches) == 1 else None
|
|
|
|
def rva_to_offset(self, rva: int, size: int = 1) -> int:
|
|
matches = [
|
|
segment for segment in self.segments
|
|
if segment.contains_file_data(rva, size)
|
|
]
|
|
if len(matches) != 1:
|
|
raise AnalyzerError(f"RVA 0x{rva:x} has no unique file mapping")
|
|
segment = matches[0]
|
|
offset = segment.offset + rva - segment.virtual_address
|
|
self._checked_range(offset, size, "RVA mapping")
|
|
return offset
|
|
|
|
def offset_to_rva(self, offset: int) -> int | None:
|
|
for segment in self.segments:
|
|
if segment.offset <= offset < segment.offset + segment.file_size:
|
|
return offset - segment.offset + segment.virtual_address
|
|
return None
|
|
|
|
def require_code_rva(self, rva: int, size: int = 1) -> None:
|
|
segment = self.segment_for_rva(rva, size)
|
|
if (segment is None or segment.flags &
|
|
(PF_READ | PF_EXECUTE) != (PF_READ | PF_EXECUTE) or
|
|
segment.flags & PF_WRITE or
|
|
not segment.contains_file_data(rva, size)):
|
|
raise AnalyzerError(
|
|
f"RVA 0x{rva:x} is not in a read/execute segment")
|
|
|
|
def require_writable_rva(self, rva: int, size: int = 1) -> None:
|
|
segment = self.segment_for_rva(rva, size)
|
|
if (segment is None or
|
|
segment.flags & (PF_READ | PF_WRITE) != (PF_READ | PF_WRITE) or
|
|
segment.flags & PF_EXECUTE):
|
|
raise AnalyzerError(
|
|
f"RVA 0x{rva:x} is not in a non-executable RW segment")
|
|
|
|
def require_relro_rva(self, rva: int, size: int = 1) -> None:
|
|
matches = [
|
|
(begin, length)
|
|
for begin, length in self.relro_ranges
|
|
if size >= 0 and begin <= rva and rva + size <= begin + length
|
|
]
|
|
if len(matches) != 1:
|
|
raise AnalyzerError(
|
|
f"RVA 0x{rva:x} is not in a unique PT_GNU_RELRO range")
|
|
|
|
def text_section(self) -> Section:
|
|
return self.sections[".text"]
|
|
|
|
def find_alloc_string(self, needle: bytes) -> int:
|
|
"""Return the unique read-only mapped RVA whose bytes equal needle."""
|
|
if b"\0" in needle:
|
|
raise AnalyzerError("anchor strings must not contain NUL bytes")
|
|
wanted = needle + b"\0"
|
|
hits: list[int] = []
|
|
position = 0
|
|
while True:
|
|
position = self.data.find(wanted, position)
|
|
if position < 0:
|
|
break
|
|
rva = self.offset_to_rva(position)
|
|
if rva is not None:
|
|
segment = self.segment_for_rva(rva, len(wanted))
|
|
if segment is not None and not segment.flags & PF_WRITE:
|
|
hits.append(rva)
|
|
position += 1
|
|
if not hits:
|
|
raise AnalyzerError(f"anchor string {needle.decode('ascii')!r}"
|
|
" was not found in the ELF")
|
|
if len(hits) > 1:
|
|
listed = ", ".join(f"0x{hit:x}" for hit in hits)
|
|
raise AnalyzerError(f"anchor string {needle.decode('ascii')!r}"
|
|
f" is ambiguous: {listed}")
|
|
return hits[0]
|
|
|
|
def relative_relocations(self) -> dict[int, int]:
|
|
if self._relocations is not None:
|
|
return self._relocations
|
|
section = self.sections[".rela.dyn"]
|
|
if section.size > MAX_RELA_DYN_BYTES:
|
|
raise AnalyzerError(".rela.dyn section is suspiciously large")
|
|
encoded = self.section_bytes(section)
|
|
relocations: dict[int, int] = {}
|
|
count = 0
|
|
for relocation in decode_aps2_relocations(encoded):
|
|
count += 1
|
|
if count > MAX_RELOCATIONS:
|
|
raise AnalyzerError("APS2 relocation stream is too large")
|
|
if relocation.info & 0xFFFFFFFF in (
|
|
R_X86_64_RELATIVE,
|
|
R_X86_64_IRELATIVE,
|
|
):
|
|
relocations[relocation.offset] = relocation.addend
|
|
self._relocations = relocations
|
|
return relocations
|
|
|
|
|
|
class FunctionTable:
|
|
"""Function boundaries from the .eh_frame_hdr binary-search table."""
|
|
|
|
def __init__(self, image: ElfImage):
|
|
self.image = image
|
|
section = image.sections[".eh_frame_hdr"]
|
|
encoded = image.section_bytes(section)
|
|
base = section.address
|
|
if len(encoded) < 12 or encoded[:4] != bytes((1, 27, 3, 59)):
|
|
raise AnalyzerError(".eh_frame_hdr uses an unsupported encoding")
|
|
count = struct.unpack_from("<I", encoded, 8)[0]
|
|
if count <= 0 or count > MAX_FUNCTION_TABLE_ENTRIES:
|
|
raise AnalyzerError(".eh_frame_hdr table size is invalid")
|
|
if len(encoded) < 12 + count * 8:
|
|
raise AnalyzerError(".eh_frame_hdr table is truncated")
|
|
self.starts = [
|
|
base + struct.unpack_from("<i", encoded, 12 + index * 8)[0]
|
|
for index in range(count)
|
|
]
|
|
if any(self.starts[i] >= self.starts[i + 1] for i in range(count - 1)):
|
|
raise AnalyzerError(".eh_frame_hdr table is not sorted")
|
|
text = image.text_section()
|
|
self.ends = self.starts[1:] + [text.address + text.size]
|
|
|
|
def bounds(self, rva: int) -> tuple[int, int]:
|
|
index = bisect.bisect_right(self.starts, rva) - 1
|
|
if index < 0 or self.starts[index] > rva or rva >= self.ends[index]:
|
|
raise AnalyzerError(
|
|
f"RVA 0x{rva:x} is not covered by .eh_frame_hdr")
|
|
return self.starts[index], self.ends[index]
|
|
|
|
|
|
class Disassembler:
|
|
|
|
def __init__(self, image: ElfImage):
|
|
require_capstone()
|
|
self.image = image
|
|
self.decoder = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
|
|
self.decoder.detail = True
|
|
self._cache: dict[int, tuple[Any, ...]] = {}
|
|
|
|
def function(self, start: int, end: int) -> tuple[Any, ...]:
|
|
cached = self._cache.get(start)
|
|
if cached is not None:
|
|
return cached
|
|
size = end - start
|
|
if size <= 0 or size > MAX_FUNCTION_BYTES:
|
|
raise AnalyzerError(f"function at 0x{start:x} has an invalid size")
|
|
offset = self.image.rva_to_offset(start, size)
|
|
code = self.image.bytes_at(offset, size, "function body")
|
|
instructions = tuple(self.decoder.disasm(code, start))
|
|
if not instructions or instructions[0].address != start:
|
|
raise AnalyzerError(f"cannot decode function at 0x{start:x}")
|
|
self._cache[start] = instructions
|
|
return instructions
|
|
|
|
|
|
def require_capstone() -> None:
|
|
if capstone is None or capstone_x86 is None:
|
|
raise AnalyzerError(
|
|
"Python capstone 5 is required for VR offset analysis")
|
|
try:
|
|
version = capstone.cs_version()
|
|
except (AttributeError, TypeError, ValueError) as error:
|
|
raise AnalyzerError(
|
|
"Python capstone version cannot be verified") from error
|
|
if (not isinstance(version, tuple) or len(version) < 2 or
|
|
not all(isinstance(component, int) for component in version[:2]) or
|
|
version[0] != 5):
|
|
raise AnalyzerError("Python capstone major version 5 is required")
|
|
|
|
|
|
def rip_target(instruction: Any) -> Iterator[int]:
|
|
for operand in instruction.operands:
|
|
if (operand.type == capstone_x86.X86_OP_MEM and
|
|
operand.mem.base == capstone_x86.X86_REG_RIP):
|
|
yield instruction.address + instruction.size + operand.mem.disp
|
|
|
|
|
|
def find_text_refs(
|
|
image: ElfImage,
|
|
table: FunctionTable,
|
|
disasm: Disassembler,
|
|
targets: set[int],
|
|
) -> dict[int, list[tuple[int, int, int]]]:
|
|
"""Find RIP-relative references at decoded instruction boundaries.
|
|
|
|
Returns:
|
|
Target RVAs mapped to (instruction RVA, function start, function end).
|
|
"""
|
|
text = image.text_section()
|
|
data = image.section_bytes(text)
|
|
base = text.address
|
|
combined = re.compile(
|
|
b"(?=" + TEXT_REF_OPCODES.pattern + TEXT_REF_MODRMS.pattern + b".{4})",
|
|
re.DOTALL,
|
|
)
|
|
candidates: dict[int, set[int]] = {}
|
|
for match in combined.finditer(data):
|
|
position = match.start()
|
|
opcode = data[position]
|
|
extra = 1 if opcode in (0x80, 0x83,
|
|
0xC6) else 4 if opcode in (0x81, 0xC7) else 0
|
|
target = (base + position + 6 + extra +
|
|
struct.unpack_from("<i", data, position + 2)[0])
|
|
if target in targets:
|
|
candidates.setdefault(target, set()).add(base + position)
|
|
results: dict[int, list[tuple[int, int, int]]] = {}
|
|
for target in sorted(candidates):
|
|
for position in sorted(candidates[target]):
|
|
start, end = table.bounds(position)
|
|
instructions = disasm.function(start, end)
|
|
for instruction in instructions:
|
|
if (instruction.address <= position <
|
|
instruction.address + instruction.size):
|
|
if target in rip_target(instruction):
|
|
results.setdefault(target, []).append(
|
|
(instruction.address, start, end))
|
|
return results
|
|
|
|
|
|
def scan_pointer_getter_sites(image: ElfImage) -> list[tuple[int, int, int]]:
|
|
"""Return (address, selector offset, UserCFrame base offset) per site."""
|
|
text = image.text_section()
|
|
data = image.section_bytes(text)
|
|
sites: list[tuple[int, int, int]] = []
|
|
for match in POINTER_GETTER_PATTERN.finditer(data):
|
|
selector = struct.unpack("<i", match.group(1))[0]
|
|
base = struct.unpack("<i", match.group(2))[0]
|
|
if selector > 0 and base > 0:
|
|
sites.append((text.address + match.start(), selector, base))
|
|
return sites
|
|
|
|
|
|
def ends_with_ret(instructions: Sequence[Any]) -> bool:
|
|
return any(
|
|
instruction.mnemonic == "ret" for instruction in instructions[-3:])
|
|
|
|
|
|
def is_state_getter(instructions: Sequence[Any]) -> bool:
|
|
"""mov reg, rdi; add rsi, 0x14; mov edx, 0x138; call memcpy; ret."""
|
|
if not 6 <= len(instructions) <= 24:
|
|
return False
|
|
calls = [
|
|
instruction for instruction in instructions
|
|
if instruction.mnemonic == "call" and instruction.operands and
|
|
instruction.operands[0].type == capstone_x86.X86_OP_IMM
|
|
]
|
|
if len(calls) != 1:
|
|
return False
|
|
if not ends_with_ret(instructions):
|
|
return False
|
|
has_saved_self = False
|
|
has_state_offset = False
|
|
has_state_size = False
|
|
for instruction in instructions:
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[1].type == capstone_x86.X86_OP_REG and
|
|
operands[1].reg == capstone_x86.X86_REG_RDI):
|
|
has_saved_self = True
|
|
if (instruction.mnemonic == "add" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RSI and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == STATE_OFFSET):
|
|
has_state_offset = True
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == STATE_COPY_SIZE):
|
|
has_state_size = True
|
|
return has_saved_self and has_state_offset and has_state_size
|
|
|
|
|
|
def is_eye_getter(instructions: Sequence[Any]) -> bool:
|
|
"""movsxd rax, esi; shl rax, 4; mov rax, [rdi+rax+0x150]; ret."""
|
|
if len(instructions) != 4:
|
|
return False
|
|
first, second, third, last = instructions
|
|
if first.mnemonic != "movsxd" or len(first.operands) != 2:
|
|
return False
|
|
if (first.operands[0].type != capstone_x86.X86_OP_REG or
|
|
first.operands[0].reg != capstone_x86.X86_REG_RAX or
|
|
first.operands[1].type != capstone_x86.X86_OP_REG or
|
|
first.operands[1].reg != capstone_x86.X86_REG_ESI):
|
|
return False
|
|
if (second.mnemonic != "shl" or
|
|
second.operands[0].type != capstone_x86.X86_OP_REG or
|
|
second.operands[0].reg != capstone_x86.X86_REG_RAX or
|
|
second.operands[1].type != capstone_x86.X86_OP_IMM or
|
|
second.operands[1].imm != 4):
|
|
return False
|
|
if third.mnemonic != "mov" or len(third.operands) != 2:
|
|
return False
|
|
memory = third.operands[1]
|
|
if memory.type != capstone_x86.X86_OP_MEM:
|
|
return False
|
|
if (memory.mem.base != capstone_x86.X86_REG_RDI or
|
|
memory.mem.index != capstone_x86.X86_REG_RAX or
|
|
memory.mem.disp != FRAMEBUFFER_SLOT_OFFSET):
|
|
return False
|
|
return last.mnemonic == "ret"
|
|
|
|
|
|
def is_eye_initializer(instructions: Sequence[Any], eye_label_rva: int) -> bool:
|
|
"""The eye-resource initializer references 'VR Eye' and the slot arrays."""
|
|
has_label = any(eye_label_rva in rip_target(instruction)
|
|
for instruction in instructions)
|
|
if not has_label:
|
|
return False
|
|
ready_hits = set()
|
|
has_framebuffer_lea = False
|
|
has_texture_lea = False
|
|
has_register_call = False
|
|
for instruction in instructions:
|
|
operands = instruction.operands
|
|
if instruction.mnemonic == "mov" and operands and operands[
|
|
0].type == capstone_x86.X86_OP_MEM:
|
|
if operands[0].size == 1 and operands[
|
|
0].mem.disp in READY_BYTE_OFFSETS:
|
|
ready_hits.add(operands[0].mem.disp)
|
|
if instruction.mnemonic == "lea" and len(operands) == 2:
|
|
memory = operands[1]
|
|
if (memory.type == capstone_x86.X86_OP_MEM and
|
|
memory.mem.index == capstone_x86.X86_REG_INVALID):
|
|
if memory.mem.disp == FRAMEBUFFER_SLOT_OFFSET:
|
|
has_framebuffer_lea = True
|
|
if memory.mem.disp == TEXTURE_SLOT_OFFSET:
|
|
has_texture_lea = True
|
|
if (instruction.mnemonic == "call" and operands and
|
|
operands[0].type == capstone_x86.X86_OP_REG):
|
|
has_register_call = True
|
|
return (len(ready_hits) >= 2 and has_framebuffer_lea and has_texture_lea and
|
|
has_register_call)
|
|
|
|
|
|
def is_constructor(instructions: Sequence[Any], vtable_rva: int,
|
|
device_name_rva: int) -> bool:
|
|
"""The constructor stores the vtable, the 'DebugDevice' name and type 6."""
|
|
has_vtable = any(
|
|
instruction.mnemonic == "lea" and vtable_rva in rip_target(instruction)
|
|
for instruction in instructions)
|
|
has_name = any(instruction.mnemonic == "lea" and
|
|
device_name_rva in rip_target(instruction)
|
|
for instruction in instructions)
|
|
has_type = False
|
|
has_state_size = False
|
|
has_direct_call = False
|
|
for instruction in instructions:
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_MEM and
|
|
operands[0].size == 4 and
|
|
operands[0].mem.disp == OBJECT_TYPE_OFFSET and
|
|
operands[0].mem.index == capstone_x86.X86_REG_INVALID and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == OBJECT_TYPE_DEBUG_DEVICE):
|
|
has_type = True
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == CONSTRUCTOR_MEMSET_SIZE):
|
|
has_state_size = True
|
|
if (instruction.mnemonic == "call" and operands and
|
|
operands[0].type == capstone_x86.X86_OP_IMM):
|
|
has_direct_call = True
|
|
return (has_vtable and has_name and has_type and has_state_size and
|
|
has_direct_call)
|
|
|
|
|
|
def extract_framebuffer_slot(instructions: Sequence[Any]) -> int | None:
|
|
"""Find ``mov rax, [rax+disp]`` ... ``call rax`` on a device vtable."""
|
|
hits: set[int] = set()
|
|
for index, instruction in enumerate(instructions):
|
|
operands = instruction.operands
|
|
if instruction.mnemonic != "mov" or len(operands) != 2:
|
|
continue
|
|
if operands[0].type != capstone_x86.X86_OP_REG or operands[
|
|
1].type != capstone_x86.X86_OP_MEM:
|
|
continue
|
|
memory = operands[1].mem
|
|
register = operands[0].reg
|
|
if (memory.base != register or
|
|
memory.index != capstone_x86.X86_REG_INVALID or
|
|
memory.disp < 0x40):
|
|
continue
|
|
has_vtable_load = any(
|
|
previous.mnemonic == "mov" and len(previous.operands) == 2 and
|
|
previous.operands[0].type == capstone_x86.X86_OP_REG and
|
|
previous.operands[1].type == capstone_x86.X86_OP_MEM and
|
|
previous.operands[1].mem.index == capstone_x86.X86_REG_INVALID and
|
|
previous.operands[1].mem.disp == 0 and
|
|
previous.operands[1].mem.base != capstone_x86.X86_REG_INVALID and
|
|
previous.operands[1].mem.base != capstone_x86.X86_REG_RIP
|
|
for previous in instructions[max(0, index - 6):index])
|
|
has_register_call = any(
|
|
later.mnemonic == "call" and later.operands and
|
|
later.operands[0].type == capstone_x86.X86_OP_REG and
|
|
later.operands[0].reg == register
|
|
for later in instructions[index + 1:index + 16])
|
|
if has_vtable_load and has_register_call:
|
|
hits.add(memory.disp)
|
|
if len(hits) != 1:
|
|
return None
|
|
return next(iter(hits))
|
|
|
|
|
|
def extract_registration_storage(instructions: Sequence[Any],
|
|
flag_name_rva: int) -> int | None:
|
|
"""lea rsi, [flag]; lea rdx, [storage]; mov ecx, 1; mov r8d, 4; jmp."""
|
|
storage: int | None = None
|
|
for index, instruction in enumerate(instructions):
|
|
operands = instruction.operands
|
|
if instruction.mnemonic != "lea" or len(operands) != 2:
|
|
continue
|
|
if operands[0].type != capstone_x86.X86_OP_REG or operands[
|
|
0].reg != capstone_x86.X86_REG_RSI:
|
|
continue
|
|
if flag_name_rva not in rip_target(instruction):
|
|
continue
|
|
for follower in instructions[index + 1:index + 4]:
|
|
follower_operands = follower.operands
|
|
if (follower.mnemonic == "lea" and len(follower_operands) == 2 and
|
|
follower_operands[0].type == capstone_x86.X86_OP_REG and
|
|
follower_operands[0].reg == capstone_x86.X86_REG_RDX):
|
|
for target in rip_target(follower):
|
|
storage = target
|
|
if storage is None:
|
|
return None
|
|
has_flag_scope = False
|
|
has_category = False
|
|
for instruction in instructions:
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_ECX and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == 1):
|
|
has_flag_scope = True
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_R8D and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == 4):
|
|
has_category = True
|
|
if not (has_flag_scope and has_category):
|
|
return None
|
|
return storage
|
|
|
|
|
|
def find_flag_zeroing(instructions: Sequence[Any], storage_rva: int) -> bool:
|
|
"""mov byte ptr [rip+storage], 0 in the flag descriptor initializer."""
|
|
for instruction in instructions:
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_MEM and
|
|
operands[0].size == 1 and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == 0):
|
|
if storage_rva in rip_target(instruction):
|
|
return True
|
|
return False
|
|
|
|
|
|
def extract_hand_pitch(instructions: Sequence[Any],
|
|
flag_name_rva: int) -> tuple[int, int] | None:
|
|
"""Find hand-pitch storage and its default value in the flag initializer."""
|
|
for index, instruction in enumerate(instructions):
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic != "mov" or len(operands) != 2 or
|
|
operands[0].type != capstone_x86.X86_OP_MEM or
|
|
operands[1].type != capstone_x86.X86_OP_IMM):
|
|
continue
|
|
if operands[0].size != 4:
|
|
continue
|
|
default = operands[1].imm
|
|
if not 0 <= default < 360:
|
|
continue
|
|
storage = next(iter(rip_target(instruction)), None)
|
|
if storage is None:
|
|
continue
|
|
for follower_index in range(index + 1, min(index + 5,
|
|
len(instructions))):
|
|
follower = instructions[follower_index]
|
|
follower_operands = follower.operands
|
|
if (follower.mnemonic != "lea" or len(follower_operands) != 2 or
|
|
follower_operands[0].type != capstone_x86.X86_OP_REG):
|
|
continue
|
|
if flag_name_rva not in rip_target(follower):
|
|
continue
|
|
register = follower_operands[0].reg
|
|
if follower_index + 1 >= len(instructions):
|
|
continue
|
|
store = instructions[follower_index + 1]
|
|
store_operands = store.operands
|
|
if (store.mnemonic == "mov" and len(store_operands) == 2 and
|
|
store_operands[0].type == capstone_x86.X86_OP_MEM and
|
|
store_operands[1].type == capstone_x86.X86_OP_REG and
|
|
store_operands[1].reg == register):
|
|
name_slot = next(iter(rip_target(store)), None)
|
|
if name_slot == storage + 8:
|
|
return storage, default
|
|
return None
|
|
|
|
|
|
def extract_bool_flag_storage(instructions: Sequence[Any],
|
|
flag_name_rva: int) -> int | None:
|
|
"""Value byte of a boolean flag: a byte or word store to it, then
|
|
lea reg, [name]; mov [storage+8], reg."""
|
|
for index, instruction in enumerate(instructions):
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic != "lea" or len(operands) != 2 or
|
|
operands[0].type != capstone_x86.X86_OP_REG or
|
|
flag_name_rva not in rip_target(instruction) or
|
|
index + 1 >= len(instructions)):
|
|
continue
|
|
store = instructions[index + 1]
|
|
store_operands = store.operands
|
|
if (store.mnemonic != "mov" or len(store_operands) != 2 or
|
|
store_operands[0].type != capstone_x86.X86_OP_MEM or
|
|
store_operands[1].type != capstone_x86.X86_OP_REG or
|
|
store_operands[1].reg != operands[0].reg):
|
|
continue
|
|
name_slot = next(iter(rip_target(store)), None)
|
|
if name_slot is None:
|
|
continue
|
|
storage = name_slot - 8
|
|
for writer in instructions[max(0, index - 3):index]:
|
|
writer_operands = writer.operands
|
|
if (writer.mnemonic == "mov" and len(writer_operands) == 2 and
|
|
writer_operands[0].type == capstone_x86.X86_OP_MEM and
|
|
writer_operands[0].size in (1, 2) and
|
|
storage in rip_target(writer)):
|
|
return storage
|
|
return None
|
|
|
|
|
|
def find_bool_flags(image: ElfImage, table: FunctionTable,
|
|
disasm: Disassembler, names: dict[str, bytes],
|
|
evidence: list[str], warnings: list[str]) -> dict[str, int]:
|
|
"""Value-byte RVA per field; 0 when the build lacks the flag."""
|
|
found: dict[str, int] = {}
|
|
for field, name in names.items():
|
|
label = name.decode("ascii")
|
|
try:
|
|
name_rva = image.find_alloc_string(name)
|
|
except AnalyzerError as error:
|
|
warnings.append(f"{label}: {error}; {field} left at 0")
|
|
found[field] = 0
|
|
continue
|
|
matches: set[int] = set()
|
|
for refs in find_text_refs(image, table, disasm, {name_rva}).values():
|
|
for _address, start, end in refs:
|
|
storage = extract_bool_flag_storage(disasm.function(start, end),
|
|
name_rva)
|
|
if storage is not None:
|
|
matches.add(storage)
|
|
if len(matches) != 1:
|
|
listed = ", ".join(f"0x{rva:x}" for rva in sorted(matches))
|
|
raise AnalyzerError(f"{label} flag storage is not unique: [{listed}]")
|
|
storage = matches.pop()
|
|
image.require_writable_rva(storage, 16)
|
|
evidence.append(f"{label}: value byte with name pointer at storage+8"
|
|
f" -> 0x{storage:x}")
|
|
found[field] = storage
|
|
return found
|
|
|
|
|
|
def match_pointer_getter(instructions: Sequence[Any], selector_offset: int,
|
|
base_offset: int) -> int | None:
|
|
"""World-space pointer getter: UserCFrame copy + workspace/camera path.
|
|
|
|
Returns the camera HeadScale offset, or None when the contract fails.
|
|
"""
|
|
has_selector = False
|
|
has_scale = False
|
|
has_stride = False
|
|
usercframe_loads = 0
|
|
has_workspace_handoff = False
|
|
has_camera_slot_call = False
|
|
head_scale_offsets: set[int] = set()
|
|
frame_registers = (capstone_x86.X86_REG_RBP, capstone_x86.X86_REG_RSP,
|
|
capstone_x86.X86_REG_RIP)
|
|
for instruction in instructions:
|
|
operands = instruction.operands
|
|
if instruction.mnemonic == "movsxd" and len(operands) == 2:
|
|
if (operands[1].type == capstone_x86.X86_OP_MEM and
|
|
operands[1].mem.base == capstone_x86.X86_REG_RSI and
|
|
operands[1].mem.index == capstone_x86.X86_REG_INVALID and
|
|
operands[1].mem.disp == selector_offset):
|
|
has_selector = True
|
|
if instruction.mnemonic == "lea" and len(operands) == 2:
|
|
memory = operands[1]
|
|
if (memory.type == capstone_x86.X86_OP_MEM and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RAX and
|
|
memory.mem.base == capstone_x86.X86_REG_RAX and
|
|
memory.mem.index == capstone_x86.X86_REG_RAX and
|
|
memory.mem.scale == 2):
|
|
has_scale = True
|
|
if instruction.mnemonic == "shl" and len(operands) == 2:
|
|
if (operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RAX and
|
|
operands[1].type == capstone_x86.X86_OP_IMM and
|
|
operands[1].imm == 4):
|
|
has_stride = True
|
|
if instruction.mnemonic in ("movups", "movupd", "movaps",
|
|
"movapd") and len(operands) == 2:
|
|
memory = operands[1]
|
|
if memory.type == capstone_x86.X86_OP_MEM and (
|
|
memory.mem.base == capstone_x86.X86_REG_RSI and
|
|
memory.mem.index == capstone_x86.X86_REG_RAX and
|
|
memory.mem.disp in (
|
|
base_offset,
|
|
base_offset + 0x10,
|
|
base_offset + 0x20,
|
|
)):
|
|
usercframe_loads += 1
|
|
if instruction.mnemonic == "movss" and len(operands) == 2:
|
|
memory = operands[1]
|
|
# The only float the getter loads from an object is HeadScale.
|
|
if (memory.type == capstone_x86.X86_OP_MEM and
|
|
memory.mem.index == capstone_x86.X86_REG_INVALID and
|
|
memory.mem.base not in frame_registers and
|
|
memory.mem.disp >= MIN_CAMERA_HEAD_SCALE_OFFSET):
|
|
head_scale_offsets.add(memory.mem.disp)
|
|
for index, instruction in enumerate(instructions):
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RDI and
|
|
operands[1].type == capstone_x86.X86_OP_REG and
|
|
operands[1].reg == capstone_x86.X86_REG_RSI):
|
|
for follower in instructions[index + 1:index + 3]:
|
|
if (follower.mnemonic == "call" and follower.operands and
|
|
follower.operands[0].type == capstone_x86.X86_OP_IMM):
|
|
has_workspace_handoff = True
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RCX and
|
|
operands[1].type == capstone_x86.X86_OP_MEM and
|
|
operands[1].mem.base == capstone_x86.X86_REG_RAX and
|
|
operands[1].mem.disp == 0):
|
|
for follower_index in range(index + 1,
|
|
min(index + 4, len(instructions))):
|
|
follower = instructions[follower_index]
|
|
if (follower.mnemonic == "mov" and
|
|
len(follower.operands) == 2 and
|
|
follower.operands[0].type == capstone_x86.X86_OP_REG and
|
|
follower.operands[0].reg == capstone_x86.X86_REG_RDI and
|
|
follower.operands[1].type == capstone_x86.X86_OP_REG and
|
|
follower.operands[1].reg == capstone_x86.X86_REG_RAX):
|
|
for caller in instructions[follower_index +
|
|
1:follower_index + 4]:
|
|
if (caller.mnemonic == "call" and caller.operands and
|
|
caller.operands[0].type
|
|
== capstone_x86.X86_OP_MEM and
|
|
caller.operands[0].mem.base
|
|
== capstone_x86.X86_REG_RCX and
|
|
caller.operands[0].mem.disp > 0x100):
|
|
has_camera_slot_call = True
|
|
if not (has_selector and has_scale and has_stride and
|
|
usercframe_loads >= 3 and has_workspace_handoff and
|
|
has_camera_slot_call and len(head_scale_offsets) == 1):
|
|
return None
|
|
return next(iter(head_scale_offsets))
|
|
|
|
|
|
def extract_pointer_getter_anchors(instructions: Sequence[Any],
|
|
image: ElfImage) -> tuple[int, int]:
|
|
"""Return (workspace getter RVA, camera vtable slot offset)."""
|
|
workspace_getter = None
|
|
camera_slot = None
|
|
for index, instruction in enumerate(instructions):
|
|
operands = instruction.operands
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RDI and
|
|
operands[1].type == capstone_x86.X86_OP_REG and
|
|
operands[1].reg == capstone_x86.X86_REG_RSI):
|
|
for follower in instructions[index + 1:index + 3]:
|
|
if (follower.mnemonic == "call" and follower.operands and
|
|
follower.operands[0].type == capstone_x86.X86_OP_IMM):
|
|
target = follower.operands[0].imm
|
|
try:
|
|
image.require_code_rva(target)
|
|
except AnalyzerError:
|
|
continue
|
|
workspace_getter = target
|
|
if (instruction.mnemonic == "mov" and len(operands) == 2 and
|
|
operands[0].type == capstone_x86.X86_OP_REG and
|
|
operands[0].reg == capstone_x86.X86_REG_RCX and
|
|
operands[1].type == capstone_x86.X86_OP_MEM and
|
|
operands[1].mem.base == capstone_x86.X86_REG_RAX and
|
|
operands[1].mem.disp == 0):
|
|
for follower_index in range(index + 1,
|
|
min(index + 4, len(instructions))):
|
|
follower = instructions[follower_index]
|
|
if (follower.mnemonic == "mov" and
|
|
len(follower.operands) == 2 and
|
|
follower.operands[0].type == capstone_x86.X86_OP_REG and
|
|
follower.operands[0].reg == capstone_x86.X86_REG_RDI and
|
|
follower.operands[1].type == capstone_x86.X86_OP_REG and
|
|
follower.operands[1].reg == capstone_x86.X86_REG_RAX):
|
|
for caller in instructions[follower_index +
|
|
1:follower_index + 4]:
|
|
if (caller.mnemonic == "call" and caller.operands and
|
|
caller.operands[0].type
|
|
== capstone_x86.X86_OP_MEM and
|
|
caller.operands[0].mem.base
|
|
== capstone_x86.X86_REG_RCX and
|
|
caller.operands[0].mem.disp > 0x100):
|
|
camera_slot = caller.operands[0].mem.disp
|
|
if workspace_getter is None or camera_slot is None:
|
|
raise AnalyzerError("world-space pointer getter contract matched"
|
|
" but its workspace/camera"
|
|
" anchors could not be extracted")
|
|
return workspace_getter, camera_slot
|
|
|
|
|
|
def vtable_slots(
|
|
image: ElfImage,
|
|
relocations: dict[int, int],
|
|
vtable_rva: int,
|
|
table: "FunctionTable",
|
|
) -> dict[int, int]:
|
|
"""Collect vtable slots whose targets are covered by the function table.
|
|
|
|
Segment flags alone cannot distinguish .text from .rodata in these builds.
|
|
"""
|
|
slots: dict[int, int] = {}
|
|
misses = 0
|
|
for slot in range(0, MAX_VTABLE_SCAN_BYTES, 8):
|
|
target = relocations.get(vtable_rva + slot)
|
|
valid = False
|
|
if target is not None:
|
|
try:
|
|
image.require_code_rva(target)
|
|
table.bounds(target)
|
|
valid = True
|
|
except AnalyzerError:
|
|
valid = False
|
|
if valid:
|
|
slots[slot] = target
|
|
misses = 0
|
|
else:
|
|
misses += 1
|
|
if misses >= 8:
|
|
break
|
|
if len(slots) < 16:
|
|
raise AnalyzerError(
|
|
f"vtable candidate at 0x{vtable_rva:x} does not look like a vtable")
|
|
return slots
|
|
|
|
|
|
def find_vr_offsets(elf_path: pathlib.Path) -> dict[str, Any]:
|
|
"""Analyze VR entry points without executing the ELF.
|
|
|
|
Args:
|
|
elf_path: Path to an x86-64 libroblox.so.
|
|
|
|
Returns:
|
|
ELF identity, bridge offsets, evidence, and analysis warnings.
|
|
|
|
Raises:
|
|
AnalyzerError: The ELF or a required machine-code contract is invalid.
|
|
"""
|
|
evidence: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
with ElfImage(elf_path) as image:
|
|
relocations = image.relative_relocations()
|
|
table = FunctionTable(image)
|
|
disasm = Disassembler(image)
|
|
|
|
# Itanium RTTI: mangled name -> typeinfo -> primary vtable.
|
|
rtti_name_rva = image.find_alloc_string(RTTI_TYPE_NAME)
|
|
name_slots = sorted(offset for offset, addend in relocations.items()
|
|
if addend == rtti_name_rva and offset % 8 == 0)
|
|
if not name_slots:
|
|
raise AnalyzerError("no APS2 relative relocation references"
|
|
" the DebugDeviceVR RTTI name")
|
|
vtable_result: tuple[int, dict[int, int]] | None = None
|
|
typeinfo_result = 0
|
|
for name_slot in name_slots:
|
|
typeinfo = name_slot - 8
|
|
for reference in sorted(
|
|
offset for offset, addend in relocations.items()
|
|
if addend == typeinfo and offset % 8 == 0):
|
|
vtable = reference + 8
|
|
try:
|
|
image.require_relro_rva(vtable, 0xE0)
|
|
slots = vtable_slots(image, relocations, vtable, table)
|
|
except AnalyzerError:
|
|
continue
|
|
vtable_result = (vtable, slots)
|
|
typeinfo_result = typeinfo
|
|
break
|
|
if vtable_result is not None:
|
|
break
|
|
if vtable_result is None:
|
|
raise AnalyzerError(
|
|
"DebugDeviceVR vtable could not be resolved from its typeinfo")
|
|
vtable, slots = vtable_result
|
|
offset_to_top = image.bytes_at(image.rva_to_offset(vtable - 16, 8), 8,
|
|
"vtable header")
|
|
if offset_to_top != b"\0" * 8:
|
|
raise AnalyzerError(
|
|
f"vtable at 0x{vtable:x} is not a primary vtable (offset-to-top"
|
|
" is nonzero)")
|
|
evidence.append(
|
|
f"RTTI: mangled name {RTTI_TYPE_NAME.decode('ascii')!r} at"
|
|
f" 0x{rtti_name_rva:x} -> typeinfo 0x{typeinfo_result:x} -> vtable"
|
|
f" 0x{vtable:x} through APS2 relative relocations")
|
|
|
|
state_matches = [
|
|
(slot, target)
|
|
for slot, target in slots.items()
|
|
if is_state_getter(disasm.function(*table.bounds(target)))
|
|
]
|
|
eye_matches = [(slot, target)
|
|
for slot, target in slots.items()
|
|
if is_eye_getter(disasm.function(*table.bounds(target)))]
|
|
eye_label_rva = image.find_alloc_string(EYE_RESOURCE_LABEL)
|
|
initializer_matches = [
|
|
(slot, target)
|
|
for slot, target in slots.items()
|
|
if is_eye_initializer(disasm.function(
|
|
*table.bounds(target)), eye_label_rva)
|
|
]
|
|
for label, matches in (
|
|
("state getter", state_matches),
|
|
("eye getter", eye_matches),
|
|
("eye initializer", initializer_matches),
|
|
):
|
|
if not matches:
|
|
raise AnalyzerError(
|
|
f"no vtable slot satisfies the {label} contract")
|
|
if len(matches) > 1:
|
|
listed = ", ".join(f"vtable+0x{slot:x} -> 0x{target:x}"
|
|
for slot, target in matches)
|
|
raise AnalyzerError(
|
|
f"ambiguous {label} contract matches: {listed}")
|
|
state_slot, state_getter = state_matches[0]
|
|
# is_state_getter accepts exactly one direct call: the memcpy.
|
|
state_memcpy = next(
|
|
instruction.operands[0].imm
|
|
for instruction in disasm.function(*table.bounds(state_getter))
|
|
if instruction.mnemonic == "call" and instruction.operands and
|
|
instruction.operands[0].type == capstone_x86.X86_OP_IMM)
|
|
eye_slot, eye_getter = eye_matches[0]
|
|
initializer_slot, eye_initializer = initializer_matches[0]
|
|
for label, slot, expected in (
|
|
("state getter", state_slot, EXPECTED_STATE_GETTER_SLOT),
|
|
("eye getter", eye_slot, EXPECTED_EYE_GETTER_SLOT),
|
|
("eye initializer", initializer_slot,
|
|
EXPECTED_EYE_INITIALIZER_SLOT),
|
|
):
|
|
if slot != expected:
|
|
warnings.append(f"{label} moved from vtable+0x{expected:x}"
|
|
f" to vtable+0x{slot:x};"
|
|
" update the bridge slot constants")
|
|
evidence.append(
|
|
f"state getter: sret memcpy of 0x{STATE_COPY_SIZE:x} bytes from"
|
|
f" +0x{STATE_OFFSET:x} at vtable+0x{state_slot:x}"
|
|
f" -> 0x{state_getter:x}")
|
|
evidence.append("eye getter: movsxd/shl/mov"
|
|
f" [rdi+rax+0x{FRAMEBUFFER_SLOT_OFFSET:x}] at"
|
|
f" vtable+0x{eye_slot:x} -> 0x{eye_getter:x}")
|
|
evidence.append(
|
|
f"eye initializer: {EYE_RESOURCE_LABEL.decode('ascii')!r}"
|
|
" label, ready"
|
|
f" bytes {[f'0x{x:x}' for x in READY_BYTE_OFFSETS]}, slot arrays"
|
|
f" +0x{FRAMEBUFFER_SLOT_OFFSET:x}/+0x{TEXTURE_SLOT_OFFSET:x} at"
|
|
f" vtable+0x{initializer_slot:x} -> 0x{eye_initializer:x}")
|
|
|
|
haptic_matches = []
|
|
for slot, target in slots.items():
|
|
start, end = table.bounds(target)
|
|
if end - start == 1:
|
|
body = image.bytes_at(image.rva_to_offset(target, 1), 1,
|
|
"haptic sink")
|
|
if body == b"\xc3":
|
|
haptic_matches.append((slot, target))
|
|
if not haptic_matches:
|
|
raise AnalyzerError(
|
|
"no vtable slot points at a 1-byte ret haptic sink")
|
|
haptic_slot, haptic_sink = min(haptic_matches)
|
|
if haptic_slot != EXPECTED_HAPTIC_SLOT:
|
|
warnings.append(
|
|
f"haptic sink moved from vtable+0x{EXPECTED_HAPTIC_SLOT:x} to"
|
|
f" vtable+0x{haptic_slot:x}")
|
|
evidence.append(
|
|
f"haptic sink: 1-byte ret stub at vtable+0x{haptic_slot:x} ->"
|
|
f" 0x{haptic_sink:x}")
|
|
|
|
device_name_rva = image.find_alloc_string(DEVICE_OBJECT_NAME)
|
|
vtable_refs = find_text_refs(image, table, disasm, {vtable})
|
|
constructor_candidates: dict[int, int] = {}
|
|
for _target, refs in vtable_refs.items():
|
|
for _address, start, end in refs:
|
|
instructions = disasm.function(start, end)
|
|
if is_constructor(instructions, vtable, device_name_rva):
|
|
constructor_candidates[start] = end
|
|
if not constructor_candidates:
|
|
raise AnalyzerError(
|
|
"no function satisfies the DebugDeviceVR constructor contract")
|
|
if len(constructor_candidates) > 1:
|
|
listed = ", ".join(
|
|
f"0x{start:x}" for start in constructor_candidates)
|
|
raise AnalyzerError(
|
|
f"ambiguous constructor contract matches: {listed}")
|
|
constructor = next(iter(constructor_candidates))
|
|
if constructor in slots.values():
|
|
raise AnalyzerError(
|
|
"constructor candidate is itself a vtable slot target"
|
|
" (destructor?)")
|
|
evidence.append(
|
|
f"constructor: vtable store, {DEVICE_OBJECT_NAME.decode('ascii')!r}"
|
|
f" name at +0x8, type {OBJECT_TYPE_DEBUG_DEVICE} at"
|
|
f" +0x{OBJECT_TYPE_OFFSET:x},"
|
|
f" memset 0x{CONSTRUCTOR_MEMSET_SIZE:x} of"
|
|
f" +0x{STATE_OFFSET:x} -> 0x{constructor:x}")
|
|
|
|
initializer_instructions = disasm.function(
|
|
*table.bounds(eye_initializer))
|
|
framebuffer_slot = extract_framebuffer_slot(initializer_instructions)
|
|
if framebuffer_slot is None:
|
|
raise AnalyzerError(
|
|
"eye initializer does not contain a unique device vtable"
|
|
" framebuffer call")
|
|
evidence.append(
|
|
f"device createFramebuffer: mov rax,[rax+0x{framebuffer_slot:x}];"
|
|
" call rax inside the eye initializer")
|
|
|
|
emulator_name_rva = image.find_alloc_string(EMULATOR_FLAG_NAME)
|
|
flag_refs = find_text_refs(image, table, disasm, {emulator_name_rva})
|
|
flag_functions: dict[int, int] = {}
|
|
for _target, refs in flag_refs.items():
|
|
for _address, start, end in refs:
|
|
flag_functions[start] = end
|
|
registration_storage = None
|
|
for start, end in sorted(flag_functions.items()):
|
|
storage = extract_registration_storage(disasm.function(start, end),
|
|
emulator_name_rva)
|
|
if storage is not None:
|
|
if (registration_storage is not None and
|
|
storage != registration_storage):
|
|
raise AnalyzerError("ambiguous emulator flag storage:"
|
|
f" 0x{registration_storage:x}"
|
|
f" and 0x{storage:x}")
|
|
registration_storage = storage
|
|
if registration_storage is None:
|
|
raise AnalyzerError(
|
|
"DebugEnableVREmulator registration contract was not found")
|
|
image.require_writable_rva(registration_storage, 1)
|
|
zeroing_seen = any(
|
|
find_flag_zeroing(disasm.function(start, end), registration_storage)
|
|
for start, end in flag_functions.items())
|
|
if zeroing_seen:
|
|
evidence.append(
|
|
"emulator flag: registration lea rsi/rdx contract plus init"
|
|
f" byte-store cross-check -> 0x{registration_storage:x}")
|
|
else:
|
|
warnings.append("emulator flag storage derived from"
|
|
" the registration contract only;"
|
|
" the zero-initializing writer was not found")
|
|
|
|
hand_pitch_name_rva = image.find_alloc_string(HAND_PITCH_FLAG_NAME)
|
|
pitch_refs = find_text_refs(image, table, disasm, {hand_pitch_name_rva})
|
|
pitch_functions: dict[int, int] = {}
|
|
for _target, refs in pitch_refs.items():
|
|
for _address, start, end in refs:
|
|
pitch_functions[start] = end
|
|
hand_pitch = None
|
|
for start, end in sorted(pitch_functions.items()):
|
|
match = extract_hand_pitch(disasm.function(start, end),
|
|
hand_pitch_name_rva)
|
|
if match is not None:
|
|
if hand_pitch is not None and match != hand_pitch:
|
|
raise AnalyzerError(
|
|
"ambiguous NewVRSystemHandPitch storage:"
|
|
f" 0x{hand_pitch[0]:x}"
|
|
f" and 0x{match[0]:x}")
|
|
hand_pitch = match
|
|
if hand_pitch is None:
|
|
raise AnalyzerError(
|
|
"NewVRSystemHandPitch storage contract was not found")
|
|
hand_pitch_storage, hand_pitch_default = hand_pitch
|
|
image.require_writable_rva(hand_pitch_storage, 4)
|
|
evidence.append(
|
|
f"hand pitch: dword default {hand_pitch_default} degrees with name"
|
|
f" pointer at storage+8 -> 0x{hand_pitch_storage:x}")
|
|
|
|
haptic_flags = find_bool_flags(image, table, disasm, HAPTIC_FLAG_NAMES,
|
|
evidence, warnings)
|
|
|
|
getter_candidates: dict[int, tuple[int, int]] = {}
|
|
for hit, selector, base in scan_pointer_getter_sites(image):
|
|
try:
|
|
start, _end = table.bounds(hit)
|
|
except AnalyzerError:
|
|
continue
|
|
getter_candidates[start] = (selector, base)
|
|
getter_matches: list[tuple[int, tuple[Any, ...], int, int, int]] = []
|
|
for start, (selector, base) in sorted(getter_candidates.items()):
|
|
instructions = disasm.function(*table.bounds(start))
|
|
head_scale = match_pointer_getter(instructions, selector, base)
|
|
if head_scale is not None:
|
|
getter_matches.append(
|
|
(start, instructions, selector, base, head_scale))
|
|
if not getter_matches:
|
|
raise AnalyzerError(
|
|
"no function satisfies the world-space pointer getter contract")
|
|
if len(getter_matches) > 1:
|
|
listed = ", ".join(f"0x{match[0]:x}" for match in getter_matches)
|
|
raise AnalyzerError(f"ambiguous pointer getter matches: {listed}")
|
|
(pointer_getter, getter_instructions, selector_offset, base_offset,
|
|
head_scale_offset) = getter_matches[0]
|
|
workspace_getter, camera_slot = extract_pointer_getter_anchors(
|
|
getter_instructions, image)
|
|
evidence.append("pointer getter: UserCFrame selector"
|
|
f" +0x{selector_offset:x},"
|
|
f" base +0x{base_offset:x}, stride"
|
|
f" 0x{USERCFRAME_STRIDE:x},"
|
|
f" HeadScale +0x{head_scale_offset:x}"
|
|
f" -> 0x{pointer_getter:x}")
|
|
evidence.append(f"workspace getter 0x{workspace_getter:x}"
|
|
" via mov rdi,rsi + direct call;"
|
|
f" camera via call [rcx+0x{camera_slot:x}]")
|
|
|
|
return {
|
|
"schema_version": 1,
|
|
"tool": "find_roblox_vr_offsets",
|
|
"elf": {
|
|
"path": str(elf_path),
|
|
"size": image.file_size,
|
|
"build_id": image.build_id,
|
|
"sha256": image.sha256,
|
|
},
|
|
"vr_debug_device_bridge": {
|
|
"vtable_rva":
|
|
f"0x{vtable:x}",
|
|
"constructor_rva":
|
|
f"0x{constructor:x}",
|
|
"state_getter_rva":
|
|
f"0x{state_getter:x}",
|
|
"eye_getter_rva":
|
|
f"0x{eye_getter:x}",
|
|
"eye_initializer_rva":
|
|
f"0x{eye_initializer:x}",
|
|
"emulator_flag_storage_rva":
|
|
f"0x{registration_storage:x}",
|
|
"device_create_framebuffer_vtable_offset":
|
|
f"0x{framebuffer_slot:x}",
|
|
},
|
|
"vr_bridge_supplement": {
|
|
"state_getter_vtable_slot_offset":
|
|
f"0x{state_slot:x}",
|
|
"state_getter_memcpy_rva":
|
|
f"0x{state_memcpy:x}",
|
|
"eye_getter_vtable_slot_offset":
|
|
f"0x{eye_slot:x}",
|
|
"eye_initializer_vtable_slot_offset":
|
|
f"0x{initializer_slot:x}",
|
|
"haptic_sink_rva":
|
|
f"0x{haptic_sink:x}",
|
|
"haptic_sink_vtable_slot_offset":
|
|
f"0x{haptic_slot:x}",
|
|
"world_pointer_getter_rva":
|
|
f"0x{pointer_getter:x}",
|
|
"workspace_getter_rva":
|
|
f"0x{workspace_getter:x}",
|
|
"workspace_camera_vtable_offset":
|
|
f"0x{camera_slot:x}",
|
|
"usercframe_selector_offset":
|
|
f"0x{selector_offset:x}",
|
|
"usercframe_base_offset":
|
|
f"0x{base_offset:x}",
|
|
"camera_head_scale_offset":
|
|
f"0x{head_scale_offset:x}",
|
|
"new_vr_system_hand_pitch_storage_rva":
|
|
f"0x{hand_pitch_storage:x}",
|
|
"new_vr_system_hand_pitch_default_degrees":
|
|
hand_pitch_default,
|
|
**{field: f"0x{rva:x}" for field, rva in haptic_flags.items()},
|
|
},
|
|
"evidence": evidence,
|
|
"warnings": warnings,
|
|
}
|
|
|
|
|
|
def write_json_atomic(path: pathlib.Path, payload: dict[str, Any]) -> None:
|
|
encoded = json.dumps(payload, indent=2) + "\n"
|
|
if len(encoded.encode("utf-8")) > MAX_JSON_OUTPUT_BYTES:
|
|
raise AnalyzerError("analysis result exceeds the output size limit")
|
|
parent = path.parent
|
|
try:
|
|
handle = tempfile.NamedTemporaryFile("w",
|
|
encoding="utf-8",
|
|
dir=parent,
|
|
prefix=path.name,
|
|
delete=False)
|
|
with handle:
|
|
handle.write(encoded)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(handle.name, path)
|
|
except OSError as error:
|
|
raise AnalyzerError(f"cannot write output file: {path}") from error
|
|
|
|
|
|
def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="find-roblox-vr-offsets",
|
|
description=(
|
|
"Extract and validate the native VR bridge offsets from an x86-64"
|
|
" Roblox libroblox.so payload."),
|
|
)
|
|
parser.add_argument(
|
|
"--elf",
|
|
required=True,
|
|
type=pathlib.Path,
|
|
help="path to the libroblox.so ELF image to analyze",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=pathlib.Path,
|
|
default=None,
|
|
help="optional path for the JSON result (written atomically)",
|
|
)
|
|
parser.add_argument(
|
|
"--quiet",
|
|
action="store_true",
|
|
help="do not echo the JSON result to stdout when --output is given",
|
|
)
|
|
return parser.parse_args(list(argv))
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
arguments = parse_arguments(sys.argv[1:] if argv is None else argv)
|
|
try:
|
|
result = find_vr_offsets(arguments.elf)
|
|
if arguments.output is not None:
|
|
write_json_atomic(arguments.output, result)
|
|
if not arguments.quiet:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(json.dumps(result, indent=2))
|
|
except AnalyzerError as error:
|
|
print(f"find-roblox-vr-offsets: error: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|