Description
There is a kind of common type erasure scheme used in Rust that goes something like this:
/// Invariant: there exists some type `T` such that `data` is actually a `&'a T` and `op`
/// is actually a `fn(&T)`.
struct ErasedTypeAndOp<'a> {
data: *const (),
op: fn(*const ()),
_phantom: PhantomData<&'a ()>,
}
impl<'a> ErasedTypeAndOp<'a> {
pub fn new<T>(data: &'a T, op: fn(&'a T)) {
Self {
data: data as *const T,
op: unsafe { std::mem::transmute(op) },
_phantom: PhantomData,
}
}
pub fn call_op(&self) {
(self.op)(self.data)
}
}
This is used, in particular, in the standard library for type-erased fmt
arguments.
Unfortunately, CFI is not happy with this since the function pointer op
was transmuted, and is not invoked at its original type. Our documented ABI compatibility rules are fine with this (and Miri also won't complain), but CFI is actually more restrictive than those rules and (at least in some configurations) rejects this call since caller and callee do not agree on the type of the argument.
This is not good: ideally we could just tell people they can turn on CFI in any Rust program and expect it to work. In other words, ideally CFI would only reject programs that we consider buggy (in an official Rust lang/opsem sense), since they have either UB or erroneous behavior.
I am not sure what is the best way to fix this. I also know very little about what CFI can and cannot do (and I understand there's actually a bunch of CFI implementations that differ in their capabilities). My completely naive first idea would be to say that we have a new magic primitive type Erased
and then declare ErasedTypeAndOp
as follows:
struct ErasedTypeAndOp<'a> {
data: *const Erased,
op: fn(*const Erased),
_phantom: PhantomData<&'a ()>,
}
Then we say that if caller and callee use different pointer types for their arguments, that is erroneous behavior unless one of them uses the special Erased
pointee type, in which case the call is permitted. (Or maybe only the call site is allowed to use Erased
like that?)
Is that something CFI can do -- basically have pointee type checking "turned off" if the call site uses *const Erased
? If yes, would this be an acceptable trade-off between still rejecting accidental type mismatches (and catching as many attacks as possible) while accepting legitimate type erasure patterns?