-
-
Notifications
You must be signed in to change notification settings - Fork 14.5k
Description
Overview
I work on a downstream fork that tries to add Pointer Authentication Code (PAC) support for external C calls to the Rust compiler. PAC is a hardware security feature introduced by ARM in ARMv8.3. It works by adding a cryptographic signature to pointers and verifying that signature before the pointers are used. This is made possible because the actual address space in 64-bit architectures is less than 64 bits, leaving some upper bits unused, free to be repurposed as PAC data holders.
At a high level, PAC is primarily concerned with attacks on pointers to code that can be modified (i.e., writable pointers). This creates a need to sign those pointers at the time of their creation and subsequently authenticate them at the time of use. The sign/authenticate pair guarantees that the pointer has not been substituted in memory between its creation and use.
PAC in LLVM
To give a concrete model of how to think about PAC, in LLVM there is a clear split between:
- places or operations needing protection. These include return instructions, indirect function calls and storing function pointers (local/global values, init/fini arrays, v-table entries),
- and how pointer metadata is represented. Both for signing and authenticating (see the sample below).
For the sake of simplicity, I am ignoring intrinsics and a detailed discussion of keys, modifiers, and different discriminators. It is sufficient to understand that the previously mentioned split between signing and authenticating pointers is also present in LLVM and that there are different ways to represent the metadata, based on the use. Consider the following IR:
store ptr ptrauth (ptr @add, i32 0, i64 0), ptr %0, align 8
%fn_ptr_add = load ptr, ptr %0, align 8, !nonnull !4, !noundef !4
%res = call noundef i32 %fn_ptr_add(i32 noundef 39, i32 noundef 3) #8 [ "ptrauth"(i32 0, i64 0) ]
The sample performs:
- signed store using
ptrauthconstant expression (internally it is wrapped inConstantPtrAuth), wherei32 0, i64 0is signing metadata, - ordinary load,
- an indirect call with
"ptrauth"(i32 0, i64 0)operand bundle. The bundle tells us that prior to using%fn_ptr_add, as a branch target for the call, it must be authenticated using the metadata provided (i32 0, i64 0). Metadata used for authentication must match that used when the pointer was signed.
This could be written more expressively as the following pseudo-IR:
%signed_ptr = sign(@add, key=0, discriminator=0)
store %signed_ptr into %0
%fn_ptr_add = load ptr, ptr %0
%fp_add_authed = auth(%fn_ptr_add, key=0, discriminator=0)
call %fn_ptr_add_authed(39, 3)Signing and authentication
The actual signing of addresses varies and can be performed by the dynamic loader (for instance, when dealing with constant initializers) or via intrinsic calls using pointer metadata.
Pointer authentication mostly focuses on indirect calls (I am deliberately skipping return address authentication, as we get it for free from the backend, and other cases to keep the picture clear). The distinction is crucial because, for direct calls, the destination is baked into the instruction and cannot be altered.
Indirect calls have PAC metadata attached (see the operand bundle above), and the code ensures that the signed pointer value can be authenticated using the key and discriminator values provided in the metadata. It is crucial that the signing and authentication contract is preserved. If a function pointer is only signed and not authenticated, then the program will fail at the point of a call, because the upper bits of the pointer will contain additional PAC information (non-address-related) and will therefore point to an invalid memory location. On the other hand, attempting to authenticate an unsigned pointer also results in failure, similar to authenticating a corrupted pointer or using non-matching metadata.
PAC in Rust
Keeping in mind the objective of this work: PAC support for external C calls that allows Rust code to use PAC-enabled C routines, it ultimately comes down to properly handling indirect calls, which consist of:
- signing function pointers at the time of their creation,
- followed by adding authentication bundle to the indirect calls.
This is the same idea as what was done by Oskar in his proposed patch to add support for arm64e intrinsics.
Operand bundle
Decorating indirect calls is relatively simple and mostly handled in LLVM via a wrapper, that creates operand bundle for a call.
ConstantPtrAuth expression
Signing pointers proves to be more challenging. The logic revolves around the split between get_fn and get_fn_addr: the former represents a function object, either obtained through LLVM's getOrInsertFunction/getNamedValue or retrieved from a cache, while the latter uses the function's address. This approach is somewhat problematic. First, in LLVM IR, functions are themselves pointers. In rustc LLVM codegen the implementation of both get_fn and get_fn_addr also is identical. This is possible due to type aliasing, which aliases both Function and Value to &'ll Value. I am also not entirely sure how strict the split in Rust code base is. But, signing function pointers in get_fn_addr results in the logic being applied to too many use
cases, critically, including signing direct calls. Furthermore as it is an interface function, also called from outside of rustc LLVM codegen, some callsites are too far removed from the logic in rustc LLVM codegen to be able to access contextual information. For example when codegening a call through codegen_call_terminator we end up signing direct calls:
extern "C" {
fn rand() -> i32;
}
fn main() {
unsafe {
rand();
}
}define hidden void @_RNvCsj1Vs3SqQY5P_6simple4main() unnamed_addr #0 {
start:
%_1 = tail call noundef i32 ptrauth (ptr @rand, i32 0)() #7
ret void
}Note: fore each Rust snippet used here, I'm providing a C equivalent that shows expected IR (hidden in <details> for brevity).
Details
extern int rand(void);
int main(void) {
rand();
return 0;
}define dso_local noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i32 @rand() #2
ret i32 0
}This use case seems to be a clear-cut situation: a call is constructed, even though get_fn_addr is requested, it will always result in a direct call, which needs no signing.
It can get a bit trickier, though. Another example where get_fn_addr is used is when rustc SSA codegen tries to perform a function object to pointer coercion. In this case, we lack the contextual information about how the coerced pointer will be used. Consider two samples extracted from auxvec:
- creating a local variable initialized with a coerced pointer:
use std::hint::black_box;
extern crate libc;
type F = unsafe extern "C" fn(libc::c_ulong) -> libc::c_ulong;
fn main() {
let ffi_getauxval: F = libc::getauxval;
black_box(ffi_getauxval);
}This correctly compiles to a signed store:
define hidden void @_RNvCshcvSqA7SVfR_12rvalue_local4main() unnamed_addr #0 {
start:
%0 = alloca [8 x i8], align 8
call void @llvm.lifetime.start.p0(i64 8, ptr nonnull %0)
store ptr ptrauth (ptr @getauxval, i32 0), ptr %0, align 8
call void asm sideeffect "", "r,~{memory}"(ptr nonnull %0) #7, !srcloc !3
call void @llvm.lifetime.end.p0(i64 8, ptr nonnull %0)
ret void
}Details
#include <sys/auxv.h>
typedef unsigned long (*F)(unsigned long);
static inline void black_box(const void *ptr) {
asm volatile("" : : "r"(ptr) : "memory");
}
int main(void) {
F ffi_getauxval = getauxval;
black_box((void*)ffi_getauxval);
return 0;
}define dso_local noundef i32 @main() local_unnamed_addr #0 {
tail call void asm sideeffect "", "r,~{memory}"(ptr nonnull ptrauth (ptr @getauxval, i32 0)) #2
ret i32 0
}- versus: a call through a coerced pointer:
extern crate libc;
type F = unsafe extern "C" fn(libc::c_ulong) -> libc::c_ulong;
fn main() {
let ffi_getauxval: F = libc::getauxval;
unsafe { ffi_getauxval(145 as libc::c_ulong) as usize };
}In this case, the use of ptrauth constant wrapping @getauxvsl is a miscompilation - as the compiler wants to perform a direct call through a signed pointer. A call for which no bundle is provided (correctly).
define hidden void @_RNvCs6R4oPz8FX6Y_11rvalue_call4main() unnamed_addr #0 {
start:
%_2 = tail call noundef i64 ptrauth (ptr @getauxval, i32 0)(i64 noundef 145) #7
ret void
}It should instead be a direct non PAC-enabled call:
%_2 = tail call noundef i64 @getauxval(i64 noundef 145) #7Details
#include <sys/auxv.h>
typedef unsigned long (*F)(unsigned long);
int main(void) {
F ffi_getauxval = getauxval;
unsigned long result = ffi_getauxval((unsigned long)145);
return 0;
}define dso_local noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i64 @getauxval(i64 noundef 145) #2
ret i32 0
}Both cases go through the same arm of mir::CastKind::PointerCoercion(PointerCoercion::ReifyFnPointer, ...)
Correct location for signing pointers
I would like to get some opinions on how best to handle signing of function pointers, and how to tackle the fact that contextual information is distant from the use sites. It does not seem trivial, given that the bulk of APIs are shared across multiple codegens. get_fn_addr intuitively seems like a good fit, a centralised point which is responsible for creating function pointers, that approach would save us from having to always keep track of use cases, and potentially missing some.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status