diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index a724f939..76555d27 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,8 +1,11 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ - file_format::pe, future::retry, print_limited, signature::Signature, Address, PointerSize, - Process, + file_format::pe, future::retry, print_limited, signature::Signature, Address, Error, + PointerSize, Process, }; mod builds; @@ -17,9 +20,11 @@ pub use pointer::UnityPointer; mod offsets; use offsets::IL2CPPOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::managed; +use super::{managed, ManagedString}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -317,6 +322,38 @@ impl Module { self.get_image(process, "Assembly-CSharp") } + /// Reads a managed string through the reference stored at the given + /// address, such as the end of a pointer path or a slot in a static + /// table. The string carries its own character count, so no length is + /// passed, and the returned [`ManagedString`] holds exactly that many + /// UTF-16 units, a nul character among them like any other. `N` bounds + /// how many units the string holds, and a string claiming more than that + /// fails rather than truncates, as do a negative count and a null + /// reference. + pub fn read_string( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_string(process, self.pointer_size, at) + } + + /// Reads a managed array of value elements through the reference stored + /// at the given address. The array carries its own length, so no count + /// is passed; `N` bounds how many elements the returned [`ArrayVec`] + /// holds, and an array claiming more than that fails rather than + /// truncates, as does a null reference. The element type is the caller's + /// claim and has to match the target's own element layout: a managed + /// `char` is a `u16` here, a `bool` a single byte, and Rust's `char` and + /// `usize` never match. Reference elements have no portable claim. + pub fn read_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_array(process, self.pointer_size, at) + } + /// Attaches to a Unity game that is using the IL2CPP backend. This function /// automatically detects the [IL2CPP version](Version). If you know the /// version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs new file mode 100644 index 00000000..ed934dd9 --- /dev/null +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -0,0 +1,87 @@ +//! Parity tests for the managed readers through the IL2CPP module. The +//! implementation is shared, so this pins the mirror surface and the one +//! behavior whose rationale is IL2CPP's: the full-width length judgment. + +use super::{IL2CPPOffsets, Module, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x40_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +fn image() -> Vec { + let mut i = vec![0; 0x1000]; + + ptr(&mut i, 0x0, BASE + 0x100); + put(&mut i, 0x100 + 0x10, &4_i32.to_le_bytes()); + for (index, unit) in "Loop".encode_utf16().enumerate() { + put(&mut i, 0x100 + 0x14 + 2 * index as u64, &unit.to_le_bytes()); + } + + // An array whose length slot carries garbage above the low u32. IL2CPP's + // length really is pointer-sized, so the whole slot judges the claim. + ptr(&mut i, 0x8, BASE + 0x200); + put(&mut i, 0x200 + 0x18, &0x1_0000_0003_u64.to_le_bytes()); + + ptr(&mut i, 0x10, BASE + 0x300); + put(&mut i, 0x300 + 0x18, &2_u64.to_le_bytes()); + put(&mut i, 0x300 + 0x20, &11_u32.to_le_bytes()); + put(&mut i, 0x300 + 0x24, &22_u32.to_le_bytes()); + + i +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + let module = Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version: Version::V2022, + offsets: IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + }; + test(process, &module); + }); +} + +#[test] +fn strings_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_string::<8>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Loop")); + }); +} + +#[test] +fn arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x10)) + .unwrap(); + assert_eq!(read.as_slice(), [11, 22]); + }); +} + +// A length whose low u32 reads small but whose full width does not is +// garbage, not a small array. An i32 read here would wrongly succeed. +#[test] +fn array_lengths_judge_at_full_width() { + on_fixture(|process, module| { + assert!(module + .read_array::(process, Address::new(BASE + 0x8)) + .is_err()); + }); +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 2d12b034..18141022 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -8,12 +8,16 @@ mod cursor; mod pointer; +mod readers; mod runtime; +mod string; mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; +pub use readers::{read_array, read_string}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; +pub use string::ManagedString; pub use walk::Walk; use crate::{string::ArrayCString, Address, PointerSize, Process}; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs new file mode 100644 index 00000000..69882223 --- /dev/null +++ b/src/game_engine/unity/managed/readers.rs @@ -0,0 +1,87 @@ +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; +use core::mem::MaybeUninit; + +use super::ManagedString; +use crate::{Address, Error, PointerSize, Process}; + +/// How many bytes a managed object's two header words occupy. The readers +/// skip them; nothing in them is read. +const fn object_header(pointer_size: PointerSize) -> u64 { + 2 * pointer_size as u64 +} + +/// Reads a managed string through the reference stored at the given address. +/// The layout is runtime ABI, shared by both runtimes at both widths: the +/// character count as an i32 past the object header, the UTF-16 characters +/// inline behind it. The count is the string's length, so a nul character +/// inside the string is kept like any other. +pub fn read_string( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let count = process.read::(object + header)?; + + // The buffer size is the bound past which a claimed count is nonsense: a + // torn or garbage read claims billions, and something has to refuse + // before reading it. Refusal, never truncation. + let count = usize::try_from(count) + .ok() + .filter(|&count| count <= N) + .ok_or(Error {})?; + + let mut units = [0_u16; N]; + let characters = &mut bytemuck::cast_slice_mut::(&mut units)[..2 * count]; + process.read_into_slice(object + header + 4, characters)?; + + ManagedString::from_units(&units[..count]).ok_or(Error {}) +} + +/// Reads a managed array of value elements through the reference stored at +/// the given address. The layout is runtime ABI: past the object header sit +/// the bounds word, the length, and the elements inline. +pub fn read_array( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + + // The length judges at pointer width before any narrowing, so garbage + // that a narrower read would truncate small still refuses. Only IL2CPP's + // length is truly pointer-sized; 64-bit mono stores a u32 whose zeroed + // padding reads the same value through the wide slot. + let length = process + .read_pointer(object + header + pointer_size as u64, pointer_size)? + .value(); + let length = usize::try_from(length) + .ok() + .filter(|&length| length <= N) + .ok_or(Error {})?; + + let mut elements = [const { MaybeUninit::::uninit() }; N]; + let elements = process.read_into_uninit_slice( + object + header + 2 * pointer_size as u64, + &mut elements[..length], + )?; + + let mut array = ArrayVec::new(); + array + .try_extend_from_slice(elements) + .map_err(|_| Error {})?; + Ok(array) +} diff --git a/src/game_engine/unity/managed/string.rs b/src/game_engine/unity/managed/string.rs new file mode 100644 index 00000000..64472810 --- /dev/null +++ b/src/game_engine/unity/managed/string.rs @@ -0,0 +1,78 @@ +use arrayvec::ArrayVec; +use core::ops; + +/// A managed string read out of a game: the UTF-16 units of a +/// `System.String`, as many as the string counts. A nul unit is a character +/// like any other, and an unpaired surrogate stays as it was read. `N` bounds +/// how many units the string holds. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ManagedString(ArrayVec); + +impl ManagedString { + /// Builds a string from its units. More than `N` units is `None`. + pub(crate) fn from_units(units: &[u16]) -> Option { + let mut string = ArrayVec::new(); + string.try_extend_from_slice(units).ok()?; + Some(Self(string)) + } + + /// Returns every unit of the string. + pub fn as_slice(&self) -> &[u16] { + self.0.as_slice() + } + + /// Returns how many units the string holds. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether the string holds no units. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Checks whether the string is exactly the given units. + pub fn matches(&self, text: impl AsRef<[u16]>) -> bool { + self.as_slice() == text.as_ref() + } + + /// Checks whether the string is exactly the given text. This re-encodes + /// the text to UTF-16 as it compares, which is slower than + /// [`matches`](Self::matches). + pub fn matches_str(&self, text: &str) -> bool { + self.0.iter().copied().eq(text.encode_utf16()) + } +} + +impl ops::Deref for ManagedString { + type Target = [u16]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +#[cfg(test)] +mod tests { + use super::ManagedString; + + #[test] + fn compares_by_every_unit() { + let with_nul = ManagedString::<4>::from_units(&[b'a' as u16, 0, b'b' as u16]).unwrap(); + let a = ManagedString::<4>::from_units(&[b'a' as u16]).unwrap(); + assert_eq!(with_nul.len(), 3); + assert!(!with_nul.is_empty()); + assert_ne!(with_nul, a); + assert!(!with_nul.matches_str("a")); + assert!(with_nul.matches_str("a\0b")); + assert!(with_nul.matches([b'a' as u16, 0, b'b' as u16])); + assert!(a.matches_str("a")); + assert_eq!(with_nul[2], b'b' as u16); + } + + #[test] + fn refuses_more_units_than_it_holds() { + assert!(ManagedString::<2>::from_units(&[1, 2, 3]).is_none()); + assert!(ManagedString::<2>::from_units(&[]).unwrap().is_empty()); + } +} diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index 1e221459..c4490add 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -85,6 +85,7 @@ pub mod il2cpp; mod managed; +pub use managed::ManagedString; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index e0c61852..c765633d 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,12 +3,15 @@ #[cfg(feature = "alloc")] use crate::file_format::macho; +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::{elf, pe}, future::retry, print_limited, signature::Signature, - Address, Address32, PointerSize, Process, + Address, Address32, Error, PointerSize, Process, }; mod builds; @@ -23,9 +26,11 @@ pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{managed, BinaryFormat}; +use super::{managed, BinaryFormat, ManagedString}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -329,6 +334,38 @@ impl Module { self.get_image(process, "Assembly-CSharp") } + /// Reads a managed string through the reference stored at the given + /// address, such as the end of a pointer path or a slot in a static + /// table. The string carries its own character count, so no length is + /// passed, and the returned [`ManagedString`] holds exactly that many + /// UTF-16 units, a nul character among them like any other. `N` bounds + /// how many units the string holds, and a string claiming more than that + /// fails rather than truncates, as do a negative count and a null + /// reference. + pub fn read_string( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_string(process, self.pointer_size, at) + } + + /// Reads a managed array of value elements through the reference stored + /// at the given address. The array carries its own length, so no count + /// is passed; `N` bounds how many elements the returned [`ArrayVec`] + /// holds, and an array claiming more than that fails rather than + /// truncates, as does a null reference. The element type is the caller's + /// claim and has to match the target's own element layout: a managed + /// `char` is a `u16` here, a `bool` a single byte, and Rust's `char` and + /// `usize` never match. Reference elements have no portable claim. + pub fn read_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_array(process, self.pointer_size, at) + } + /// Attaches to a Unity game that is using the standard Mono backend. This /// function automatically detects the [Mono version](Version). If you /// know the version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs new file mode 100644 index 00000000..e4d7f72a --- /dev/null +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -0,0 +1,207 @@ +//! Tests pinning the managed readers over hand-laid string and array +//! objects. The readers are pointer-size ABI, not walk work, so the fixtures +//! are tiny blobs rather than the walk's class fixtures. + +use super::{BinaryFormat, Module, MonoOffsets, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x30_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +fn utf16(image: &mut [u8], at: u64, text: &str) { + for (index, unit) in text.encode_utf16().enumerate() { + put(image, at + 2 * index as u64, &unit.to_le_bytes()); + } +} + +// String and array objects at the 64-bit layout: two header words, then the +// string's i32 character count and inline UTF-16 characters, or the array's +// bounds word, length, and inline elements. Each slot at the front holds one +// reference the readers dereference. +fn image() -> Vec { + let mut i = vec![0; 0x1000]; + + ptr(&mut i, 0x0, BASE + 0x100); // a healthy string + ptr(&mut i, 0x8, BASE + 0x200); // one claiming more than a buffer holds + ptr(&mut i, 0x10, BASE + 0x300); // one claiming a negative count + ptr(&mut i, 0x18, 0); // a null reference + ptr(&mut i, 0x20, BASE + 0x400); // a string with a nul inside + ptr(&mut i, 0x28, BASE + 0x500); // a healthy i32 array + ptr(&mut i, 0x30, BASE + 0x600); // an array claiming more than a buffer holds + ptr(&mut i, 0x38, BASE + 0x700); // a u16 array + + put(&mut i, 0x100 + 0x10, &9_i32.to_le_bytes()); + utf16(&mut i, 0x100 + 0x14, "Chapter 3"); + + put(&mut i, 0x200 + 0x10, &64_i32.to_le_bytes()); + put(&mut i, 0x300 + 0x10, &(-1_i32).to_le_bytes()); + + // A managed string may hold a nul character; its count says where it + // ends. + put(&mut i, 0x400 + 0x10, &3_i32.to_le_bytes()); + for (index, unit) in [b'a' as u16, 0, b'b' as u16].into_iter().enumerate() { + put(&mut i, 0x400 + 0x14 + 2 * index as u64, &unit.to_le_bytes()); + } + + // Mono stores the length as a u32 the allocator's zeroing pads to the + // pointer-wide slot the reader judges, which is what these bytes lay. + put(&mut i, 0x500 + 0x18, &3_u32.to_le_bytes()); + for (index, value) in [7_i32, 8, 9].into_iter().enumerate() { + put( + &mut i, + 0x500 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + put(&mut i, 0x600 + 0x18, &64_u32.to_le_bytes()); + + put(&mut i, 0x700 + 0x18, &5_u32.to_le_bytes()); + utf16(&mut i, 0x700 + 0x20, "melon"); + + i +} + +fn module(pointer_size: PointerSize) -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets: MonoOffsets::new(Version::V2, pointer_size, BinaryFormat::PE).unwrap(), + pointer_size, + } +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module(PointerSize::Bit64)); + }); +} + +#[test] +fn strings_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_string::<16>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Chapter 3")); + }); +} + +// The buffer size is the bound past which a claimed count is nonsense: a +// torn read claims billions, and refusing beats truncating. +// A nul character inside a managed string is a character like any other. +// The read keeps the runtime's count, so every unit stays reachable, and the +// string is not "a" just because a nul follows the a. +#[test] +fn strings_keep_a_nul_inside() { + on_fixture(|process, module| { + let read = module + .read_string::<16>(process, Address::new(BASE + 0x20)) + .unwrap(); + assert_eq!(read.len(), 3); + assert_eq!(read.as_slice(), [b'a' as u16, 0, b'b' as u16]); + assert!(read.matches_str("a\0b")); + assert!(read.matches([b'a' as u16, 0, b'b' as u16])); + assert!(!read.matches_str("a")); + assert!(!read.matches([b'a' as u16])); + assert_ne!( + read, + module + .read_string::<16>(process, Address::new(BASE)) + .unwrap() + ); + }); +} + +#[test] +fn string_counts_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x8)) + .is_err()); + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x10)) + .is_err()); + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +#[test] +fn arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x28)) + .unwrap(); + assert_eq!(read.as_slice(), [7, 8, 9]); + assert_eq!(read.len(), 3); + assert_eq!(read[1], 8); + assert_eq!(read.as_slice(), [7, 8, 9]); + assert_ne!(read.as_slice(), [7, 8]); + }); +} + +// The element type is the caller's claim; a u16 claim strides a managed +// char array correctly. +#[test] +fn array_elements_stride_by_their_claimed_type() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x38)) + .unwrap(); + let melon: Vec = "melon".encode_utf16().collect(); + assert_eq!(read.as_slice(), melon); + }); +} + +#[test] +fn array_lengths_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_array::(process, Address::new(BASE + 0x30)) + .is_err()); + assert!(module + .read_array::(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +// The 32-bit layout halves the header, the reference width, and the length +// slot. +#[test] +fn readers_resolve_on_32_bit_targets() { + let mut i = vec![0; 0x1000]; + put(&mut i, 0x0, &((BASE + 0x100) as u32).to_le_bytes()); + put(&mut i, 0x100 + 0x8, &5_i32.to_le_bytes()); + utf16(&mut i, 0x100 + 0xC, "Ridge"); + put(&mut i, 0x8, &((BASE + 0x200) as u32).to_le_bytes()); + put(&mut i, 0x200 + 0xC, &2_u32.to_le_bytes()); + put(&mut i, 0x200 + 0x10, &21_i32.to_le_bytes()); + put(&mut i, 0x200 + 0x14, &22_i32.to_le_bytes()); + + with_process(&[(BASE, &i)], |process| { + let module = module(PointerSize::Bit32); + let read = module + .read_string::<8>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Ridge")); + + let read = module + .read_array::(process, Address::new(BASE + 0x8)) + .unwrap(); + assert_eq!(read.as_slice(), [21, 22]); + }); +}