diff --git a/src/game_engine/unity/il2cpp/globals.rs b/src/game_engine/unity/il2cpp/globals.rs
index 84b6126d..12c7716d 100644
--- a/src/game_engine/unity/il2cpp/globals.rs
+++ b/src/game_engine/unity/il2cpp/globals.rs
@@ -24,6 +24,7 @@ fn export(
name: &str,
) -> Option
{
let matches = |symbol_name: ArrayCString<48>| symbol_name.matches(name);
+
match format {
BinaryFormat::PE => pe::symbols(process, module.0)
.find(|symbol| symbol.get_name::<48>(process).is_ok_and(matches))
@@ -44,28 +45,36 @@ pub(super) fn assemblies(
pointer_size: PointerSize,
) -> Option {
let start = export(process, module, format, "il2cpp_domain_get_assemblies")?;
+ let x64 = pointer_size == PointerSize::Bit64;
let linux = format == BinaryFormat::ELF;
+
walk(process, module, format, start, pointer_size, 1, |code| {
- if pointer_size == PointerSize::Bit64 {
- // lea rax, [s_Assemblies]; ret, or mov rax, [s_Assemblies]; ret.
- // On Linux a getter can pop between the two to keep its stack
- // aligned, and the master export can go on with sub rcx, rax.
- let lea = bytes_at(code, 0, &[0x48, 0x8D, 0x05]);
- let mov = bytes_at(code, 0, &[0x48, 0x8B, 0x05]);
- let popped =
- linux && matches!(code.get(7), Some(0x58..=0x5F)) && bytes_at(code, 8, &[0xC3]);
- let returned = (lea || mov) && (bytes_at(code, 7, &[0xC3]) || popped);
- let subtracted = linux && mov && bytes_at(code, 7, &[0x48, 0x29, 0xC1]);
- (returned || subtracted).then_some(3)
- } else {
- // mov eax, s_Assemblies; ret, or mov eax, [s_Assemblies]; pop ebp; ret
- let getter = bytes_at(code, 0, &[0xB8]) && bytes_at(code, 5, &[0xC3]);
- let master = bytes_at(code, 0, &[0xA1]) && bytes_at(code, 5, &[0x5D, 0xC3]);
- (getter || master).then_some(1)
- }
+ assemblies_load(code, x64, linux)
})
}
+/// Returns where the operand pointing at `s_Assemblies` starts in `code`,
+/// when `code` starts with the load of a getter or a master export.
+fn assemblies_load(code: &[u8], x64: bool, linux: bool) -> Option {
+ if x64 {
+ // lea rax, [s_Assemblies]; ret, or mov rax, [s_Assemblies]; ret.
+ // On Linux a getter can pop between the two to keep its stack
+ // aligned, and the master export can go on with sub rcx, rax.
+ let lea = bytes_at(code, 0, &[0x48, 0x8D, 0x05]);
+ let mov = bytes_at(code, 0, &[0x48, 0x8B, 0x05]);
+ let popped =
+ linux && matches!(code.get(7), Some(0x58..=0x5F)) && bytes_at(code, 8, &[0xC3]);
+ let returned = (lea || mov) && (bytes_at(code, 7, &[0xC3]) || popped);
+ let subtracted = linux && mov && bytes_at(code, 7, &[0x48, 0x29, 0xC1]);
+ (returned || subtracted).then_some(3)
+ } else {
+ // mov eax, s_Assemblies; ret, or mov eax, [s_Assemblies]; pop ebp; ret
+ let getter = bytes_at(code, 0, &[0xB8]) && bytes_at(code, 5, &[0xC3]);
+ let master = bytes_at(code, 0, &[0xA1]) && bytes_at(code, 5, &[0x5D, 0xC3]);
+ (getter || master).then_some(1)
+ }
+}
+
/// Finds `s_TypeInfoDefinitionTable` through `il2cpp_image_get_class`, or
/// through `il2cpp_type_get_class_or_element_class` where the first one isn't
/// exported. Either reaches the type accessor within a few calls and jumps.
@@ -85,26 +94,35 @@ pub(super) fn type_info_definition_table(
"il2cpp_type_get_class_or_element_class",
)
})?;
+ let x64 = pointer_size == PointerSize::Bit64;
let linux = format == BinaryFormat::ELF;
+
walk(process, module, format, start, pointer_size, 4, |code| {
- if pointer_size == PointerSize::Bit64 {
- // mov rax, [table]; then cmp qword ptr [reg + rax], 0, or
- // lea rsi or r14, [rax + reg * 8]
- let compared = bytes_at(code, 7, &[0x48, 0x83, 0x3C]) && bytes_at(code, 11, &[0]);
- let addressed =
- matches!(code.get(7), Some(0x48 | 0x4C)) && bytes_at(code, 8, &[0x8D, 0x34]);
- let windows = bytes_at(code, 0, &[0x48, 0x8B, 0x05]) && (compared || addressed);
- (windows || (linux && indexed_load(code))).then_some(3)
- } else {
- // mov eax, [table]; then cmp dword ptr [eax + reg * 4], 0, or
- // mov esi, dword ptr [eax + reg * 4]
- let compared = bytes_at(code, 5, &[0x83, 0x3C]) && bytes_at(code, 8, &[0]);
- let loaded = bytes_at(code, 5, &[0x8B, 0x34]);
- (bytes_at(code, 0, &[0xA1]) && (compared || loaded)).then_some(1)
- }
+ table_load(code, x64, linux)
})
}
+/// Returns where the operand pointing at `s_TypeInfoDefinitionTable` starts
+/// in `code`, when `code` starts with the type accessor's load of the table
+/// and the read at the type index right after it.
+fn table_load(code: &[u8], x64: bool, linux: bool) -> Option {
+ if x64 {
+ // mov rax, [table]; then cmp qword ptr [reg + rax], 0, or
+ // lea rsi or r14, [rax + reg * 8]
+ let compared = bytes_at(code, 7, &[0x48, 0x83, 0x3C]) && bytes_at(code, 11, &[0]);
+ let addressed =
+ matches!(code.get(7), Some(0x48 | 0x4C)) && bytes_at(code, 8, &[0x8D, 0x34]);
+ let windows = bytes_at(code, 0, &[0x48, 0x8B, 0x05]) && (compared || addressed);
+ (windows || (linux && indexed_load(code))).then_some(3)
+ } else {
+ // mov eax, [table]; then cmp dword ptr [eax + reg * 4], 0, or
+ // mov esi, dword ptr [eax + reg * 4]
+ let compared = bytes_at(code, 5, &[0x83, 0x3C]) && bytes_at(code, 8, &[0]);
+ let loaded = bytes_at(code, 5, &[0x8B, 0x34]);
+ (bytes_at(code, 0, &[0xA1]) && (compared || loaded)).then_some(1)
+ }
+}
+
/// Checks whether `code` starts with a load of a global into a register that
/// one of the next 2 instructions reads at an index times 8, the way clang
/// compiles the type accessor on Linux. A return, jump, call or branch ends
@@ -116,6 +134,7 @@ fn indexed_load(code: &[u8]) -> bool {
if !matches!(rex, 0x48 | 0x4C) || modrm & 0xC7 != 0x05 {
return false;
}
+
let register = ((rex & 0x04) << 1) | ((modrm >> 3) & 7);
let mut at = 7;
for _ in 0..2 {
@@ -130,6 +149,7 @@ fn indexed_load(code: &[u8]) -> bool {
}
at += next.len;
}
+
false
}
@@ -144,11 +164,13 @@ fn reads_scaled(instruction: &[u8], register: u8) -> bool {
Some(0x0F) => rest.get(1..).unwrap_or_default(),
_ => rest,
};
+
let (Some(&modrm), Some(&sib)) = (rest.get(1), rest.get(2)) else {
return false;
};
let base = ((rex & 0x01) << 3) | (sib & 7);
let displacement = modrm >> 6 == 0 && sib & 7 == 5;
+
modrm >> 6 != 3 && modrm & 7 == 4 && sib >> 6 == 3 && !displacement && base == register
}
@@ -171,9 +193,9 @@ const STEPS: usize = 2048;
/// Walks the functions reachable from `start`, breadth first and at most
/// `depth` calls or jumps deep. Inside a function it follows every branch
-/// and stops at a return or a jump. `look` gets the bytes at each
-/// instruction and returns where an operand naming a global starts in them.
-/// Returns the first global found that way.
+/// and stops at a return or a jump, but skips a hook at the start. `look`
+/// gets the bytes at each instruction and returns where an operand naming a
+/// global starts in them. Returns the first global found that way.
fn walk(
process: &Process,
module: (Address, u64),
@@ -183,119 +205,251 @@ fn walk(
depth: usize,
mut look: impl FnMut(&[u8]) -> Option,
) -> Option {
- let x64 = pointer_size == PointerSize::Bit64;
- let end = module.0 + module.1;
- let inside = |address: Address| address >= module.0 && address < end;
- let mut code = Code::new(process, end);
- let mut done = ArrayVec::::new();
+ let mut walker = Walker::new(process, module, format, pointer_size);
let mut level = ArrayVec::::new();
level.push(start);
for _ in 0..=depth {
let mut next = ArrayVec::::new();
for &function in &level {
- if done.contains(&function) || done.try_push(function).is_err() {
- continue;
+ if let Some(global) = walker.function(function, &mut look, &mut next) {
+ return Some(global);
}
- // An x64 PE image lists where each function starts and ends, and an
- // ELF image gives the size of each exported function, so the walk
- // stops at the end even after a call that never returns.
- let bounds = match format {
- BinaryFormat::PE if x64 => function_range(process, module.0, function),
- BinaryFormat::PE => None,
- _ => symbol_range(process, module.0, function),
- };
- let function_end = bounds.map_or(end, |(_, to)| to);
- let mut branches = ArrayVec::::new();
- let mut walked = ArrayVec::<(Address, Address), BRANCHES>::new();
- branches.push(function);
- let mut steps = 0;
- while let Some(start) = branches.pop() {
- let mut at = start;
- while steps < STEPS
- && at < function_end
- && !walked.iter().any(|&(from, to)| at >= from && at < to)
- {
- steps += 1;
- let Some(bytes) = code.at(at) else { break };
- // The bytes past the function's end belong to other code,
- // so neither the decoder nor `look` gets to see them.
- let left = (function_end.value() - at.value()) as usize;
- let bytes = &bytes[..bytes.len().min(left)];
- let Some(instruction) = decode(bytes, x64) else {
- break;
- };
- // A match whose global lies outside the module is some
- // other load, so the walk goes on past it.
- let global = look(bytes).and_then(|offset| {
- let operand = bytes.get(offset..offset + 4)?;
- let value =
- u32::from_le_bytes([operand[0], operand[1], operand[2], operand[3]]);
- Some(if x64 {
- at + offset as u64 + 4 + value as i32
- } else {
- Address::new(value as u64)
- })
- });
- if let Some(global) = global.filter(|&global| inside(global)) {
- return Some(global);
+ }
+ level = next;
+ }
+
+ None
+}
+
+/// Keeps the module a walk reads, the code it read so far and the functions it
+/// already walked, for every function the walk goes through.
+struct Walker<'a> {
+ process: &'a Process,
+ module: (Address, u64),
+ format: BinaryFormat,
+ x64: bool,
+ code: Code<'a>,
+ done: ArrayVec,
+}
+
+impl<'a> Walker<'a> {
+ fn new(
+ process: &'a Process,
+ module: (Address, u64),
+ format: BinaryFormat,
+ pointer_size: PointerSize,
+ ) -> Self {
+ Self {
+ process,
+ module,
+ format,
+ x64: pointer_size == PointerSize::Bit64,
+ code: Code::new(process, module.0 + module.1),
+ done: ArrayVec::new(),
+ }
+ }
+
+ /// Walks every branch of `function` and returns the first global `look`
+ /// finds in it. Calls and jumps out of the function go into `next`. Skips a
+ /// function the walk already went through.
+ fn function(
+ &mut self,
+ function: Address,
+ look: &mut impl FnMut(&[u8]) -> Option,
+ next: &mut ArrayVec,
+ ) -> Option {
+ if self.done.contains(&function) || self.done.try_push(function).is_err() {
+ return None;
+ }
+
+ let bounds = self.bounds(function);
+ let function_end = bounds.map_or(self.module.0 + self.module.1, |(_, to)| to);
+ let mut branches = ArrayVec::::new();
+ let mut walked = ArrayVec::<(Address, Address), BRANCHES>::new();
+ let mut steps = 0;
+ branches.push(function);
+
+ while let Some(start) = branches.pop() {
+ let mut at = start;
+ while steps < STEPS
+ && at < function_end
+ && !walked.iter().any(|&(from, to)| at >= from && at < to)
+ {
+ steps += 1;
+ let Some(bytes) = self.code.at(at) else { break };
+ // The bytes past the function's end belong to other code,
+ // so neither the decoder nor `look` gets to see them.
+ let left = (function_end.value() - at.value()) as usize;
+ let bytes = &bytes[..bytes.len().min(left)];
+
+ // Skips over a hook at the start of a function because the
+ // hook only overwrites the first few bytes of it.
+ if at == function {
+ if let Some(len) = hook(bytes, at, self.x64, self.module) {
+ at = self.behind_hook(function, at + len);
+ continue;
}
- let after = at + instruction.len as u64;
- let target = |rel: i32| Some(after + rel).filter(|&target| inside(target));
- at = after;
- match instruction.flow {
- Flow::Next => {}
- Flow::Branch(rel) => match target(rel) {
- // A branch out of the function goes on in another
- // one, like a jump, and this one goes on too.
- Some(target)
- if bounds
- .is_some_and(|(from, to)| target < from || target >= to) =>
- {
- if !next.contains(&target) {
- let _ = next.try_push(target);
- }
- }
- Some(target) => {
- let _ = branches.try_push(target);
- }
- None => {}
- },
- Flow::Call(rel) => {
- if let Some(target) =
- target(rel).filter(|target| !next.contains(target))
- {
- let _ = next.try_push(target);
- }
- }
- Flow::Jump(rel) => {
- // A jump inside the function goes on with it. A
- // jump out of it goes on in another function.
- let inner = |target: &Address| {
- bounds.is_some_and(|(from, to)| *target >= from && *target < to)
- };
- match target(rel) {
- Some(target) if inner(&target) => {
- let _ = branches.try_push(target);
- }
- Some(target) if !next.contains(&target) => {
- let _ = next.try_push(target);
- }
- _ => {}
- }
- break;
- }
- Flow::Stop => break,
+ }
+
+ let Some(instruction) = decode(bytes, self.x64) else {
+ break;
+ };
+
+ // A match whose global lies outside the module is some
+ // other load, so the walk goes on past it.
+ let global = look(bytes).and_then(|offset| operand(bytes, at, offset, self.x64));
+ if let Some(global) = global.filter(|&global| contains(self.module, global)) {
+ return Some(global);
+ }
+
+ at = at + instruction.len as u64;
+ if !self.follow(instruction.flow, at, bounds, &mut branches, next) {
+ break;
+ }
+ }
+
+ if at > start {
+ let _ = walked.try_push((start, at));
+ }
+ }
+
+ None
+ }
+
+ /// Finds where `function` starts and ends. An x64 PE image lists where
+ /// each function starts and ends, and an ELF image gives the size of each
+ /// exported function, so the walk stops at the end even after a call that
+ /// never returns.
+ fn bounds(&self, function: Address) -> Option<(Address, Address)> {
+ match self.format {
+ BinaryFormat::PE if self.x64 => function_range(self.process, self.module.0, function),
+ BinaryFormat::PE => None,
+ _ => symbol_range(self.process, self.module.0, function),
+ }
+ }
+
+ /// Finds where the walk goes on behind a hook that ends at `end`, which is
+ /// the end of the first prologue instruction that ends at or after `end`,
+ /// because the hook can end inside an instruction whose leftover bytes
+ /// decode as something else. Falls back to `end` when there is no unwind
+ /// info, since only an x64 PE image has it.
+ fn behind_hook(&self, function: Address, end: Address) -> Address {
+ match self.format {
+ BinaryFormat::PE if self.x64 => {
+ prologue_end(self.process, self.module.0, function, end).unwrap_or(end)
+ }
+ _ => end,
+ }
+ }
+
+ /// Queues the places the code goes on to after an instruction with this
+ /// `flow`. Puts a branch or jump inside the function into `branches` and a
+ /// call or a jump out of the function into `next`. Returns whether the walk
+ /// goes on with the instruction at `after`.
+ fn follow(
+ &self,
+ flow: Flow,
+ after: Address,
+ bounds: Option<(Address, Address)>,
+ branches: &mut ArrayVec,
+ next: &mut ArrayVec,
+ ) -> bool {
+ let target = |rel: i32| Some(after + rel).filter(|&target| contains(self.module, target));
+ let within =
+ |target: Address| bounds.is_some_and(|(from, to)| target >= from && target < to);
+
+ match flow {
+ Flow::Next => true,
+ Flow::Branch(rel) => {
+ match target(rel) {
+ // A branch out of the function goes on in another
+ // one, like a jump, and this one goes on too.
+ Some(target) if bounds.is_some() && !within(target) => queue(next, target),
+ Some(target) => {
+ let _ = branches.try_push(target);
}
+ None => {}
}
- if at > start {
- let _ = walked.try_push((start, at));
+ true
+ }
+ Flow::Call(rel) => {
+ if let Some(target) = target(rel) {
+ queue(next, target);
}
+ true
}
+ Flow::Jump(rel) => {
+ // A jump inside the function goes on with it. A
+ // jump out of it goes on in another function.
+ match target(rel) {
+ Some(target) if within(target) => {
+ let _ = branches.try_push(target);
+ }
+ Some(target) => queue(next, target),
+ None => {}
+ }
+ false
+ }
+ Flow::Stop => false,
}
- level = next;
}
- None
+}
+
+/// Returns the address the operand at `offset` in the instruction at `at`
+/// points at. Adds an x64 operand to the address right after the operand
+/// because x64 code holds the distance to the global there.
+fn operand(code: &[u8], at: Address, offset: usize, x64: bool) -> Option {
+ let bytes = code.get(offset..offset + 4)?;
+ let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
+
+ Some(if x64 {
+ at + offset as u64 + 4 + value as i32
+ } else {
+ Address::new(value as u64)
+ })
+}
+
+/// Returns how many bytes the jump at the start of `code` takes up when it
+/// leaves the module, either straight to its target or through a pointer
+/// outside the module, because that is how a hook starts. Returns `None` for
+/// a jump through a pointer inside the module because that is an import
+/// thunk.
+fn hook(code: &[u8], at: Address, x64: bool, module: (Address, u64)) -> Option {
+ let word = |at: usize| Some(u32::from_le_bytes(code.get(at..at + 4)?.try_into().ok()?));
+ let outside = |address: Address| !contains(module, address);
+
+ match code {
+ // jmp rel32
+ [0xE9, ..] => outside(at + 5 + word(1)? as i32).then_some(5),
+ // jmp [rip + 0], with the 64-bit target right behind the jump
+ [0xFF, 0x25, 0, 0, 0, 0, ..] if x64 => {
+ let target = u64::from_le_bytes(code.get(6..14)?.try_into().ok()?);
+ outside(Address::new(target)).then_some(14)
+ }
+ // jmp [rip + disp32] on x64, jmp [disp32] on x86
+ [0xFF, 0x25, ..] => {
+ let pointer = if x64 {
+ at + 6 + word(2)? as i32
+ } else {
+ Address::new(word(2)?.into())
+ };
+ outside(pointer).then_some(6)
+ }
+ _ => None,
+ }
+}
+
+/// Checks whether `address` lies inside `module`.
+fn contains(module: (Address, u64), address: Address) -> bool {
+ address >= module.0 && address < module.0 + module.1
+}
+
+/// Adds `function` to `level` unless it's there already or `level` is full.
+fn queue(level: &mut ArrayVec, function: Address) {
+ if !level.contains(&function) {
+ let _ = level.try_push(function);
+ }
}
/// Finds the start and end of the x64 function that holds `address`, in the
@@ -306,29 +460,90 @@ fn function_range(
module: Address,
address: Address,
) -> Option<(Address, Address)> {
+ let [from, to, _] = function_entry(process, module, address)?;
+ Some((module + from, module + to))
+}
+
+/// Returns the exception directory entry of the x64 function that holds
+/// `address`, with the function's start, end and unwind info relative to the
+/// module at `module`.
+fn function_entry(process: &Process, module: Address, address: Address) -> Option<[u32; 3]> {
// The exception directory is the fourth data directory of a PE32+ header,
// and each of its entries holds the start, end and unwind info of one
// function, sorted by start.
let header = process.read::(module + 0x3C).ok()?;
let [table, size] = process.read::<[u32; 2]>(module + header + 0xA0).ok()?;
let offset = address.value().checked_sub(module.value())? as u32;
+
let (mut low, mut high) = (0, size / 12);
while low < high {
let middle = (low + high) / 2;
- let [from, to, _] = process
+ let entry = process
.read::<[u32; 3]>(module + table + middle * 12)
.ok()?;
- if offset < from {
+ if offset < entry[0] {
high = middle;
- } else if offset >= to {
+ } else if offset >= entry[1] {
low = middle + 1;
} else {
- return Some((module + from, module + to));
+ return Some(entry);
}
}
+
None
}
+/// Finds where the first prologue instruction of the x64 function at
+/// `function` that ends at or after `at` ends. Reads the ends from the
+/// function's unwind codes and the size of its prologue because each code
+/// holds where the instruction it describes ends.
+fn prologue_end(
+ process: &Process,
+ module: Address,
+ function: Address,
+ at: Address,
+) -> Option {
+ // Skips an entry that points at offset 0 because it has no unwind info,
+ // since the DOS header sits at offset 0.
+ let [_, _, unwind] = function_entry(process, module, function)?;
+ if unwind == 0 {
+ return None;
+ }
+ let [_, prologue, count, _] = process.read::<[u8; 4]>(module + unwind).ok()?;
+ let wanted = at.value().checked_sub(function.value())?;
+
+ let mut end = (u64::from(prologue) >= wanted).then_some(u64::from(prologue));
+ let mut slot = 0;
+ while slot < u32::from(count) {
+ let [offset, code] = process
+ .read::<[u8; 2]>(module + unwind + 4 + slot * 2)
+ .ok()?;
+ let (op, info) = (code & 0x0F, code >> 4);
+ // Skips epilog codes because they describe the epilog instead of a
+ // prologue instruction.
+ if op != 6 && u64::from(offset) >= wanted {
+ end = Some(end.map_or(u64::from(offset), |end| end.min(u64::from(offset))));
+ }
+ slot += 1 + u32::from(extra_slots(op, info));
+ }
+
+ end.map(|end| function + end)
+}
+
+/// Returns how many more slots an unwind code with this op takes after its
+/// own, because some ops keep a size or an offset there.
+const fn extra_slots(op: u8, info: u8) -> u8 {
+ match op {
+ // UWOP_ALLOC_LARGE: 1 slot for a size up to 512 KiB, 2 for a larger one
+ 1 => 1 + (info != 0) as u8,
+ // UWOP_SAVE_NONVOL and UWOP_SAVE_XMM128
+ 4 | 8 => 1,
+ // UWOP_SAVE_NONVOL_FAR and UWOP_SAVE_XMM128_FAR
+ 5 | 9 => 2,
+ _ => 0,
+ }
+}
+
/// Finds the start and end of the exported function that holds `address`,
/// from the sizes the ELF dynamic symbols carry. Returns `None` for a function
/// nothing exports.
@@ -345,7 +560,7 @@ fn symbol_range(
}
/// The longest instruction is 15 bytes, and the patterns the walk looks for
-/// reach 12 bytes past the start of one.
+/// reach 14 bytes past the start of one.
const AHEAD: usize = 16;
/// Code read from the process a block at a time, so the walk doesn't make a
@@ -375,6 +590,7 @@ impl<'a> Code<'a> {
fn at(&mut self, at: Address) -> Option<&[u8]> {
let left = self.end.value().checked_sub(at.value())? as usize;
let offset = at.value().wrapping_sub(self.start.value()) as usize;
+
if at < self.start || offset + AHEAD.min(left) > self.len {
let len = self.bytes.len().min(left);
self.process
@@ -384,6 +600,7 @@ impl<'a> Code<'a> {
self.len = len;
return Some(&self.bytes[..len]);
}
+
Some(&self.bytes[offset..self.len])
}
}
diff --git a/src/game_engine/unity/il2cpp/globals_tests.rs b/src/game_engine/unity/il2cpp/globals_tests.rs
index dd90c709..be479388 100644
--- a/src/game_engine/unity/il2cpp/globals_tests.rs
+++ b/src/game_engine/unity/il2cpp/globals_tests.rs
@@ -376,6 +376,168 @@ fn finds_the_table_four_jumps_in() {
);
}
+/// Looks for the table in an image where the hook is at 0x1200, 2 jumps into
+/// `il2cpp_image_get_class`, and the table accessor sits right behind it.
+fn hooked_accessor(hook: &[u8]) -> Option {
+ let behind = 0x1200 + hook.len() as u32;
+ let accessor = [hook, &accessor_x64(behind)].concat();
+ let code = [
+ (0x1000, jmp(0x1000, 0x1100)),
+ (0x1100, jmp(0x1100, 0x1200)),
+ (0x1200, accessor),
+ ];
+ let code: Vec<(u32, &[u8])> = code.iter().map(|(at, bytes)| (*at, &bytes[..])).collect();
+ table(PointerSize::Bit64, "il2cpp_image_get_class", &code)
+}
+
+// MelonLoader replaces the first 6 bytes of the accessor with a jmp through a
+// pointer that sits outside the module.
+#[test]
+fn finds_the_table_behind_a_jump_through_a_pointer_outside_the_module() {
+ let hook = [&[0xFF, 0x25][..], &displacement(0x1206, 0x7000_0000)].concat();
+ assert_eq!(hooked_accessor(&hook), Some(Address::new(BASE + 0x3000)));
+}
+
+// The hook MelonLoader wrote into Big Walk, with its exception directory
+// entry. The pointer sits below the module, so the displacement is negative.
+#[test]
+fn finds_the_table_behind_the_hook_of_a_windows_player() {
+ let accessor = [
+ &[0xFF, 0x25, 0x3A, 0x47, 0x06, 0x80][..],
+ &accessor_x64(0x1206),
+ ]
+ .concat();
+ let code = [
+ (0x1000, jmp(0x1000, 0x1100)),
+ (0x1100, jmp(0x1100, 0x1200)),
+ (0x1200, accessor),
+ ];
+ let code: Vec<(u32, &[u8])> = code.iter().map(|(at, bytes)| (*at, &bytes[..])).collect();
+ let mut image = image(
+ PointerSize::Bit64,
+ &[("il2cpp_image_get_class", 0x1000)],
+ &code,
+ );
+ put_functions(
+ &mut image,
+ &[(0x1000, 0x1005), (0x1100, 0x1105), (0x1200, 0x1220)],
+ );
+
+ let found = with_process(&[(BASE, &image)], |process| {
+ globals::type_info_definition_table(
+ process,
+ (Address::new(BASE), SIZE as u64),
+ BinaryFormat::PE,
+ PointerSize::Bit64,
+ )
+ });
+ assert_eq!(found, Some(Address::new(BASE + 0x3000)));
+}
+
+// The hook check runs for the assemblies walk too, where the hooked function
+// is the getter the export calls.
+#[test]
+fn finds_s_assemblies_behind_a_hook_on_the_getter() {
+ let call = [
+ &[0x48, 0x83, 0xEC, 0x28, 0xE8][..],
+ &displacement(0x1009, 0x1100),
+ ]
+ .concat();
+ let getter = [
+ &[0xFF, 0x25][..],
+ &displacement(0x1106, 0x7000_0000),
+ &[0x48, 0x8D, 0x05],
+ &displacement(0x110D, 0x3000),
+ &[0xC3],
+ ]
+ .concat();
+ assert_eq!(
+ assemblies(PointerSize::Bit64, &[(0x1000, &call), (0x1100, &getter)]),
+ Some(Address::new(BASE + 0x3000))
+ );
+}
+
+#[test]
+fn finds_the_table_behind_a_hook_in_an_elf_export() {
+ let accessor = [
+ &[0xFF, 0x25][..],
+ &displacement(0x1006, 0x7000_0000),
+ &accessor_x64(0x1006),
+ ]
+ .concat();
+ let image = elf_image(
+ &[("il2cpp_image_get_class", 0x1000, 0x20)],
+ &[(0x1000, &accessor)],
+ );
+ let found = with_process(&[(BASE, &image)], |process| {
+ globals::type_info_definition_table(
+ process,
+ (Address::new(BASE), SIZE as u64),
+ BinaryFormat::ELF,
+ PointerSize::Bit64,
+ )
+ });
+ assert_eq!(found, Some(Address::new(BASE + 0x3000)));
+}
+
+// The walk doesn't treat a jump through a pointer kept right behind it as a
+// hook when the pointer holds an address inside the module.
+#[test]
+fn stops_at_a_jump_through_an_inline_pointer_inside_the_module() {
+ let inline = [
+ &[0xFF, 0x25, 0, 0, 0, 0][..],
+ &(BASE + 0x2000).to_le_bytes(),
+ ]
+ .concat();
+ assert_eq!(hooked_accessor(&inline), None);
+}
+
+// Other hooks jump through a pointer kept right behind the jump or jump
+// straight out of the module.
+#[test]
+fn finds_the_table_behind_the_other_jumps_out_of_the_module() {
+ let inline = [
+ &[0xFF, 0x25, 0, 0, 0, 0][..],
+ &0x7FFE_0000_0000_u64.to_le_bytes(),
+ ]
+ .concat();
+ assert_eq!(hooked_accessor(&inline), Some(Address::new(BASE + 0x3000)));
+ let straight = [&[0xE9][..], &displacement(0x1205, 0x7000_0000)].concat();
+ assert_eq!(
+ hooked_accessor(&straight),
+ Some(Address::new(BASE + 0x3000))
+ );
+}
+
+#[test]
+fn finds_the_x86_table_behind_a_jump_through_a_pointer_outside_the_module() {
+ let hook = [0xFF, 0x25, 0x00, 0x00, 0x00, 0x70];
+ // mov eax, [s_TypeInfoDefinitionTable]; cmp dword ptr [eax + esi*4], 0
+ let accessor = [
+ &hook[..],
+ &[0xA1],
+ &absolute(0x3000),
+ &[0x83, 0x3C, 0xB0, 0x00],
+ ]
+ .concat();
+ assert_eq!(
+ table(
+ PointerSize::Bit32,
+ "il2cpp_image_get_class",
+ &[(0x1000, &accessor)]
+ ),
+ Some(Address::new(BASE + 0x3000))
+ );
+}
+
+// The walk doesn't read past an import thunk because the thunk jumps through
+// a pointer inside the module and is the whole function.
+#[test]
+fn stops_at_a_jump_through_a_pointer_inside_the_module() {
+ let thunk = [&[0xFF, 0x25][..], &displacement(0x1206, 0x3800)].concat();
+ assert_eq!(hooked_accessor(&thunk), None);
+}
+
#[test]
fn searches_a_level_of_more_than_256_functions() {
// The export calls 10 functions, and each of those calls 30 more. The
@@ -575,14 +737,16 @@ fn ends_on_a_loop() {
);
}
+/// Where `put_functions` writes the exception directory in an image.
+const EXCEPTIONS: usize = 0x300;
+
/// Writes an exception directory, the table x64 images keep of where each
-/// function starts and ends, at 0x300 of an image.
+/// function starts and ends, at `EXCEPTIONS` of an image.
fn put_functions(image: &mut [u8], functions: &[(u32, u32)]) {
- const TABLE: usize = 0x300;
- image[0x120..0x124].copy_from_slice(&(TABLE as u32).to_le_bytes());
+ image[0x120..0x124].copy_from_slice(&(EXCEPTIONS as u32).to_le_bytes());
image[0x124..0x128].copy_from_slice(&(functions.len() as u32 * 12).to_le_bytes());
for (index, (begin, end)) in functions.iter().enumerate() {
- let at = TABLE + index * 12;
+ let at = EXCEPTIONS + index * 12;
image[at..at + 4].copy_from_slice(&begin.to_le_bytes());
image[at + 4..at + 8].copy_from_slice(&end.to_le_bytes());
}
@@ -625,6 +789,107 @@ fn stops_at_the_end_of_a_function_after_a_call_that_does_not_return() {
assert_eq!(found, Some(Address::new(BASE + 0x3000)));
}
+/// Writes version 2 unwind info at `at` for function `index` of the exception
+/// directory `put_functions` wrote, with the size of the prologue and the
+/// unwind codes, 2 bytes each.
+fn put_unwind(image: &mut [u8], index: usize, at: u32, prologue: u8, codes: &[[u8; 2]]) {
+ let entry = EXCEPTIONS + index * 12 + 8;
+ image[entry..entry + 4].copy_from_slice(&at.to_le_bytes());
+
+ let at = at as usize;
+ image[at..at + 4].copy_from_slice(&[2, prologue, codes.len() as u8, 0]);
+ for (slot, code) in codes.iter().enumerate() {
+ image[at + 4 + slot * 2..at + 6 + slot * 2].copy_from_slice(code);
+ }
+}
+
+// The walk goes on where the unwind info says the next prologue instruction
+// starts because a hook that ends inside an instruction leaves the rest of
+// that instruction behind and those bytes decode as something else.
+#[test]
+fn resumes_behind_a_hook_where_the_unwind_info_ends_an_instruction() {
+ // mov rax, rsp; mov [rax + 8], rbx; then the table load. The 6-byte hook
+ // ends inside mov [rax + 8], rbx and leaves its last byte behind.
+ let mut accessor = [
+ &[0x48, 0x8B, 0xC4, 0x48, 0x89, 0x58, 0x08][..],
+ &accessor_x64(0x1207),
+ ]
+ .concat();
+ let hook = [&[0xFF, 0x25][..], &displacement(0x1206, 0x7000_0000)].concat();
+ accessor[..6].copy_from_slice(&hook);
+ let code = [
+ (0x1000, jmp(0x1000, 0x1100)),
+ (0x1100, jmp(0x1100, 0x1200)),
+ (0x1200, accessor),
+ ];
+ let code: Vec<(u32, &[u8])> = code.iter().map(|(at, bytes)| (*at, &bytes[..])).collect();
+ let mut image = image(
+ PointerSize::Bit64,
+ &[("il2cpp_image_get_class", 0x1000)],
+ &code,
+ );
+ put_functions(&mut image, &[(0x1200, 0x1220)]);
+
+ // An epilog code, a code that ends at 14 and a save of rbx that ends at 7.
+ // A walk that took the epilog code's 6 or read the save's second slot,
+ // which holds 6, as a code of its own would go on at 6. A walk that took
+ // the first or the largest end would go on at 14, in the middle of the
+ // load.
+ put_unwind(
+ &mut image,
+ 0,
+ 0x2000,
+ 14,
+ &[[6, 0x16], [14, 0x02], [7, 0x34], [6, 0]],
+ );
+
+ let found = with_process(&[(BASE, &image)], |process| {
+ globals::type_info_definition_table(
+ process,
+ (Address::new(BASE), SIZE as u64),
+ BinaryFormat::PE,
+ PointerSize::Bit64,
+ )
+ });
+ assert_eq!(found, Some(Address::new(BASE + 0x3000)));
+}
+
+// The walk reads all 255 slots unwind info can hold, even when the last code
+// takes 2 more slots than there are, because it counts the slots past 255.
+#[test]
+fn reads_unwind_info_with_the_most_slots() {
+ let mut accessor = [
+ &[0x48, 0x8B, 0xC4, 0x48, 0x89, 0x58, 0x08][..],
+ &accessor_x64(0x1007),
+ ]
+ .concat();
+ let hook = [&[0xFF, 0x25][..], &displacement(0x1006, 0x7000_0000)].concat();
+ accessor[..6].copy_from_slice(&hook);
+ let code = [(0x1000, accessor)];
+ let code: Vec<(u32, &[u8])> = code.iter().map(|(at, bytes)| (*at, &bytes[..])).collect();
+ let mut image = image(
+ PointerSize::Bit64,
+ &[("il2cpp_image_get_class", 0x1000)],
+ &code,
+ );
+ put_functions(&mut image, &[(0x1000, 0x1020)]);
+
+ // 254 pushes that end at 7 and a far save of rbx in the last slot.
+ let mut codes = vec![[7, 0x00]; 254];
+ codes.push([7, 0x15]);
+ put_unwind(&mut image, 0, 0x2000, 7, &codes);
+
+ let found = with_process(&[(BASE, &image)], |process| {
+ globals::type_info_definition_table(
+ process,
+ (Address::new(BASE), SIZE as u64),
+ BinaryFormat::PE,
+ PointerSize::Bit64,
+ )
+ });
+ assert_eq!(found, Some(Address::new(BASE + 0x3000)));
+}
+
/// Builds a Linux `GameAssembly.so` image with the given exports and code.
/// Each export is a name, the offset of its code and the size of that code.
fn elf_image(exports: &[(&str, u32, u32)], code: &[(u32, &[u8])]) -> Vec {