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.
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 malloc
3.
upgrayedd
worksAs 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_read() }) % 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).
upgrayedd
comes with a few caveats beyond the normal limitations of
LD_PRELOAD
:
For the time being, it uses Rust’s std
(and more generally, users
may somewhat reasonably expect to be able to use crates or logic that
needs std
in their interpositioned code). As a result, upgrayedd
may not always hook where you expect if mingled inside of a larger
Rust codebase.
Bottom line: use it in small codebases, with minimal dependencies5.
Despite being written in Rust (tm) and exposing a “safe” wrapper
(until you choose to call the underlying target), upgrayedd
is
fundamentally unsafe and cannot be made safe: interposition fundamentally
involves unsafe castings of pointers behind symbols, with no guarantee that
the signature is correct (or that the symbol is even a function6).
Even just declaring an upgrayedd
hook can cause (silent!) memory corruption
if the type or signature behind the symbol is not the expected one. You
should not use it in anything that needs to be stable or secure; its
primary value is in writing instrumentation tooling that stays on a
developer’s workbench.
It’s Linux-only for the moment, for the simple reason of “I haven’t bothered to make it work anywhere else.” It would probably work well on the BSDs with minimal changes, and should work on macOS with SIP disabled; I don’t know if Windows supports this kind of thing7.
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.
On Linux and (most?) BSDs. The same technique also works macOS via DYLD_LIBRARY_PATH
, although not with SIP enabled. ↩
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. ↩
Some allocators actually suggest this technique as a relatively simple integration strategy; see for example jemalloc. ↩
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. ↩
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. ↩
Lots of other things show up in the symbol table: locals, globals, GNUisms like IFUNC
. ↩
I know about DLL injection; I don’t know if there’s a “blessed correct” way to do it. ↩
For example: transforming a parameter sequence like foo: u8, bar: u32
into just u8, u32
requires a remarkably large and lossy helper function. ↩