Rust Learning from Zero (21) —— All I need is a dlsym | Exploit macOS application with Rust

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.

  1. Compile a .dylib that macOS recognises
  2. Build a Constructor Function that Invokes on dylib Loading
  3. All I need is a dlsym
    1. Find the Symbol You Want
    2. Casting to Function Pointer
  4. 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
    );
}

Leave a Reply

Your email address will not be published. Required fields are marked *

18 + 4 =