Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions src/game_engine/unity/il2cpp/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<const N: usize>(
&self,
process: &Process,
at: Address,
) -> Result<ManagedString<N>, 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<T: CheckedBitPattern, const N: usize>(
&self,
process: &Process,
at: Address,
) -> Result<ArrayVec<T, N>, 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
Expand Down
87 changes: 87 additions & 0 deletions src/game_engine/unity/il2cpp/readers_tests.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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::<u32, 4>(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::<i32, 8>(process, Address::new(BASE + 0x8))
.is_err());
});
}
4 changes: 4 additions & 0 deletions src/game_engine/unity/managed/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
87 changes: 87 additions & 0 deletions src/game_engine/unity/managed/readers.rs
Original file line number Diff line number Diff line change
@@ -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<const N: usize>(
process: &Process,
pointer_size: PointerSize,
at: Address,
) -> Result<ManagedString<N>, 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::<i32>(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::<u16, u8>(&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<T: CheckedBitPattern, const N: usize>(
process: &Process,
pointer_size: PointerSize,
at: Address,
) -> Result<ArrayVec<T, N>, 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::<T>::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)
}
78 changes: 78 additions & 0 deletions src/game_engine/unity/managed/string.rs
Original file line number Diff line number Diff line change
@@ -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<const N: usize>(ArrayVec<u16, N>);

impl<const N: usize> ManagedString<N> {
/// Builds a string from its units. More than `N` units is `None`.
pub(crate) fn from_units(units: &[u16]) -> Option<Self> {
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<const N: usize> ops::Deref for ManagedString<N> {
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());
}
}
1 change: 1 addition & 0 deletions src/game_engine/unity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@

pub mod il2cpp;
mod managed;
pub use managed::ManagedString;
pub mod mono;
pub mod scene_manager;

Expand Down
Loading
Loading