You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
What are the opsem concerns around pointer authentication? #624
There is a pointer authentication project goal. Are there any opsem questions we have to answer around pointer authentication, or is the idea that all UB-free Rust programs (including arbitrary UB-free unsafe code) will behave exactly as before?
I haven't yet found a description of pointer authentication that's written for people that don't care about the aarch64 details, everything is exceedingly low-level. The goal owner pointed me to https://llvm.org/docs/PointerAuth.html which is the most comprehensible description I found so far, though what is still missing is any idea when those operations are invoked by a Rust program. So based on that there are some first questions:
It seems like llvm.ptrauth.sign alters the address bits of the pointer. What does that mean for things like dereferenceability? Are both the old and new pointer dereferenceable but point to the same memory? That seems pretty incompatible with our memory model. Does the old pointer become invalid when the intrinsic is called?
Something happens with function pointer calls. Those are apparently also signed. Do we have to worry about introducing new ABI compatibility issues here, similar to what happened with CFI?
It seems like llvm.ptrauth.sign alters the address bits of the pointer. What does that mean for things like dereferenceability? Are both the old and new pointer dereferenceable but point to the same memory? That seems pretty incompatible with our memory model. Does the old pointer become invalid when the intrinsic is called?
Pointer signing adds a tag in the upper 8 bits of the pointer, which were otherwise required to be a sign extension of the remaining 52bit of the address that is actually used by the CPU (or on most CPUs actually only 48bit, with the entire upper 16bit required to be sign extended). When dereferencing a pointer, the compiler will emit an instruction to check that the tag is correct and then strip it. The signed pointer itself can't be directly loaded from at the CPU level, but unsigned pointers can't be loaded from at the Rust AM level. .addr() can strip the tag after authenticating the pointer. .with_addr() could authenticate the old pointer, strip the tag and then sign the new pointer. For exposed provenance, I think we did have to strip the tag and allow creating an arbitrary valid signed pointer. .with_addr() and exposed provenance would hurt effectiveness of pointer authentication as the address could be tampered with while it is not signed.
Something happens with function pointer calls. Those are apparently also signed. Do we have to worry about introducing new ABI compatibility issues here, similar to what happened with CFI?
For arm64 ELF pauth no. Different keys are used for function and data pointers, but otherwise all function pointers use the same key and all data pointers use the same key. Some pointers that should not be accessible for the user like return pointers do use different keys (+ possibly pointer location dependent salt), but that shouldn't matter.
Thanks! That's the kind of stuff I wish the goal would have explained. :)
For arm64 ELF pauth no. Different keys are used for function and data pointers, but otherwise all function pointers use the same key and all data pointers use the same key. Some pointers that should not be accessible for the user like return pointers do use different keys (+ possibly pointer location dependent salt), but that shouldn't matter.
There is a function in rustc that computes a "discriminator" for a function signature, and it takes into account the actual function type. IIUC, not just the key but also the discriminator has to be the same. If fn(T) and fn(Wrap<T>) end up using different discriminators, then transmuting from one to the other would fail, no?
And same for pointer types themselves -- I don't know what the "discriminator" is there, but a *const bool should still be usable after being transmuted to *const u8.
It's also pretty concerning that apparently we need yet another copy of the same logic that computes the actual ABI of a type. And it'll likely be slightly different than all the other copies.
When do we stop accepting such code and insist on a proper refactor instead? (rust-lang/rust#119183)
Yes, you are right. I've addressed both Optoin<T> and the lack of support for repr(transparent) in the PR that contains the other half of this discussion.
There is a function in rustc that computes a "discriminator" for a function signature, and it takes into account the actual function type. IIUC, not just the key but also the discriminator has to be the same. If fn(T) and fn(Wrap) end up using different discriminators, then transmuting from one to the other would fail, no?
I'm not sure if you are worried about the second function using a transparent wrapper around T or a general transmute between different types case.
If it's the former, the bugfix mentioned above should address it (stripping of transparent wrappers).
If it's the latter, then there is a PR in the series that provides function pointer type discrimination support for transmutes. Assuming those two functions are extern "C" (as that is the only ABI that the support is provided for in the series of PRs) and the calculated discriminators do differ, the transmute would trigger a resign of the pointers:
In that example the pointer was transmuted from extern "C" fn(i32) -> i32 discriminator: 2981 to extern "C" fn(i32, i32) -> i32 discriminator: 63165.
And same for pointer types themselves -- I don't know what the "discriminator" is there, but a *const bool should still be usable after being transmuted to *const u8.
The discriminators are only calculated for (extern "C") function pointers.
Yes, you are right. I've addressed both Optoin and the lack of support for repr(transparent) in the rust-lang/rust#159074 that contains the other half of this discussion.
Okay, seems like I understood correctly then. Please have a look at our ABI compatibility rules. It must be the case that whenever types T and U are ABI-compatible, they have the same discriminator for function pointer authentication. Does your discriminator scheme ensure this?
If it's the latter, then there is a rust-lang/rust#159082 in the series that provides function pointer type discrimination support for transmutes. Assuming those two functions are extern "C" (as that is the only ABI that the support is provided for in the series of PRs) and the calculated discriminators do differ, the transmute would trigger a resign of the pointers:
That fix is incomplete, and IMO makes things worse. There are many ways to do a transmute in Rust, they must all be equivalent:
You can call the transmute function
You can store things in a union field of type T and then load from via a different field of type U
You can store something via a raw pointer, cast the pointer, and load it at a different type.
I cannot think of any problem that you could fix by patching the transmute function.
Here is a function that code may use as a drop-in replacement for transmute whenever it wants:
pubunsafefnmy_transmute<T,U>(x:T) -> U{let x = std::mem::ManuallyDrop::new(x);unsafe{(&x as*const std::mem::ManuallyDrop<T>).cast::<U>().read()}}
Your patch sounds like code would behave different when using my_transmute vs the built-in transmute. That would be a soundness bug, we cannot have that happen.
The discriminators are only calculated for (extern "C") function pointers.
So the pointer authentication scheme for data pointers does not use a discriminator / hard-codes it to 0?
That's one big chunk of potential problems less, good. :)
One thing is now clear: transmute should not have semantics that are different from other ways of storing a value as one type and loading it as another. Like you said in the comment above special-casing the transmute intrinsic makes my_transmute observably different from std::mem::transmute.
The root cause for it was the fact that the original design was closely aligned with how Clang handles this for C: godbolt link. That link shows your 3 use cases (assuming my_transmute can be represented as memcpy). In particular, Clang only resigns explicit function pointer casts, while memcpy/union-based type puns stay intact. This led me to implementing resigning specifically for Rust's transmute, as through my inexperience Rust eyes, transmute seemed to be a direct equivalent of explicit function cast in C.
As background, the motivating use case is still function-pointer (extern "C") type punning. One example I'm thinking of is obtaining a type-erased function pointer from something like dlopen/dlsym and then converting it to the function-pointer type needed to perform a call. This is a fairly natural use case in C, although I'm less certain how common this pattern is in Rust.
I'm not yet sure what the proper way of handling possible discriminator transitions and required resigns is. However, reading through your comments suggest basing it on the type change between the store and the load (as opposed to focusing on the operation/mir shape used to perform the conversion). So, at a high level: if a function pointer is stored with one type and subsequently loaded as a different type, that would be the point at which the resign is triggered.
I'd appreciate some guidance on whether this is the right direction, and in particular where in the Rust lowering this information is best accessed. I'm also interested in whether there are any existing invariants or constraints regarding function-pointer type punning that this approach would need to account for.
I'm especially interested in potential pitfalls here, since this seems considerably more involved than my original approach. For example:
cross-function/heap based reinterpretation,
can this spin into implementing full data-flow analysis,
can we always rely on the information being there - what if compiler assumes that type cast is a no-op and removes it,
how to handle divergent control flow involving type change.
Right, I figured this would be C-thinking applied to Rust. :)
In particular, Clang only resigns explicit function pointer casts, while memcpy/union-based type puns stay intact.
FWIW I think this is wrong for C as well, in the sense that there are correct C programs that will error under PAC. Union-based type punning is legal in C. Maybe clang considers it acceptable to throw errors on entirely standards-compliant C programs, but in Rust we generally consider that inacceptable. We are working towards having a rigorously specified semantics that we'll rigorously implement; we can't have built-in sanitizer mechanisms undermine that.
So, at a high level: if a function pointer is stored with one type and subsequently loaded as a different type, that would be the point at which the resign is triggered.
Rust's memory is untyped. Once a value is stored in memory, only the bytes remain, and they can be manipulated as bytes in all sorts of ways before they are eventually reconsistuted to form a value, possibly of a different type, possibly using only some of the original bytes or mixing in bytes from other values. So this is not only completely impractical to implement as you noted, it's not even really well-defined.
One example I'm thinking of is obtaining a type-erased function pointer from something like dlopen/dlsym and then converting it to the function-pointer type needed to perform a call. This is a fairly natural use case in C,
So the problem is that the original pointers returned by dlsym have the wrong signature that doesn't actually match their type? PAC would actually allow me to call them at that wrong type even though it doesn't match the actual type of these functions? (Or do they get signed with a discriminator that said "do not call, type is not known"?)
Yeah that is a problem. The entire point of PAC as I understand it is that a function pointer has its type embedded in its value so it can only be used at that type. That's great but it only works if all parts of the system play along; if dlsym returns incorrectly-signed pointers then the invariant breaks down.
The best I can think of is to add a dedicated resign operation. That still breaks code that uses dlsym if it doesn't get patched, so it'll need some discussion before we stabilize anything. But I'd say PAC semantics are a bit broken here and there is no nice solution.
(The nice solution would be for every symbol to store the discriminator that represents its actual type somewhere, and then dlsym could return a pointer signed with the right type. That would preserve the invariant, and would find bugs where code uses dlsym and casts the function to the wrong type.)
So the problem is that the original pointers returned by dlsym have the wrong signature that doesn't actually match their type? PAC would actually allow me to call them at that wrong type even though it doesn't match the actual type of these functions? (Or do they get signed with a discriminator that said "do not call, type is not known"?)
I probably shouldn't have used dlopen/dlsym as it shows a bit of a special case of what we discuss in here. The reason for it is that it does not return a function pointer, but a raw data pointer (void *). Under type discrimination it will then be treated as signed with IA key with 0-discrimination (essentially unknown, as you say), so the resign goes from 0 to the actual function pointer type.
A more representative case is a dispatch table of type-erased pointers, like OpenSSL's core dispatch. A simplified version of the table here. Entries in the table are signed as void(*)(void), and the cast back resigns to the real type before the call.
Yeah that is a problem. The entire point of PAC as I understand it is that a function pointer has its type embedded in its value so it can only be used at that type. That's great but it only works if all parts of the system play along; if dlsym returns incorrectly-signed pointers then the invariant breaks down.
But, the whole cast (resign) is a "trust-me" operation: it authenticates using the source type and signs with the destination type without checking the function's actual type. So type discrimination guarantees a pointer's signature matches its current static type, not that it can only be called at the function's "true" type. What it defends against is forged or corrupted pointers, not programmer casts.
But why? Under a strict use of PAC I would expect every fn ptr to be signed with its actual type. It's still type-erased in the sense that the static, compiler-visible type is an erased one. But the runtime value of the fn ptr obviously has to reflect the actual runtime location of the function, and then it might as well also reflect its actual runtime type.
But, the whole cast (resign) is a "trust-me" operation: it authenticates using the source type and signs with the destination type without checking the function's actual type. So type discrimination guarantees a pointer's signature matches its current static type, not that it can only be called at the function's "true" type. What it defends against is forged or corrupted pointers, not programmer casts.
I am trying to understand the invariant it upholds to achieve that. The "obvious" invariant in my head is that a function pointer to address A can only ever exist with a signature that matches the actual function that lives at that address. If everything in the system ensures that invariant, then PAC is completely invisible while achieving the goal of defending against fn ptrs ending up in the wrong spot. Some super-low-level code that generates function pointers "out of thin air", like dlsym, needs to be aware, but all regular code, including cursed unsafe code, shouldn't have to care.
If PAC is not meant to be invisible, then you are proposing to change the language itself in a way that everyone who wants to reason about Rust code now has to be aware of. Or crates need to opt-in to supporting PAC.
What we want to avoid is a situation where a crate is entirely correct according to the general, portable rules of Rust that we document, but then somehow fails under PAC. That would make PAC-Rust a language dialect (basically, a fork), and language dialects are extremely undesirable. We have been discussing for years how to deal with the problem that CHERI breaks some valid Rust programs so it's technically a dialect; PAC might have to become part of that same discussion then.
No lang or opsem involvement was requested for this project goal, which means it really shouldn't involve any sort of language dialect. If this is more than just "Rust with better UB detection" (that's what normal sanitizers are and what I hope PAC could/should be -- turn some cases of UB into aborts to make it harder to exploit UB bugs), then we need to re-evaluate who needs to be involved in the goal.
It looks like there is some confusion of what pointer authentication (PAuth) is, what are the intentions, etc. So I hope that the long text could clarify few things.
First thing – PAuth is not a yet another sanitizer. It is not intended to catch undefined behavior, fix programs, uncover bugs, etc. While PAuth can potentially help to uncover some existing examples of undefined behavior, it is entirely a side effect. What PAuth really is a compiler- and hardware-assisted security mitigation approach. As with any mitigation approaches there are some assumptions, aims and threat model. For PAuth we are aiming to provide protection against pointer substitutions where one function (or critical data pointer is used instead of another one. The main source of such substitutions are ROP / JOP kinds of attacks where the set of existing code pieces form the sequence of gadgets that does whatever would be required to perform malicious actions.
The intention is to have all important pointers to be protected with additional provenance carried together with the pointer and is checked when pointer is used (dereferenced / indirect function call is made) Clearly, the checks must be somehow usage-specific as otherwise we can just substitute one "protected" pointer with the another one. To do this some variability is assumed:
Hardware is attaching a HMAC of a pointer itself. Keys are assumed unknown to attacker and protected by the hardware
There is an additional salt ("discriminator") incorporated. The value could be a known constant, or storage address of a pointer, or both.
Note that HMAC above is an implementation detail (as well as where this signature is stored). It could be something else provided that we can achieve the similar result. E.g. @kovdan01 played with pointer encryption / description in his PhD and it worked just fine within PAuth framework.
Compiler role here is quite important:
Compute discriminator values depending on the intended usage
Emit signing code in limited cases when we need to sign a raw unprotected pointer (there are no such cases in C and there are very some limited cases in C++)
Emit authentication code when the protected pointer is being used (dereferenced / indirect call is made)
Emit combined resigning (auth + sign) code when protected pointer itself is casted. There is nothing wrong with pointer casts and type punning by itself. But of course calling function via casted pointer with incompatible signature is an UB.
Currently clang defines a set of C/C++ language pauth ABIs. Due to some historical (and not only – more on this below) reasons, there is no single C/++ pauth ABI. Instead, there are some set of features (it is called a "signing scheme") where it is specified which pointers are signed and how. The most important thing here is that each signing scheme is a completely new and incompatible ABI. These features do not "stack" on top each other, the "producer" of signed pointers and "consumer" must fully agree on every single detail.
To keep things simple I will talk only about C side of things below. C++ is more complex :) In C function pointers are always trivially copyable, so one can copy them using memset. This means we cannot use storage address of a function pointer as part of its discriminator. So, the only thing that is left is the function type itself. Now we have to deal with the reality: there are lots of real-world programs that e.g. call function int(*)(void) via void(*)(void) pointer ignoring the result. And everything "just works" while of course technically this is an undefined behavior. As a result to this, by default for plain "C style" function pointers zero discriminator value is used and type is ignored in its entirety. This is was done essentially to overcome "PAuth crashed my program" situation. OpenSSL example above is exactly such situation :)
There are not so many different "kinds" of function pointers in C actually. Besides plain function pointers we're also having pointers to __attribute__((constructor)) functions captured by the compiler, some other things due to way GOT is implemented if GOT is not relro and here the list if likely exhausted.
In some cases, where the usage of function pointers is safe, one can use type discrimination for function pointers. Here the type of the function itself is used as discriminator value. There are few important notes here:
Enabling function pointer type type discrimination creates new incompatible ABI. One cannot mix-and-match zero-discriminated pointers with type-discriminated ones.
Compiler must emit resigns (auth + sign) every time function pointer is casted to another type. So, this means that cast incurs some non-negligible overhead and might even have some side effects.
But in general, PAuth should be transparent to the application. Every semantically correct and standard-compliant C/C++ application could just have PAuth enabled and run just fine. We know that some platforms actually have 2 separate ABIs: one for "type safe" code with function pointer type type discrimination enabled and zero-discriminated ABI for "everything else".
When we started that "PAuth in Rust" project we thought about 3 separate steps of this project:
Providing interop with PAuth-enabled C code
Providing interop with PAuth-enabled C++ code (this seems to be something we're having for free due to way how cxx.rs is implemented, which is good)
Providing PAuth for Rust itself
I was hoping that only at 3. we will need to start discussing language-specific semantics, define "Rust PAuth ABI", etc. But looks like I was wrong as preserving function pointer type provenance through possible valid casts is required for type discriminated function pointers, so we need indeed discuss proper semantics here...
I am trying to understand the invariant it upholds to achieve that. The "obvious" invariant in my head is that a function pointer to address A can only ever exist with a signature that matches the actual function that lives at that address. If everything in the system ensures that invariant, then PAC is completely invisible while achieving the goal of defending against fn ptrs ending up in the wrong spot. Some super-low-level code that generates function pointers "out of thin air", like dlsym, needs to be aware, but all regular code, including cursed unsafe code, shouldn't have to care.
Well, this invariant for sure entirely disallows function pointers casts even if they roundtrip to the correct type? E.g. we should allow the following sequence of pointer casts for the function having type A: A -> B -> A. In C / C++ world one can e.g. consider type erasure via casting to void*. Indeed, allowing arbitrary void* -> A cast could be considered a possible weakness, but we cannot prohibit it provided that the input pointer is correctly signed as void*.
We have been discussing for years how to deal with the problem that CHERI breaks some valid Rust programs so it's technically a dialect; PAC might have to become part of that same discussion then.
Do you have an example what will be invalid with CHERI?
I am trying to understand the invariant it upholds to achieve that. The "obvious" invariant in my head is that a function pointer to address A can only ever exist with a signature that matches the actual function that lives at that address. If everything in the system ensures that invariant, then PAC is completely invisible while achieving the goal of defending against fn ptrs ending up in the wrong spot. Some super-low-level code that generates function pointers "out of thin air", like dlsym, needs to be aware, but all regular code, including cursed unsafe code, shouldn't have to care.
Well, this invariant for sure entirely disallows function pointers casts even if they roundtrip to the correct type? E.g. we should allow the following sequence of pointer casts for the function having type A: A -> B -> A. In C / C++ world one can e.g. consider type erasure via casting to void*. Indeed, allowing arbitrary void* -> A cast could be considered a possible weakness, but we cannot prohibit it provided that the input pointer is correctly signed as void*.
The invariant as Ralf stated it says that the signature has to match the actual function (pointee), not that it has to match the function pointer type it's currently being stored in. This would allow roundtrip casts.
(Honestly I'm shocked to learn that -fptrauth-function-pointer-type-discrimination resigns on casts, as opposed to just retaining the original signature and expecting the function pointer to be cast back before calling.)
It was described as what I would call a sanitizer in the project goal:
This mechanism helps
prevent attacks that attempt to modify or substitute writable pointers, such as
return addresses or function pointers.
That's what a sanitizer is: adding extra "stuff" to the execution to stop the program when there is UB. Sometimes this is done in order to find bugs, sometimes to prevent attacks (the kind of attacks we are talking about here all begin by causing UB), but that difference doesn't really matter when we are evaluating the opsem effects of a sanitizer.
I view things like stack canaries also as a form of sanitizer. It turns some instances of UB into "safe" aborts. It doesn't affect the execution of UB-free programs.
Maybe we are just using this term in a slightly different way. If there's a different term I should use for this family of mechanisms, let me know. :)
But in general, PAuth should be transparent to the application. Every semantically correct and standard-compliant C/C++ application could just have PAuth enabled and run just fine.
Based on what was explained above, that is not correct. I could load a function via dlsym, then cast it via union type punning, and then PAuth will abort my program because it didn't see the cast and thinks I am using the wrong type.
But looks like I was wrong as preserving function pointer type provenance through possible valid casts is required for type discriminated function pointers, so we need indeed discuss proper semantics here...
I assume you don't mean provenance here? Everything discussed above sounds very different from provenance. I guess you mean the general English meaning of the word, not the specific technical meaning it has in Rust (and LLVM and C/C++).
Well, this invariant for sure entirely disallows function pointers casts even if they roundtrip to the correct type?
No, it would explicitly allow round-trips. It would disallow non-roundtrips! If you cast from A to B to C and C is "sufficiently different" from A (as encoded in the discriminator), the call will fail. if you cast to A' which has the same discriminator as A, the call will succeed.
Do you have an example what will be invalid with CHERI?
I don't want to distract this discussion. Ask me on Zulip.
EDIT: I made an issue: #625
That's what a sanitizer is: adding extra "stuff" to the execution to stop the program when there is UB. Sometimes this is done in order to find bugs, sometimes to prevent attacks (the kind of attacks we are talking about here all begin by causing UB), but that difference doesn't really matter when we are evaluating the opsem effects of a sanitizer.
The program might not have an UB, but still be vulnerable to substitution attacks.
I assume you don't mean provenance here? Everything discussed above sounds very different from provenance. I guess you mean the general English meaning of the word, not the specific technical meaning it has in Rust (and LLVM and C/C++).
Right. I realized that Rust has its own special meaning for this after I sent the text.
The program might not have an UB, but still be vulnerable to substitution attacks.
Can you give an example? Ideally as concrete and self-contained as possible.
All non-UB programs must behave the same on all targets (modulo non-determinism and differences in data layout and endianess). If you are proposing to change the behavior of programs that don't have UB then you are proposing a language change that affects all targets, not just a compiler change for a particular new target. (Or you are proposing a language dialect for a specific target. So far we don't have any language dialects and they are generally considered very undesirable.) That needs lang team discussions and a lang team champion, typically before implementation begins.
If you are proposing to change the behavior of programs that don't have UB then you are proposing a language change, not just a compiler change. All non-UB programs must behave the same on all targets (modulo non-determinism and differences in data layout and endianess). That needs lang team discussions and a lang team champion, typically before implementation begins.
Well you said yourself you want to change the behavior of a non-UB program. Unless this happens in the scope of already existing non-determinism, that is a language change.
Well you said yourself you want to change the behavior of a non-UB program. Unless this happens in the scope of already existing non-determinism, that is a language change.
(Honestly I'm shocked to learn that -fptrauth-function-pointer-type-discrimination resigns on casts, as opposed to just retaining the original signature and expecting the function pointer to be cast back before calling.)
If the cast is explicit, then we should resign. Because we're changing the type and this is what code is explicitly asking. PAuth protects against unintentional type mismatches, e.g.:
union {
void(*void_ret)(int);
int(*int_ret)(int);
} u;
u.int_ret=&foo;
u.void_ret(1); // trap
Well you said yourself you want to change the behavior of a non-UB program. Unless this happens in the scope of already existing non-determinism, that is a language change.
Will you please point me where I said this?
You didn't say it explicitly, but it's a logical implication of what you said, namely:
For PAuth we are aiming to provide protection against pointer substitutions
and:
The program might not have an UB, but still be vulnerable to substitution attacks.
The first quote most probably says that PAuth is meant to safely abort a program when it detects a pointer substitution.
The second quote almost exactly says that an execution without UB may experience a pointer substitution.
Combining both you get that an execution without UB may experience safe abortion. If the program without PAuth does not permit safe abortion at the pointer substitution point, then that's a new behavior, and thus a language change.
That's why it would help if you could provide an example of an execution without UB that experience pointer substitution. Then we could figure out if that execution already permits safe abortion when the pointer substitution is experienced.
(I'm not sure if you're relying on the fact that the program may safely abort at any time due to stack overflow to justify that it's not a language change. If you do I think it's important to point it out. Note that even though that's a possible behavior in the source program, the compiler tries to minimize occurrences of that behavior in the target program. EDIT: That said, the fact that programs may randomly abort is a convenient framework for all mitigation techniques. It means we can add any mitigation technique without any language change. We just need to avoid aborting too often for quality-of-life, similarly to stack overflows.)
Sorry, I jumped one logical conclusion ahead -- you said, as Justin pointed out
The program might not have an UB, but still be vulnerable to substitution attacks.
I assume you said this because it was relevant for the discussion of PAuth.
If PAuth really only affects programs that have UB then we can ignore this comment.
If the cast is explicit, then we should resign. Because we're changing the type and this is what code is explicitly asking. PAuth protects against unintentional type mismatches, e.g.:
That sounds like a language change to me. The language (both C and Rust) say that both of those operations do the same thing. In particular this contradicts your earlier statement that PAuth should have no effect on standards-compliant C programs.
The attacker has the ability to read and write to a certain range of addresses, possibly the entire address space. However, they are constrained by the normal rules of the process: for example, they cannot write to memory that is mapped read-only, and if they access unmapped memory it will trigger a trap.
The attacker has no ability to add arbitrary executable code to the program. For example, the program does not include malicious code to begin with, and the attacker cannot alter existing instructions, load a malicious shared library, or remap writable pages as executable. If the attacker wants to get the process to perform a specific sequence of actions, they must somehow subvert the normal control flow of the process.
Re-signing on casts would seem to actively undermine the goal of protecting against this attack vector...
Re-signing on casts would seem to actively undermine the goal of protecting against this attack vector...
Unfortunately, this is mostly driving by the practical consideration. Without this many real-world programs will just break as they are doing some unsafe things that "just work in practice".
Although, Rust might chose not to resign on casts – I'd suppose "practical considerations" here are different compared to what we're having in C/C++ world.
Without this many real-world programs will just break as they are doing some unsafe things that "just work in practice".
There's nothing wrong with "unsafe" (in the Rust sense of the term). I think what you mean is that they are doing things that are UB. That's not the same.
For Rust we don't have to worry about code doing UB things, we're generally entirely fine with breaking such code even if it happens to work today. (But obviously we can't break all unsafe code, a lot of unsafe code is correct and that code has to keep working.)
However, if I understood it correctly, the dlsym example shows that this is more than just a problem for some code that anyway has UB. There is nothing fishy or UB about calling dlsym, casting to the right type, and calling the function -- that's the intended way for dlsym to be used. It seems that PAuth breaks this, and then as a partial fix, resign-on-cast was added. But that's a partial fix since a C program could also do the cast via union field accesses, and what would be equally well-defined, but PAuth would still complain.
So, there seems to be a fundamental flaw in PAuth, and the partial fix that was used in C is less likely to work well in Rust (and also less likely to be acceptable to the Rust lang team -- which we have to involve since the moment we're breaking programs that didn't have UB, this is no longer just a compiler thing).
Activity
bjorn3 commentedon Sep 7, 2026
Pointer signing adds a tag in the upper 8 bits of the pointer, which were otherwise required to be a sign extension of the remaining 52bit of the address that is actually used by the CPU (or on most CPUs actually only 48bit, with the entire upper 16bit required to be sign extended). When dereferencing a pointer, the compiler will emit an instruction to check that the tag is correct and then strip it. The signed pointer itself can't be directly loaded from at the CPU level, but unsigned pointers can't be loaded from at the Rust AM level.
.addr()can strip the tag after authenticating the pointer..with_addr()could authenticate the old pointer, strip the tag and then sign the new pointer. For exposed provenance, I think we did have to strip the tag and allow creating an arbitrary valid signed pointer..with_addr()and exposed provenance would hurt effectiveness of pointer authentication as the address could be tampered with while it is not signed.For arm64 ELF pauth no. Different keys are used for function and data pointers, but otherwise all function pointers use the same key and all data pointers use the same key. Some pointers that should not be accessible for the user like return pointers do use different keys (+ possibly pointer location dependent salt), but that shouldn't matter.
For apple arm6e4, C++ vtable method pointers get signed with a different key that regular function pointers and a salt derived from the function signature according to https://developer.apple.com/documentation/security/preparing-your-app-to-work-with-pointer-authentication#Update-your-code-to-avoid-pointer-authentication-failures. Regular function pointers all use the same key without salt.
RalfJung commentedon Sep 8, 2026
Thanks! That's the kind of stuff I wish the goal would have explained. :)
There is a function in rustc that computes a "discriminator" for a function signature, and it takes into account the actual function type. IIUC, not just the key but also the discriminator has to be the same. If
fn(T)andfn(Wrap<T>)end up using different discriminators, then transmuting from one to the other would fail, no?And same for pointer types themselves -- I don't know what the "discriminator" is there, but a
*const boolshould still be usable after being transmuted to*const u8.bjorn3 commentedon Sep 8, 2026
Looks like we support the option to hash various things into the discriminator/salt beyond what the official arm64 ELF pauth ABI allows: https://github.com/rust-lang/rust/blob/a3e94c2ff51d6fa9929cf637fd85df3975119801/compiler/rustc_session/src/session.rs#L97-L107 If you did enable that, you did indeed get similar problems to CFI.
This code is wrong though: https://github.com/rust-lang/rust/blob/798151e197c3d53e57f070dc13878edf1ce4ece5/compiler/rustc_middle/src/ptrauth/discriminator.rs#L320-L331 That should be checking for optional references, not optional raw pointers. Optional raw pointers are not ABI compatible with raw pointers at all as raw pointers don't have a NULL niche. cc @jchlanda
RalfJung commentedon Sep 8, 2026
It's also pretty concerning that apparently we need yet another copy of the same logic that computes the actual ABI of a type. And it'll likely be slightly different than all the other copies.
When do we stop accepting such code and insist on a proper refactor instead? (rust-lang/rust#119183)
jchlanda commentedon Sep 10, 2026
Thank you for filling in the gaps @bjorn3.
Yes, you are right. I've addressed both
Optoin<T>and the lack of support forrepr(transparent)in the PR that contains the other half of this discussion.I'm not sure if you are worried about the second function using a transparent wrapper around T or a general transmute between different types case.
If it's the former, the bugfix mentioned above should address it (stripping of transparent wrappers).
If it's the latter, then there is a PR in the series that provides function pointer type discrimination support for transmutes. Assuming those two functions are
extern "C"(as that is the only ABI that the support is provided for in the series of PRs) and the calculated discriminators do differ, the transmute would trigger a resign of the pointers:In that example the pointer was transmuted from
extern "C" fn(i32) -> i32discriminator:2981toextern "C" fn(i32, i32) -> i32discriminator:63165.The discriminators are only calculated for (extern "C") function pointers.
RalfJung commentedon Sep 10, 2026
Okay, seems like I understood correctly then. Please have a look at our ABI compatibility rules. It must be the case that whenever types T and U are ABI-compatible, they have the same discriminator for function pointer authentication. Does your discriminator scheme ensure this?
That fix is incomplete, and IMO makes things worse. There are many ways to do a transmute in Rust, they must all be equivalent:
transmutefunctionTand then load from via a different field of typeUI cannot think of any problem that you could fix by patching the
transmutefunction.Here is a function that code may use as a drop-in replacement for
transmutewhenever it wants:Your patch sounds like code would behave different when using
my_transmutevs the built-intransmute. That would be a soundness bug, we cannot have that happen.So the pointer authentication scheme for data pointers does not use a discriminator / hard-codes it to 0?
That's one big chunk of potential problems less, good. :)
jchlanda commentedon Sep 22, 2026
Thank you for the feedback @bjorn3 and @RalfJung.
One thing is now clear: transmute should not have semantics that are different from other ways of storing a value as one type and loading it as another. Like you said in the comment above special-casing the transmute intrinsic makes
my_transmuteobservably different fromstd::mem::transmute.The root cause for it was the fact that the original design was closely aligned with how Clang handles this for C: godbolt link. That link shows your 3 use cases (assuming
my_transmutecan be represented asmemcpy). In particular, Clang only resigns explicit function pointer casts, while memcpy/union-based type puns stay intact. This led me to implementing resigning specifically for Rust's transmute, as through my inexperience Rust eyes, transmute seemed to be a direct equivalent of explicit function cast in C.As background, the motivating use case is still function-pointer (extern "C") type punning. One example I'm thinking of is obtaining a type-erased function pointer from something like
dlopen/dlsymand then converting it to the function-pointer type needed to perform a call. This is a fairly natural use case in C, although I'm less certain how common this pattern is in Rust.I'm not yet sure what the proper way of handling possible discriminator transitions and required resigns is. However, reading through your comments suggest basing it on the type change between the store and the load (as opposed to focusing on the operation/mir shape used to perform the conversion). So, at a high level: if a function pointer is stored with one type and subsequently loaded as a different type, that would be the point at which the resign is triggered.
I'd appreciate some guidance on whether this is the right direction, and in particular where in the Rust lowering this information is best accessed. I'm also interested in whether there are any existing invariants or constraints regarding function-pointer type punning that this approach would need to account for.
I'm especially interested in potential pitfalls here, since this seems considerably more involved than my original approach. For example:
RalfJung commentedon Sep 22, 2026
Right, I figured this would be C-thinking applied to Rust. :)
FWIW I think this is wrong for C as well, in the sense that there are correct C programs that will error under PAC. Union-based type punning is legal in C. Maybe clang considers it acceptable to throw errors on entirely standards-compliant C programs, but in Rust we generally consider that inacceptable. We are working towards having a rigorously specified semantics that we'll rigorously implement; we can't have built-in sanitizer mechanisms undermine that.
Rust's memory is untyped. Once a value is stored in memory, only the bytes remain, and they can be manipulated as bytes in all sorts of ways before they are eventually reconsistuted to form a value, possibly of a different type, possibly using only some of the original bytes or mixing in bytes from other values. So this is not only completely impractical to implement as you noted, it's not even really well-defined.
So the problem is that the original pointers returned by dlsym have the wrong signature that doesn't actually match their type? PAC would actually allow me to call them at that wrong type even though it doesn't match the actual type of these functions? (Or do they get signed with a discriminator that said "do not call, type is not known"?)
Yeah that is a problem. The entire point of PAC as I understand it is that a function pointer has its type embedded in its value so it can only be used at that type. That's great but it only works if all parts of the system play along; if dlsym returns incorrectly-signed pointers then the invariant breaks down.
The best I can think of is to add a dedicated resign operation. That still breaks code that uses dlsym if it doesn't get patched, so it'll need some discussion before we stabilize anything. But I'd say PAC semantics are a bit broken here and there is no nice solution.
(The nice solution would be for every symbol to store the discriminator that represents its actual type somewhere, and then dlsym could return a pointer signed with the right type. That would preserve the invariant, and would find bugs where code uses dlsym and casts the function to the wrong type.)
jchlanda commentedon Sep 23, 2026
I probably shouldn't have used dlopen/dlsym as it shows a bit of a special case of what we discuss in here. The reason for it is that it does not return a function pointer, but a raw data pointer (
void *). Under type discrimination it will then be treated as signed with IA key with 0-discrimination (essentially unknown, as you say), so the resign goes from 0 to the actual function pointer type.A more representative case is a dispatch table of type-erased pointers, like OpenSSL's core dispatch. A simplified version of the table here. Entries in the table are signed as
void(*)(void), and the cast back resigns to the real type before the call.But, the whole cast (resign) is a "trust-me" operation: it authenticates using the source type and signs with the destination type without checking the function's actual type. So type discrimination guarantees a pointer's signature matches its current static type, not that it can only be called at the function's "true" type. What it defends against is forged or corrupted pointers, not programmer casts.
jchlanda commentedon Sep 23, 2026
cc @kovdan01, @asl
RalfJung commentedon Sep 23, 2026
But why? Under a strict use of PAC I would expect every fn ptr to be signed with its actual type. It's still type-erased in the sense that the static, compiler-visible type is an erased one. But the runtime value of the fn ptr obviously has to reflect the actual runtime location of the function, and then it might as well also reflect its actual runtime type.
RalfJung commentedon Sep 23, 2026
I am trying to understand the invariant it upholds to achieve that. The "obvious" invariant in my head is that a function pointer to address A can only ever exist with a signature that matches the actual function that lives at that address. If everything in the system ensures that invariant, then PAC is completely invisible while achieving the goal of defending against fn ptrs ending up in the wrong spot. Some super-low-level code that generates function pointers "out of thin air", like dlsym, needs to be aware, but all regular code, including cursed unsafe code, shouldn't have to care.
If PAC is not meant to be invisible, then you are proposing to change the language itself in a way that everyone who wants to reason about Rust code now has to be aware of. Or crates need to opt-in to supporting PAC.
What we want to avoid is a situation where a crate is entirely correct according to the general, portable rules of Rust that we document, but then somehow fails under PAC. That would make PAC-Rust a language dialect (basically, a fork), and language dialects are extremely undesirable. We have been discussing for years how to deal with the problem that CHERI breaks some valid Rust programs so it's technically a dialect; PAC might have to become part of that same discussion then.
No lang or opsem involvement was requested for this project goal, which means it really shouldn't involve any sort of language dialect. If this is more than just "Rust with better UB detection" (that's what normal sanitizers are and what I hope PAC could/should be -- turn some cases of UB into aborts to make it harder to exploit UB bugs), then we need to re-evaluate who needs to be involved in the goal.
asl commentedon Sep 23, 2026
It looks like there is some confusion of what pointer authentication (PAuth) is, what are the intentions, etc. So I hope that the long text could clarify few things.
First thing – PAuth is not a yet another sanitizer. It is not intended to catch undefined behavior, fix programs, uncover bugs, etc. While PAuth can potentially help to uncover some existing examples of undefined behavior, it is entirely a side effect. What PAuth really is a compiler- and hardware-assisted security mitigation approach. As with any mitigation approaches there are some assumptions, aims and threat model. For PAuth we are aiming to provide protection against pointer substitutions where one function (or critical data pointer is used instead of another one. The main source of such substitutions are ROP / JOP kinds of attacks where the set of existing code pieces form the sequence of gadgets that does whatever would be required to perform malicious actions.
The intention is to have all important pointers to be protected with additional provenance carried together with the pointer and is checked when pointer is used (dereferenced / indirect function call is made) Clearly, the checks must be somehow usage-specific as otherwise we can just substitute one "protected" pointer with the another one. To do this some variability is assumed:
Note that HMAC above is an implementation detail (as well as where this signature is stored). It could be something else provided that we can achieve the similar result. E.g. @kovdan01 played with pointer encryption / description in his PhD and it worked just fine within PAuth framework.
Compiler role here is quite important:
Currently clang defines a set of C/C++ language pauth ABIs. Due to some historical (and not only – more on this below) reasons, there is no single C/++ pauth ABI. Instead, there are some set of features (it is called a "signing scheme") where it is specified which pointers are signed and how. The most important thing here is that each signing scheme is a completely new and incompatible ABI. These features do not "stack" on top each other, the "producer" of signed pointers and "consumer" must fully agree on every single detail.
To keep things simple I will talk only about C side of things below. C++ is more complex :) In C function pointers are always trivially copyable, so one can copy them using
memset. This means we cannot use storage address of a function pointer as part of its discriminator. So, the only thing that is left is the function type itself. Now we have to deal with the reality: there are lots of real-world programs that e.g. call functionint(*)(void)viavoid(*)(void)pointer ignoring the result. And everything "just works" while of course technically this is an undefined behavior. As a result to this, by default for plain "C style" function pointers zero discriminator value is used and type is ignored in its entirety. This is was done essentially to overcome "PAuth crashed my program" situation. OpenSSL example above is exactly such situation :)There are not so many different "kinds" of function pointers in C actually. Besides plain function pointers we're also having pointers to
__attribute__((constructor))functions captured by the compiler, some other things due to way GOT is implemented if GOT is not relro and here the list if likely exhausted.In some cases, where the usage of function pointers is safe, one can use type discrimination for function pointers. Here the type of the function itself is used as discriminator value. There are few important notes here:
But in general, PAuth should be transparent to the application. Every semantically correct and standard-compliant C/C++ application could just have PAuth enabled and run just fine. We know that some platforms actually have 2 separate ABIs: one for "type safe" code with function pointer type type discrimination enabled and zero-discriminated ABI for "everything else".
When we started that "PAuth in Rust" project we thought about 3 separate steps of this project:
I was hoping that only at 3. we will need to start discussing language-specific semantics, define "Rust PAuth ABI", etc. But looks like I was wrong as preserving function pointer type provenance through possible valid casts is required for type discriminated function pointers, so we need indeed discuss proper semantics here...
Tagging @davidtwco
asl commentedon Sep 23, 2026
Well, this invariant for sure entirely disallows function pointers casts even if they roundtrip to the correct type? E.g. we should allow the following sequence of pointer casts for the function having type
A:A->B->A. In C / C++ world one can e.g. consider type erasure via casting tovoid*. Indeed, allowing arbitraryvoid* -> Acast could be considered a possible weakness, but we cannot prohibit it provided that the input pointer is correctly signed asvoid*.Do you have an example what will be invalid with CHERI?
comex commentedon Sep 24, 2026
The invariant as Ralf stated it says that the signature has to match the actual function (pointee), not that it has to match the function pointer type it's currently being stored in. This would allow roundtrip casts.
comex commentedon Sep 24, 2026
(Honestly I'm shocked to learn that
-fptrauth-function-pointer-type-discriminationresigns on casts, as opposed to just retaining the original signature and expecting the function pointer to be cast back before calling.)RalfJung commentedon Sep 24, 2026
It was described as what I would call a sanitizer in the project goal:
That's what a sanitizer is: adding extra "stuff" to the execution to stop the program when there is UB. Sometimes this is done in order to find bugs, sometimes to prevent attacks (the kind of attacks we are talking about here all begin by causing UB), but that difference doesn't really matter when we are evaluating the opsem effects of a sanitizer.
I view things like stack canaries also as a form of sanitizer. It turns some instances of UB into "safe" aborts. It doesn't affect the execution of UB-free programs.
Maybe we are just using this term in a slightly different way. If there's a different term I should use for this family of mechanisms, let me know. :)
Based on what was explained above, that is not correct. I could load a function via
dlsym, then cast it via union type punning, and then PAuth will abort my program because it didn't see the cast and thinks I am using the wrong type.I assume you don't mean provenance here? Everything discussed above sounds very different from provenance. I guess you mean the general English meaning of the word, not the specific technical meaning it has in Rust (and LLVM and C/C++).
No, it would explicitly allow round-trips. It would disallow non-roundtrips! If you cast from A to B to C and C is "sufficiently different" from A (as encoded in the discriminator), the call will fail. if you cast to A' which has the same discriminator as A, the call will succeed.
I don't want to distract this discussion. Ask me on Zulip.
EDIT: I made an issue: #625
asl commentedon Sep 24, 2026
The program might not have an UB, but still be vulnerable to substitution attacks.
Right. I realized that Rust has its own special meaning for this after I sent the text.
RalfJung commentedon Sep 24, 2026
Can you give an example? Ideally as concrete and self-contained as possible.
All non-UB programs must behave the same on all targets (modulo non-determinism and differences in data layout and endianess). If you are proposing to change the behavior of programs that don't have UB then you are proposing a language change that affects all targets, not just a compiler change for a particular new target. (Or you are proposing a language dialect for a specific target. So far we don't have any language dialects and they are generally considered very undesirable.) That needs lang team discussions and a lang team champion, typically before implementation begins.
asl commentedon Sep 24, 2026
I doubt I'm proposing any language change.
RalfJung commentedon Sep 24, 2026
Well you said yourself you want to change the behavior of a non-UB program. Unless this happens in the scope of already existing non-determinism, that is a language change.
I'll wait for an example. :)
asl commentedon Sep 24, 2026
Will you please point me where I said this?
asl commentedon Sep 24, 2026
If the cast is explicit, then we should resign. Because we're changing the type and this is what code is explicitly asking. PAuth protects against unintentional type mismatches, e.g.:
Contrary to this, the explicit cast like in:
is interpreted as being intentional: the author knows what they're doing.
ia0 commentedon Sep 24, 2026
You didn't say it explicitly, but it's a logical implication of what you said, namely:
and:
The first quote most probably says that PAuth is meant to safely abort a program when it detects a pointer substitution.
The second quote almost exactly says that an execution without UB may experience a pointer substitution.
Combining both you get that an execution without UB may experience safe abortion. If the program without PAuth does not permit safe abortion at the pointer substitution point, then that's a new behavior, and thus a language change.
That's why it would help if you could provide an example of an execution without UB that experience pointer substitution. Then we could figure out if that execution already permits safe abortion when the pointer substitution is experienced.
(I'm not sure if you're relying on the fact that the program may safely abort at any time due to stack overflow to justify that it's not a language change. If you do I think it's important to point it out. Note that even though that's a possible behavior in the source program, the compiler tries to minimize occurrences of that behavior in the target program. EDIT: That said, the fact that programs may randomly abort is a convenient framework for all mitigation techniques. It means we can add any mitigation technique without any language change. We just need to avoid aborting too often for quality-of-life, similarly to stack overflows.)
RalfJung commentedon Sep 24, 2026
Sorry, I jumped one logical conclusion ahead -- you said, as Justin pointed out
I assume you said this because it was relevant for the discussion of PAuth.
If PAuth really only affects programs that have UB then we can ignore this comment.
That sounds like a language change to me. The language (both C and Rust) say that both of those operations do the same thing. In particular this contradicts your earlier statement that PAuth should have no effect on standards-compliant C programs.
Diggsey commentedon Sep 25, 2026
https://clang.llvm.org/docs/PointerAuthentication.html#theory-of-operation seems to be a more comprehensive description of pointer authentication than the one linked in the OP.
Re-signing on casts would seem to actively undermine the goal of protecting against this attack vector...
asl commentedon Sep 25, 2026
Unfortunately, this is mostly driving by the practical consideration. Without this many real-world programs will just break as they are doing some unsafe things that "just work in practice".
Although, Rust might chose not to resign on casts – I'd suppose "practical considerations" here are different compared to what we're having in C/C++ world.
RalfJung commentedon Sep 26, 2026
There's nothing wrong with "unsafe" (in the Rust sense of the term). I think what you mean is that they are doing things that are UB. That's not the same.
For Rust we don't have to worry about code doing UB things, we're generally entirely fine with breaking such code even if it happens to work today. (But obviously we can't break all unsafe code, a lot of unsafe code is correct and that code has to keep working.)
However, if I understood it correctly, the dlsym example shows that this is more than just a problem for some code that anyway has UB. There is nothing fishy or UB about calling dlsym, casting to the right type, and calling the function -- that's the intended way for dlsym to be used. It seems that PAuth breaks this, and then as a partial fix, resign-on-cast was added. But that's a partial fix since a C program could also do the cast via union field accesses, and what would be equally well-defined, but PAuth would still complain.
So, there seems to be a fundamental flaw in PAuth, and the partial fix that was used in C is less likely to work well in Rust (and also less likely to be acceptable to the Rust lang team -- which we have to involve since the moment we're breaking programs that didn't have UB, this is no longer just a compiler thing).