It has been a long time since my last reverse engineering on macOS, and that was about Netease Cloud Music.
But I always write that in Objective-C, perhaps I should try something different, let's say, Rust.
- Compile a .dylib that macOS recognises
- Build a Constructor Function that Invokes on dylib Loading
- All I need is a
dlsym
- Compile and Hook
1. Compile a .dylib that macOS recognises
The first step is to tell cargo that what we need is a library
cargo new --lib exploit
And cargo will create these files for us.
exploit ├── Cargo.toml └── src └── lib.rs
However, if we compile this project, the output library has a suffix .rlib
. which suggests that this is a Rust library. Yet we need a dylib that macOS recognises.
Therefore the hint for producing a .dylib
library should be added in Cargo.toml
, which is shown below.
[lib] crate-type = ["dylib"]
This tells cargo out crate type is a dylib
, which satisfies the format requirements of macOS dynamic library.
2. Build a Constructor Function that Invokes on dylib Loading
Usually a static function with __attribute__((constructor))
will be used as an init function if we write dylib in C. On macOS, such function will be save in __DATA
segment, __mod_init_func
section. dyld
will check the existence of this section and if there is one, all registered init functions will be called with argc, argv
.
We can write an init function first~
pub fn init() {}
But this will be compiled to follow Rust calling conventions and its name will be mangled in Rust style. Thus it needs to follow C calling conventions and the name of this function should not be mangled.
#[no_mangle] pub extern "C" fn init() {}
However, this function won't be invoked because it's outside __DATA,__mod_init_func
section.
To achieve our goal, we can declare a static variable with its type as extern "C" fn()
and its value as init
(the address of init
function).
Yet only declare a static variable is far from reaching our target. We need to take advantage of cfg_attr
to put this static variable into __DATA,__mod_init_func
section so that dyld
can detect it. Besides, because we won't call this function in Rust code, to avoid rustc
optimise this function out, #[used]
should attach to this function too.
#[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")] #[used] pub static INITIALIZE: extern "C" fn() = init; #[no_mangle] pub extern "C" fn init() { println!("Rust dylib loaded!"); }
3. All I need is a dlsym
As a matter of fact, I tried to direct link to these objc runtime functions in Rust but failed, include but not limited to objc_getClass
, sel_registerName
, class_getInstanceMethod
and method_setImplementation
.
Well, at least I can link to dlsym
, and that's all I need! The prototype of dlsym
in C is
void* dlsym(void* handle, const char* symbol);
And to link it in Rust, the code we need is shown below.
// it doesn't matter whether it is `*const u8` or `*const libc::c_void` // because a pointer is a pointer, the size is only platform related type CPtr = *const u8; extern { fn dlsym(handle: CPtr, symbol: CPtr) -> CPtr; }
3.1 Find the Symbol You Want
To find a symbol with dlsym
in C code, for example, objc_getClass
, the code below might be a common practice
void *(*objc_getClass)(const char * name) = 0; objc_getClass = (decltype(objc_getClass)) dlsym(RTLD_DEFAULT, "objc_getClass");
Let's implement this step by step in Rust! First of all, RTLD_DEFAULT
is a macro in its header.
#define RTLD_DEFAULT ((void *) -2)
In Rust, such type casting can be done by using as
.
let RTLD_DEFAULT = -2i64 as CPtr;
Secondly, try to find a symbol! Remember that call to external function in Rust is forbidden by default because it is considered to be unsafe code. But if we are sure about what we are doing, it okay to wrap such code in unsafe
block in Rust.
At the first glance, I thought that the code below would be ok.
let RTLD_DEFAULT = -2i64 as CPtr; let objc_getClass = unsafe { dlsym(RTLD_DEFAULT, "objc_getClass".as_ptr()) }; println!("objc_getClass: {:p}", objc_getClass as *const u8);
But dlsym
failed to find this symbol, the result was objc_getClass: 0x0
. I also tried to replace objc_getClass
with _objc_getClass
, nevertheless, it didn't work either.
And thought just came across my mind, the underlying data of Rust str
may not be null-terminated. To verify this, I wrote a simple Rust program.
extern { fn printf(fmt: *const u8, arg1: u64); fn strlen(string: *const u8) -> u64; } fn main() { unsafe { printf( "strlen: %d\n".as_ptr(), strlen("objc_getClass".as_ptr()) ); } }
The output was
strlen: 13 objc_getClass
It seemed that the format string was strlen: %d\nobjc_getClass
! And I changed the format string that passed to printf
to
printf( "strlen: %d\0".as_ptr(), strlen("objc_getClass".as_ptr()) );
Now the output is exactly what we expect, strlen: 13
. This verifies our guess, the Rust str
actually has length information associated with it. When passing Rust str
to C function, it is our responsibility to guarantee the string we passed to foreign language is null-terminated (or you can also pass its length to foreign language).
After fingering this out, the code to get a symbol from Rust becomes
let RTLD_DEFAULT = -2i64 as CPtr; let objc_getClass = unsafe { dlsym(RTLD_DEFAULT, "objc_getClass\0".as_ptr()) }; println!("objc_getClass: {:p}", objc_getClass as *const u8);
3.2 Casting to Function Pointer
It's trivial to cast from void *
to a function pointer like void *(*)(const char *)
in C.
let RTLD_DEFAULT = -2i64 as CPtr; let objc_getClass = unsafe { dlsym(RTLD_DEFAULT, "objc_getClass\0".as_ptr()) }; let objc_getClass = unsafe { std::mem::transmute::<CPtr, extern "C" fn (CPtr) -> CPtr>(objc_getClass) }; println!("objc_getClass: {:p}", objc_getClass as *const u8);
Build and run! The output is objc_getClass: 0x7fff60b339d1
, we find objc_getClass
in Rust with the help of dlsym
!
The only problem is that the code works but looks dirty. Luckily, Rust has a very powerful macro system that we can take advantage of. By writing a getSymbol!
macro, we can simplify original code a lot.
type CPtr = *const u8; extern { fn dlsym(handle: CPtr, symbol: CPtr) -> CPtr; } macro_rules! getSymbol { (\$func_name:expr, \$return_ty:ty) => { unsafe { std::mem::transmute::<CPtr, \$return_ty>(dlsym(-2i64 as CPtr, \$func_name.as_ptr())) } }; } #[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")] #[used] pub static INITIALIZE: extern "C" fn() = init; // constructor function #[no_mangle] #[allow(non_snake_case)] pub extern "C" fn init() { let objc_getClass = getSymbol!("objc_getClass\0", extern "C" fn (CPtr) -> CPtr); println!("objc_getClass: {:p}", objc_getClass as *const u8); let sel_registerName = getSymbol!("sel_registerName\0", extern "C" fn (CPtr) -> CPtr); println!("sel_registerName: {:p}", sel_registerName as *const u8); let class_getInstanceMethod = getSymbol!("class_getInstanceMethod\0", extern "C" fn (CPtr, CPtr) -> CPtr); println!("class_getInstanceMethod: {:p}", class_getInstanceMethod as *const u8); let method_setImplementation = getSymbol!("method_setImplementation\0", extern "C" fn (CPtr, CPtr) -> CPtr); println!("method_setImplementation: {:p}", method_setImplementation as *const u8); }
4. Compile and Hook
With above code, we can basically hook any Objective-C class. For instance,
// you may need to add `"libc" = "0.2"` in Cargo.toml extern crate libc; fn hook() -> libc::c_int { 0 } // ... skip a bunch of code ... // constructor function #[no_mangle] #[allow(non_snake_case)] pub extern "C" fn init() { let objc_getClass = getSymbol!("objc_getClass\0", extern "C" fn (CPtr) -> CPtr); let sel_registerName = getSymbol!("sel_registerName\0", extern "C" fn (CPtr) -> CPtr); let class_getInstanceMethod = getSymbol!("class_getInstanceMethod\0", extern "C" fn (CPtr, CPtr) -> CPtr); let method_setImplementation = getSymbol!("method_setImplementation\0", extern "C" fn (CPtr, CPtr) -> CPtr); method_setImplementation( class_getInstanceMethod( objc_getClass("TheClassNameYouWantToHook\0".as_ptr()), sel_registerName("ItsInstanceMethodName\0".as_ptr())), hook as CPtr ); }