RFC: `__ptrauth` qualifier

Introduction

In this RFC, we propose adding support for the __ptrauth qualifier. This qualifier causes so-qualified objects to hold pointers signed using the specified pointer authentication schema rather than the default schema for the type. The default schema is constrained by the rules of the base language standard in ways that generally reduce the effectiveness of pointer authentication as a security mitigation; __ptrauth allows programmers to conveniently opt in to stronger mitigation.

This RFC assumes a basic understanding of what pointer authentication is and how it works. If you’d like to learn more about pointer authentication, you can visit the following links:

clang documentation
RFC: Pointer authentication for arm64e

The __ptrauth qualifier

The __ptrauth qualifier has the following grammar:

type-qualifier:
  __ptrauth ( argument-expression-list[opt] )

At least three argument expressions are required:

  • The first argument, the key, must be an integer constant expression evaluating to one of the integral key values declared in ptrauth.h.
  • The second argument, the address diversity flag, is contextually converted to bool and must then be a constant expression of boolean type. If the value of the expression is true, the type is said to be qualified with address diversity, which means the storage address of the object will be used as part of computing the discriminator for values stored in the object.
  • The third argument, the constant discriminator, must be an integer constant expression. This value is used to provide constant diversity to values stored in the object.

The ideas of address diversity and constant diversity are explained here:
https://clang.llvm.org/docs/PointerAuthentication.html#discriminators

As a brief summary, pointer authentication provides a more effective mitigation when pointers are signed with both address diversity and a constant discriminator that is different from other places that store signed pointers. For example, signing function pointers in a virtual table with both address diversity and method-specific discriminators makes it difficult to either forge a virtual table from scratch or replace an object’s virtual table with a different valid table.

Additional arguments are allowed to provide room for further extension. The rules and restrictions on these arguments will be documented here as extensions are implemented.

Example

typedef int (*FuncPtr)(int);
extern int foo(int);
int i;

// Sign &foo with key=1, address discrimination disabled, and extra discriminator 123.
FuncPtr __ptrauth(1, 0, 123) fp1 = &foo;

// Sign &foo with key=0, address discrimination enabled, and extra discriminator 234.
FuncPtr __ptrauth(0, 1, 234) fp2 = &foo;

// Sign &i with key=2, address discrimination enabled, and extra discriminator 345.
int * __ptrauth(2, 1, 345) p = &i;

// Sign &foo with key=0, address discrimination enabled, and extra discriminator 234. Initialize s.fp with the signed pointer.
struct S {
  FuncPtr __ptrauth(0, 1, 234) fp;
} s = { &foo };

Restrictions

  • The qualified type must be a C/C++ pointer type, either to a function or to an object. It currently cannot be an Objective-C pointer type, a C++ reference type, or a block pointer type.
  • __ptrauth cannot currently be combined with the _Atomic qualifier.
  • A program is ill-formed if it uses a __ptrauth qualifier directly on a function parameter or return type. The qualifier is silently removed in these positions if added by a typedef or by template substitution.
  • The operands currently must always be constant expressions, even within templates.

Several of these restrictions are implementation limitations and may be lifted in the future.

Type identity and compatibility

The type __ptrauth(...) T is the same type as __ptrauth(...) U if the qualifiers are the same and T and U are the same. Two __ptrauth qualifiers are the same if the constant values of the operands are the same; they need not use similar expressions to compute those values.

The type __ptrauth(...) T is compatible with __ptrauth(...) U if T and U are compatible and the __ptrauth qualifiers are the same. Note that this implies that it is generally invalid to access objects of __ptrauth-qualified type through l-values of a different type. In effect, the mechanics of pointer authentication enforce a weak form of strict aliasing around signed pointers even when strict aliasing is otherwise disabled in the compiler. Only the pointer authentication schema is relevant to this enforcement. In particular, when strict aliasing is disabled, accessing an object of type __ptrauth(...) T through an l-value of type __ptrauth(...) U is effectively well-defined as long as the __ptrauth qualifiers are the same, even if the underlying types T and U are not compatible types. When strict aliasing is enabled, of course, the standard restrictions on incompatibly-typed accesses still apply and the underlying types must also be compatible; however, this is not enforced by pointer authentication.

Non-triviality from address diversity

Address diversity imposes additional restrictions in order to allow the implementation to correctly copy values.

C++ has standard concepts of non-triviality for copying and destroying values, and address diversity naturally affects these. In C++, the builtin operations to copy-initialize, move-initialize, copy-assign, or move-assign an object of a type qualified with address diversity are considered to be non-trivial operations, exactly as if the qualified type were a class with a user-provided corresponding special member. Default initialization and destruction remain trivial. Note that adding a field whose type is qualified with address diversity to a class that is otherwise trivially copyable is likely to be an ABI-breaking change even if the size and alignment of the class do not change.

C does not have standard concepts of non-triviality, and so we must describe the basic rules here, with the intention of imitating the emergent rules of C++:

  • A type may be non-trivial to copy.
  • A type may also be illegal to copy. Types that are illegal to copy are always non-trivial to copy.
  • A type may also be address-sensitive.
  • A type qualified with address diversity is non-trivial to copy and address-sensitive.
  • An array type is illegal to copy, non-trivial to copy, or address-sensitive if its element type is illegal to copy, non-trivial to copy, or address-sensitive, respectively.
  • A struct type is illegal to copy, non-trivial to copy, or address-sensitive if it has a field whose type is illegal to copy, non-trivial to copy, or address-sensitive, respectively.
  • A union type is both illegal and non-trivial to copy if it has a field whose type is either non-trivial to copy or illegal to copy.
  • A union type is address-sensitive if it has a field whose type is address-sensitive.
  • A program is ill-formed if it uses a type that is illegal to copy as a function parameter, argument, or return type.
  • A program is ill-formed if an expression requires a type to be copied that is illegal to copy.
  • Otherwise, copying a value of a type that is non-trivial to copy must correctly copy its subobjects as if they were individually loaded and then assigned into the new location.
  • Types that are address-sensitive must always be passed and returned indirectly. Thus, changing the address-sensitivity of a type may be ABI-breaking even if its size and alignment do not change.

Properly-signed values

An object of a type with the __ptrauth qualifier is said to have a properly-signed value if:

  • it is assigned a value by an initializer;
  • it is assigned a value by an assignment operator, such as = or +=;
  • the object representation of a null pointer of the underlying type is written into the object (as if by bzero, on platforms where the null pointer has the zero bit-pattern); or else
  • the object representation of an object of the same type and with a properly-signed value is written into the object, as if by memcpy. Furthermore, if the type is qualified with address diversity, the object representation must originate from an object at the same storage address (e.g. it may have been memcpy’d away and then back again) or otherwise be properly signed for that address (e.g. using the ptrauth intrinsics).

Reading the value of an object whose type has the __ptrauth qualifier that does not have a properly-signed value may (but cannot be guaranteed to) immediately halt the program. Situations causing such a read include applying an lvalue-to-rvalue conversion on an lvalue that resolves to the object, using an lvalue that resolves to the object as the left operand of a compound assignment operator, and copying or moving an aggregate object containing the object. An object need not contain a properly-signed value prior to a new value being assigned into it or when the object is destroyed.

:white_check_mark: Clang consensus called in this message.

1 Like

CC @AaronBallman, @ldionne

Thank you for the RFC! I have some basic questions while I think about the proposal:

Is this functionality specific to arm64e targets or is this expected to work on all targets?

The first argument, the key, must be an integer constant expression evaluating to one of the integral key values declared in ptrauth.h.

Does the user have to include this header file in order to use the qualifier? e.g., does this behave like std::initializer_list and typeid in C++? Or does the compiler require special knowledge of the contents of ptrauth.h to provide good diagnostics?

The qualified type must be a C/C++ pointer type, either to a function or to an object. It currently cannot be an Objective-C pointer type, a C++ reference type, or a block pointer type.

How about member function pointers in C++?

__ptrauth cannot currently be combined with the _Atomic qualifier.

How about address spaces?

Type identity and compatibility

Is a type with the __ptrauth qualifier a distinct type from one without the qualifier? e.g., can you overload on the qualifier? Can you SFINAE on it? Is it UB to have a mismatch across TU boundaries?

Also, how does this interact with polymorphism in C++?

Because this is a qualifier, does it behave like other qualifiers in C++ in that you can write them in surprising places, e.g.

struct S {
  void func() __ptrauth(...);
};

How do you expect this to interact with the STL in C++? e.g., do you expect to be able to put a qualified function pointer into a std::function and call it?

How do you expect this to interact with debuggers (are there debugging aids for when users have incorrect qualifiers)?

What is the behavior for constant expression use of these qualified pointers? Should they behave the same as other pointers or are there special considerations we have to be aware of?

1 Like

Assuming __ptrauth mechanism will take care for ARM MTE enabled tagged pointers.

int * __ptrauth(2, 1, 345) p = &i;

If i is tagged pointer, will the tag value preserve in this mechanism?

If i is tagged pointer, will the tag value preserve in this mechanism?

Generally, yes, the MTE usage of tag bits can be seen as a special case of address tagging and TBI, which is honored by the PAuth instructions.
The interaction between PAuth, MTE tagging, and TBI is complicated though, and there are notable subtleties where this visibly depends on the key and the OS kernel configuration (e.g., TCR_ELx.TBID and IA/IB keys, or TCR_ELx.TBI in general affecting the high nibble only)

These are great questions, thank you.

The same as pointer authentication overall. It is currently specific to ARMv8.3, but the design is meant to work for any similar hardware feature. It is also possible to emulate it in software (with a lot of overhead), and the current patches sketch out some limited support for that, but we are not planning to support that configuration.

For ARMv8.3, the compiler requires the key value to be in the range ([0,4)), with specific values corresponding to specific hardware-supported key registers.

Not currently.

Not currently. I think AArch64 doesn’t support any address spaces. If it did, it would have to require the inner address space (T __addrspace * __ptrauth(...)) to be at least 64 bits. The outer address space (T * __ptrauth(...) __addrspace) could be any size, but if it isn’t 64 bits, we’d have to invent some ABI rule for how to do address diversity.

Yes.

Yes, if it appears in a nested position.

Not currently, because (as noted) the operand expressions are not allowed to be value-dependent. If we lift that restriction, SFINAE should work.

Yes. All of this is expressed by the equality/compatibility rules.

Do you something specific in mind?

No. Qualifiers in this position are conventionally understood to be within the pointer, so this would translate to S __ptrauth(...) *, which is not a well-formed type.

That is an interesting question. I expect it would just work, but I don’t know that we’ve tried. As far as I know, over the last 7-8 years of Apple-internal use across C/C++/ObjC/ObjC++, nobody has complained about this not working.

There’s not much direct debugger support for pointer authentication failures in general. We did put some thought into it, but we weren’t able to identify anything that would actually help. Guessing at other signing schemas and testing whether they produced the observed value never really panned out. Watch points tend to work better.

Our experience has been that the __ptrauth qualifier has proven to be pretty resilient as a language mechanism. Most of the pointer authentication failures we’ve seen arising from programmer errors come from either assembly code or the explicit use of intrinsics.

Good question. I don’t think the constant evaluator has to do anything special here. The logical value stored in a qualified object is still the same, so ignoring the qualifier is fine.

In principle, the constant evaluator does need to reject qualifier mismatches during load/store, because it’s technically UB (just a very well-behaved UB). In practice, though, I’m not sure the restrictions of constant evaluation actually permit the construction of an incorrectly-typed l-value in the first place.

I came to the same conclusion doing the Linux debug support.

Might be good to know that lldb already removes all non-address bits (which includes pointer signatures) when accessing memory. So you can use a signed pointer whether it’s correctly signed or not (https://old.linaro.org/blog/lldb-15-and-the-mystery-of-the-non-address-bits/).

Though this does potentially hide incorrect signatures, I made the assumption that most of the time they’d be correct and you’d still get the fault when you later resumed the program.

There are some unique situations where we will show the raw pointer, then the stripped version and the symbol (if any) that the value resolves to. For example:

(lldb) p fn_ptr
(char (*)(size_t, int)) $0 = 0x003d0000004006ac (actual=0x00000000004006ac a.out`checked_mmap at main.c:13:48)

I think signals are another example.

1 Like

It’s maybe worth noting that the __restrict qualifier in that same position does apply to the pointer type of this, and not the pointee, but it probably would be simpler to just outright disallow the qualifier in that position entirely regardless of this.

I didn’t realize that, but it makes sense.

We don’t allow __ptrauth at the top level of a parameter type, so even if we analyzed __ptrauth method qualifiers as applying outside the pointer, it would still be invalid. If we ever lift that restriction, it would be to make it meaningfully affect the ABI so that you can control the signing schema used by an argument, and it would be natural to extend that capability to method qualifiers. However, we may already be locked out of that by the rule that __ptrauth qualifiers from typedefs and template substitution are ignored in this position.

Thank you! My concern is: if this is only useful for one target in practice, it’s a very heavy language lift to use a qualifier. It sounds like there’s a theoretical chance of this being used on other targets, but that it’s unlikely for that support to materialize any time soon. That makes me wary of adding a new qualifier.

Normally, I’d suggest adding an attribute instead as those are more well understood to be nonportable. However, this would be a type attribute and usually a keyword is the better approach there when it impacts things like overloading, etc.

SGTM

Okay, so long as we give good diagnostics, that seems reasonable to me.

Okay, but I presume you can specialize on the type if you can overload on it? e.g.,

template <typename Ty>
struct is_ptrauth { static constexpr bool value = false; };

template <typename Ty>
struct is_ptrauth<__ptrauth(...) Ty> { static constexpr bool value = true; };

static_assert(is_ptrauth<int * __ptrauth(...)>::value); // Ok

and if you can specialize on it, can you do something along these lines?

template <typename Ty, int Key = -1, int N1 = -1, int N2 = -1>
constexpr int get_ptrauth_key(Ty) = delete;

template <typename Ty, int Key, int N1, int N2>
constexpr int get_ptrauth_key(Ty *__ptrauth(Key, N1, N2) {
  return Key;
}
int * __ptrauth(0, 1, 2) foo;
static_assert(get_ptrauth_key(foo) == 0); // Ok

Excellent

I was mostly wondering if it impacts covariant return types, and I’m guessing yes. e.g.,

struct Base {
  virtual ~Base() = default;

  virtual Base *getThis() { return this; }
};

struct Derived : Base {
  Derived * __ptrauth(...) getThis() override { return this; } // ill-formed, not covariant?
};

or similar:

struct Base {
  virtual ~Base() = default;

  virtual Base * __ptrauth(1, 2, 3) getThis() { return this; }
};

struct Derived : Base {
  Derived * __ptrauth(1, 2, 4) getThis() override { return this; } // ill-formed, not covariant?
};

where the arguments to the attribute differ.

CC @ldionne @philnik @mordante for libc++ opinions

Okay, thanks!

Good to know, and that matches what I was thinking.

Heh, that’s the prior art I was thinking of and given what a mess that qualifier is, I agree that it’s better to prevent its use explicitly.

The complexity from the compiler’s perspective arises from the nature of the thing. The Objective-C GC and ARC qualifiers are actually written as type attributes after macro expansion, but the role they play in the language is that of qualifiers, and we don’t have much choice but to represent them as qualifiers in the compiler. The same is true here. The actual spelling as the compiler sees it is basically irrelevant. If anything, forcing a type attribute spelling increases implementation complexity because it’s so unwieldy that we can’t expect programmers to actually use it — we have to create standard macros over it, and then the compiler has to deal with the mismatch between the “true” spelling vs. what the user actually wrote.

Also, as far as spellings go, target-specific keywords are pretty well precedented. Type specifiers like uint128_t are technically target-specific keywords (even if they’re shared between targets), and several targets define address spaces as keywords.

I understand the reticence about adding a (currently) target-specific qualifier. When I did this language design, I was hoping that other chipmakers would come along and implement similar features, but so far they haven’t. Regardless, I don’t regret the decision to use a qualifier; it’s been a very successful feature and is clearly better than relying on intrinsics.

Apple is open to feedback about both the implementation and design of this feature, and if we need to tighten up restrictions in some way, we can figure out how to roll the new restrictions out across our codebase. (Generalizations, of course, don’t have this problem.) But it is a widely-used feature that we don’t have arbitrary flexibility to change, so feedback like “this should be spelled completely differently” isn’t something we can do much about; if those are hard requirements, we’ll just have to keep this downstream.

Only as a concrete application. So you can write:

template <typename T>
struct A;

template <typename T>
struct A<T * __ptrauth(0,0,0)>;

but you can’t write this because of the requirement that the operands be non-dependent:

template <typename T, int Key, int AD, int CD>
struct A<T * __ptrauth(Key,AD,CD)>;

If/when we lift that restriction, then yes, part of the work of doing that will be to make template argument inference capable of breaking down a concrete qualifier, which would allow this kind of partial specialization.

You cannot write a __ptrauth qualifier directly on a return type. If it did, it would have to be considered during override checking, yeah, because it would change the ABI of the return value.

Yeah, I wasn’t speaking so much about the lift for the compiler – the complexity is the same regardless of how we spell it, basically – I meant in terms of users. We have ample experience that users expect qualifiers to behave like other qualifiers (const specifically) and when they behave differently than other qualifiers, it causes adoption and use issues.

FWIW, I’m not asking for a change, mostly discussing the high-level design concerns I have. I definitely appreciate the constraints you’re under wheb you’ve got existing users. :slight_smile:

I’d like to tie this to our extension criteria (and this might be a broader question of the notion of pointer authentication than just narrowly about the qualifier):

I’d like to understand more about the usage experience you’ve had. Is this functionality mostly used by system headers, or is this used more by application developers, a bit of each, etc? Who is the significant user community the feature serves?

Has this been proposed (or is it planned to propose) to WG14 or WG21?

Given that this is specific to just one target, what is the need for it to reside upstream? Are you aware of other folks wanting to build on top of this for different targets/software emulation?

1 Like

We haven’t seen much confusion about this. People tend to take restrictions in stride, and it’s pretty intuitive why you can’t convert a T ** to a T * __ptrauth(...) * despite that working for const.

Sure, you’re absolutely right that we should work through this.

A mix of system and application developers. It’s mostly not in public headers for emergent reasons: most of the uses are around v-table-like structures, and in C-style APIs those tend to be implementation details of your library rather than exposed to clients. But e.g. malloc_zone_t decorates all of its function pointers with __ptrauth (via its own macro), and that’s in a system header, and it would break ABI on arm64e to somehow parse that struct without the qualifier.

The developers using arm64e have historically been almost entirely Apple employees, but it is now possible (and advisable) to compile security-sensitive third-party applications using arm64e, and in fact this is a specific requirement of BrowserEngineKit.

We do not currently plan to propose this to WG14, and I don’t think it would be very viable right now because of the single implementing architecture. I do believe that the conceptual model we’ve laid out in pointer authentication is portable to other implementations, but that’s a pretty abstract argument to make to a committee. If there is ever adoption by other architecture vendors, and we can demonstrate that the language model still works for them, I think we’d be happy to participate in the standardization process (which I imagine would probably terminate in a TR, similar to Embedded C).

A couple reasons.

The first is that Apple sees it as necessary in order to target Apple platform SDKs (only in the specific arm64e configuration, granted, but that’s a supported configuration), and we don’t want that to be restricted to Apple-provided tools.

Furthermore, since the feature adds a new type qualifier, it is a rather large and invasive patch to maintain downstream. I know that’s in some sense our problem, not LLVM’s, but it is certainly part of our interest in upstreaming.

More importantly, though, there is a working group of other companies who are interested in supporting pointer authentication in their arm64 toolchains. Currently they have to do that by basing tools on Apple’s downstream fork, which I think we can all agree creates an undesirable splintering of the community. I’ll let them speak for themselves, though.

2 Likes

Do you mean that, or do you mean a T * to a T * __ptrauth(...)? I don’t see why a cast should care whether the pointee is a __ptrauth thing or not? In particular void * ↔ T * _ptrauth(...) * needs to work for many APIs out there. I’ve been viewing this a lot like hybrid CHERI’s __capability qualifier, which doesn’t have such a restriction (and supports various other things you’re explicitly viewing as out of scope, like atomic, reference and dependent types, though our current implementation has known bugs).

A separate question, informed by my work beefing up __capability in CHERI LLVM: do you support the qualifier on array parameter types, since those are really pointers? The syntax would be void foo(T a[__ptrauth(...)]), mirroring const etc. This is particularly useful when combined with things like static N (otherwise you really might as well just make it a pointer).

About the removal of the qualifier during template substitution, this means that we basically drop the qualifier in the following example (which I am able to confirm using AppleClang):

cat <<EOF | xcrun clang++ -xc++ - -std=c++20 -fsyntax-only -arch arm64e
template <class T>
T deref(T* ptr) {
  // Within this function, `T*` is `int*`, not `int __ptrauth(...)*`
  return *ptr;
}

int main() {
  int i = 3;
  int * __ptrauth(2, 1, 345) p = &i;
  deref(p);
}
EOF

Is there a rationale for this part of the design? I think it clearly reduces the number of potential interactions with the library (which is good) because it means you’ll basically drop the qualifier whenever you call a standard library function using a signed pointer, but I also wonder whether that leaves a lot of potential safety behind?

I also tried a basic interaction like this using std::function:

cat <<EOF | xcrun clang++ -xc++ - -std=c++20 -arch arm64e -o a.out && ./a.out
#include <functional>
#include <cassert>

using Fptr = int (*)(int);
int foo(int i) { return i; }

int main() {
  Fptr __ptrauth(1, 0, 123) signed_foo = &foo;
  std::function<int(int)> f = signed_foo;
  assert(f(3) == 3);
}
EOF

This seems to “work” out of the box. Basically, I think we simply drop the qualifier from signed_foo when we initialize the std::function, so it’s as-if it never existed.

Given this, don’t think I see any major concern about “actively bad interactions” with the library, i.e. stuff that would downright not work. However, it is possible (likely?) that there are places where a user could expect that we do more than just drop the qualifier and they would feel a bit let down. An example would be std::unique_ptr: users might reasonably expect something like this to work to create a “ptrauth-qualified owned pointer”:

std::unique_ptr<int __ptrauth(1, 0, 123)> ptr = std::make_unique<int>(3);

In practice, that doesn’t work because int is not a pointer type, so you can’t ptrauth-qualify it.

TLDR: I don’t think I have any concerns about bad interactions with the standard library, but there are clearly places where we could potentially go out of our way to “do more” and we might not be able to. I don’t think that’s a huge concern since my impression is that this qualifier will be used mostly in lower-level code and users might not expect anything fancy from the library.

You can assign a T * into an l-value of type T * __ptrauth(...). Top-level qualifiers don’t generally apply to r-values in C, only to l-values and objects, so we don’t usually talk about that as “converting to T * __ptrauth(...)” — instead, we say the value is still a T *, but it’s just stored differently in that object because of the qualifier on the object’s type.

There is no implicit conversion from T ** to T * __ptrauth(...) * because those types represent incompatible assumptions about how the pointer is stored in the pointed-type object. You can force a conversion with an explicit cast, but of course accessing the object through that might misbehave if the original type was in fact correct.

You cannot currently declare parameters to be __ptrauth-qualified at the top level. Qualifiers in array bounds are understood to apply at the top level, so this is also not allowed.

The loss of the qualifier in this case actually has nothing to do with that rule. You are passing an l-value of type int * __ptrauth(2, 1, 345) to a function template with a parameter of type T *. The only possible template argument deduction is T := int.

I don’t know when exactly std::function is losing the qualifier, but if the constructor took a T &, T would be absolutely be inferred to be a __ptrauth-qualified type.

1 Like

Out of curiosity, did you evaluate the alternative to implement authenticated pointers as a C++ object? Something like auth_ptr<T, Key, AD, CD> with implicit conversion from/to T*. (Or if you need plain C, the equivalent is an alternate pointer type with implicit conversion from/to an ordinary pointer.) The implicit conversions would map to the sign/auth operations.

From what I understand, this would align for the most part with the language rules that you have for the __ptrauth qualifier, or do you disagree?

Forcing the use of C++ everywhere that we wanted to use this feature was not an option.

Modeling this as a different pointer type rather than a qualifier has advantages and disadvantages. The main advantage is that it would provide clear rules for changing how pointers are passed to and from functions. The disadvantages are that it’s a much more intrusive change to the language, especially since it introduces the idea of a kind of pointer that can only be passed around in memory (because of address diversity).

As far as I know, there is some movement to implement pointer authentication in RISC-V as a separate ISA extension. But here we are facing with the usual chicken-and-egg problem as there is a need for both compiler / toolchain and ISA support and they are dependent on each other.

In theory, one could leave without __ptrauth qualifier and do the things manually via builtins, but as @rjmccall mentioned, this is very error-prone (e.g. instead of single type annotation one should explicitly call builtins with correct arguments, etc. everywhere) making pointer authentication addition in the real code very costly.