ENOSUCHBLOG

Programming, philosophy, pedaling.


Function interposition in Rust with upgrayedd

Nov 19, 2023     Tags: devblog, programming, rust, security    


Yet another announcement-type post, this time for a small Rust library I hacked up while trying to deduplicate some boilerplate in another project: upgrayedd.

This is what using upgrayedd looks like:

1
2
3
4
5
6
7
8
use upgrayedd::upgrayedd;

#[upgrayedd]
fn X509_VERIFY_PARAM_set_auth_level(param: *mut std::ffi::c_void, level: std::ffi::c_int) {
    eprintln!("before!");
    unsafe { upgrayedd(param, level) };
    eprintln!("after!");
}

If you build that in a crate with crate-type = ["cdylib"], then you can do this:

1
2
3
# libfunkycrypto is whatever your crate's shared object build target is
LD_PRELOAD=./libfunkycrypto.so \
    curl --silent https://example.com --ciphers DEFAULT@SECLEVEL=2

…to run custom code before and after each call to OpenSSL’s X509_VERIFY_PARAM_set_auth_level.

The rest of this post will be a brief introduction to (dynamic) function interposition and how it works, upgrayedd’s implementation details, and what it can (and can’t) be used for.

(Dynamic) function interposition

Function interposition is a basic program instrumentation technique: to measure, detect, or modify the use of a function in a program, we replace calls to that function with calls to a function that we (the instrumenter) control. The interposed (“wrapper”) function can then monitor (and rewrite) the program’s state, including:

There are many different ways to interpose on a program’s functions, but one of the simplest is the LD_PRELOAD trick:1 when set, the dynamic linker/loader will give precedence to the symbols defined in the specified shared object.

The wrapper can then access the underlying replaced function via real_func = dlsym(RTLD_NEXT, ...), where RTLD_NEXT is a special pseudo-handle that tells dlsym(3) to retrieve the next occurrence of the symbol in the dynamic linker’s search order.

In practice, this means that any dynamic2 function call can be interposed with LD_PRELOAD, including “basic” routines like malloc3.

How upgrayedd works

As hinted above: upgrayedd works through LD_PRELOAD, which isn’t that special.

What does make upgrayedd special is its ability to abstract much of the (error-prone) boilerplate that comes with writing function instrumentation and interposition code.

Compare, for example, the following upgrayedd wrapper on rand(3):

1
2
3
4
5
6
7
use upgrayedd::upgrayedd;

#[upgrayedd(real_rand)]
fn rand() -> std::ffi::c_int {
    // The domain of [0, 42) is random enough.
    (unsafe { real_rand() }) % 42
}

…with its rough equivalent in C:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stddef.h>

#define _GNU_SOURCE  // for RTLD_NEXT
#include <dlfcn.h>

static int (*_real_rand)() = NULL;

int rand() {
  if (!_real_rand) {
    _real_rand = dlsym(RTLD_NEXT, "rand");
  }

  // The domain of [0, 42) is random enough.
  return _real_rand() % 42;
}

The C version is slightly more verbose and significantly more error prone: the function signature is written twice (once for the wrapper, and once for the function pointer containing the target), and the C compiler won’t catch any errors in either.

A naive Rust implementation would have similar problems; upgrayedd mostly sidesteps these by abstracting away each individual step behind its procedural macro.

When expanded, the rand example above looks something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static mut __upgrayedd_target_rand: Option<unsafe extern "C" fn() -> std::ffi::c_int> = None;

#[no_mangle]
#[doc(hidden)]
#[allow(non_snake_case)]
#[export_name = "rand"]
pub unsafe extern "C" fn __upgrayedd_inner_wrapper_rand() -> std::ffi::c_int {
    if __upgrayedd_target_rand.is_none() {
        __upgrayedd_target_rand = std::mem::transmute(
            ::libc::dlsym(::libc::RTLD_NEXT, std::mem::transmute(b"rand\x00".as_ptr())),
        );
    }
    if __upgrayedd_target_rand.is_none() {
        let msg = b"barf: upgrayedd tried to hook something that broke rust's runtime: ";
        ::libc::write(
            ::libc::STDERR_FILENO,
            msg.as_ptr() as *const ::libc::c_void,
            msg.len(),
        );
        ::libc::write(
            ::libc::STDERR_FILENO,
            b"rand".as_ptr() as *const ::libc::c_void,
            b"rand".len(),
        );
        ::libc::write(::libc::STDERR_FILENO, b"\n".as_ptr() as *const ::libc::c_void, 1);
        std::process::abort();
    }
    rand()
}

#[allow(non_snake_case)]
fn rand() -> std::ffi::c_int {
    #[allow(unused_variables)]
    let real_rand = unsafe { __upgrayedd_target_rand.unwrap_unchecked() };
    (unsafe { real_rand() }) % 42
}

…which is a mess to read, but is essentially the same thing as the C version, but with a few extra checks (most notably, a hard abort if dlsym fails to retrieve the target function4).

Limitations

upgrayedd comes with a few caveats beyond the normal limitations of LD_PRELOAD:

Wrapup

Ultimately, upgrayedd is a bikeshed/waypoint towards a larger tool that I’m working on for detecting API misuse. Stay tuned for more news on that.

Despite writing Rust for ~5 years now, this was my first time creating (rather than just modifying) a proc-macro crate.

Developing a procedural macro was an interesting experience: being able to control the generated syntax was both very freeing and also very constraining (the complexity of Rust’s AST is on full display in syn’s APIs, which makes even “trivial” looking transformations8 somewhat complicated). I once again found myself wishing for a macro system like Crystal’s, where a small amount of flexibility is exchanged for substantially simpler “template” style transformations.


  1. On Linux and (most?) BSDs. The same technique also works macOS via DYLD_LIBRARY_PATH, although not with SIP enabled. 

  2. Emphasis on dynamic: this specific technique doesn’t work on static calls, e.g. routines compiled directly into the binary through static linkage. Static function interposition is outside of the scope of this post, but can be done with techniques like static or dynamic binary translation, instruction level tracing, or, for syscalls, something like KRF

  3. Some allocators actually suggest this technique as a relatively simple integration strategy; see for example jemalloc

  4. In principle, this should never really happen. In practice, one of the ways I intend to extend upgrayedd is by allowing it to pre-collect targets in “life before main”, which in turn is less reliable in terms of pre-loaded shared objects and runtime initialization dependencies. 

  5. This is just as true for C; the only real difference is that Rust makes it way easier to introduce a bunch of dependencies that may interfere with your expected interpositions. 

  6. Lots of other things show up in the symbol table: locals, globals, GNUisms like IFUNC

  7. I know about DLL injection; I don’t know if there’s a “blessed correct” way to do it. 

  8. For example: transforming a parameter sequence like foo: u8, bar: u32 into just u8, u32 requires a remarkably large and lossy helper function


Discussions: Reddit Mastodon