PAC-RET is a way of preventing ROP attacks on Arm64 using the PAC extension which was introduced in Arm 8.3. When enabled the stack pointer is encrypted before being stored to the stack and verified again when it is restored.
Expand for another description....
The assumption here is that the attacker has gotten the ability the change writable memory in the process (possibly only the stack) and read executable memory. They do not have the ability to change readonly memory, change the control flow or change/access register contents. The goal of the attacker is to make the program execute arbitrary code. This can be done by editing the return addresses on the stack. When the program returns, it now jumps back to code the attacker wants to run. This in itself is not that useful as the attacker is limited to functions available and register contents. By looking through all executable memory they look for small groups of instructions directly proceeding a return instruction. These are "gadgets" which simply change a register or write a bit of memory. By chaining gadgets together using return addresses the attacker now can execute whatever they want. Tools exist to look at the executable code of a known program (or library) and build a library of gadgets (which is why I'm concerned about protection of CoreCLR code over jitted code).
PAC-RET works because the return address stays in a register LR (which the attacker cannot access) and only goes to memory when saved to the stack, which is encrypted before the store. When loading from the stack we unencrypt, and fault on an error. To modify the address, the hacker would need to know the secret per-process key. The hacker can't simply replace it with a different encrypted value as the location on the stack is used as a salt in the encryption, meaning every encrypted value is pinned to that location.
PAC-RET is self contained by function. When a function encrypts the return address, it will be the same function that decrypts it again before returning. Therefore, for standard programs, PAC can be enabled per function without interfering with other functionality. Issues arise when a program walks its own stack, rewrites it's stack, or jumps out of program order.
When run on systems without PAC, the PAC instructions are treated as NOPs. Therefore a PAC protected program can be run on a non-PAC system at a cost of a few NOPs per function.
Testing
There are a number of different scenarios that could be tested. To reduce testing size, only a few are required:
Test?
CoreCLR
PAC feature
OS system libraries
yes
build with branch protection
not in hardware or OS
with PAC
yes
build with branch protection
enabled in hardware + OS
with PAC
no
build with branch protection
enabled in hardware + OS
without PAC
no
build without branch protection
either
either
Assumptions
For now, only support Linux
Work items
[ ] Add PAC supported Linux hardware in the CI
Using the scenarios above. This will likely be Cobalt 100. No other PAC work can be merged until this step is complete.
[ ] Build .NET using branch-protection flags #108561
This will ensure that the entire CoreCLR VM in protected via PAC. This will always be enabled for Linux builds. The expected cost is 1-2% slowdown in the VM and jit on PAC enabled machines. This code is static and is the most vulnerable to ROP attacks as an attacker will be able to use the code to build an a library of attack gadgets ahead of time. Building with branch-protection will prevent this
[ ] Protect assembly routines:
This is only required if there are assembly routines in CoreCLR which save the return value to the stack. These should all be updated to encrypt/decrypt when saving/loading to/from the stack. Each routine could be implemented individually.
[ ] Fix up stack underwinders.
CoreCLR contains two libunwinds. It may have other stack examiners. These should be updated to strip PAC from the return address (there is no requirement here to decrypt the value). The underwinders need to be able to handle both encrypted and unencrypted values.
[ ] Add PAC-RET support to the jit
Once CoreCLR is protected, the next step is to protect code generated by CoreCLR. Enable via a config value. Ensure return values are encrypted/decrypted in prolog/epilog. Fix up any rewriting of the stack - for example return address hijacking in the GC. Suggested implementation order: 1) Encrypt the return address with a salt of 0, decrypt by stripping - testing this will ensure all stack examiners/editors are found 2) decrypt fully 3) encrypt using the stack address as the salt.
[ ] Debugging and Diagnostics
Along with stack unwinding, we need to ensure that debugger can decrypt the function addresses (if needed) and display the debug info correctly.
[ ] NativeAOT / R2R
Lastly, we definitely should validate the working of PAC with NativeAOT and R2R to guarantee that it will work as expected.
Stretch items
[ ] Harden the config variable.
An attacker could potentially overwrite the config variable and disable PAC. Either always enable PAC if the hardware supports it or ensure the config variables moved to read-only memory after startup.
[ ] Harden hijacking stub addresses
The addresses used in the return address hijacking should be kept in memory as encrypted values. When rewriting the stack, the value is loaded from memory to register, decrypted, encrypted again, then stored to the stack.
Possible future work items
[ ] Support Windows
[ ] Support MacOS. This will involve full Arm64e support.
[ ] Add BTI support
[ ] Encrypt more data pointers. Possibly all pointers that point to jitted code.
[ ] Support Arm64 GCS. This is similar to Intel's CET (see #47309). GCS is not yet available in hardware.
PAC-RET is a way of preventing ROP attacks on Arm64 using the PAC extension which was introduced in Arm 8.3. When enabled the stack pointer is encrypted before being stored to the stack and verified again when it is restored.
A detailed description of PAC-RET and the associated security issues can be found in Low-Level Software Security for Compiler Developers
Expand for another description....
The assumption here is that the attacker has gotten the ability the change writable memory in the process (possibly only the stack) and read executable memory. They do not have the ability to change readonly memory, change the control flow or change/access register contents. The goal of the attacker is to make the program execute arbitrary code. This can be done by editing the return addresses on the stack. When the program returns, it now jumps back to code the attacker wants to run. This in itself is not that useful as the attacker is limited to functions available and register contents. By looking through all executable memory they look for small groups of instructions directly proceeding a return instruction. These are "gadgets" which simply change a register or write a bit of memory. By chaining gadgets together using return addresses the attacker now can execute whatever they want. Tools exist to look at the executable code of a known program (or library) and build a library of gadgets (which is why I'm concerned about protection of CoreCLR code over jitted code). PAC-RET works because the return address stays in a register LR (which the attacker cannot access) and only goes to memory when saved to the stack, which is encrypted before the store. When loading from the stack we unencrypt, and fault on an error. To modify the address, the hacker would need to know the secret per-process key. The hacker can't simply replace it with a different encrypted value as the location on the stack is used as a salt in the encryption, meaning every encrypted value is pinned to that location.PAC-RET is self contained by function. When a function encrypts the return address, it will be the same function that decrypts it again before returning. Therefore, for standard programs, PAC can be enabled per function without interfering with other functionality. Issues arise when a program walks its own stack, rewrites it's stack, or jumps out of program order.
When run on systems without PAC, the PAC instructions are treated as NOPs. Therefore a PAC protected program can be run on a non-PAC system at a cost of a few NOPs per function.
Testing
Assumptions
Work items
[ ] Add PAC supported Linux hardware in the CI Using the scenarios above. This will likely be Cobalt 100. No other PAC work can be merged until this step is complete.
[ ] Build .NET using branch-protection flags #108561 This will ensure that the entire CoreCLR VM in protected via PAC. This will always be enabled for Linux builds. The expected cost is 1-2% slowdown in the VM and jit on PAC enabled machines. This code is static and is the most vulnerable to ROP attacks as an attacker will be able to use the code to build an a library of attack gadgets ahead of time. Building with branch-protection will prevent this
[ ] Protect assembly routines: This is only required if there are assembly routines in CoreCLR which save the return value to the stack. These should all be updated to encrypt/decrypt when saving/loading to/from the stack. Each routine could be implemented individually.
[ ] Fix up stack underwinders. CoreCLR contains two libunwinds. It may have other stack examiners. These should be updated to strip PAC from the return address (there is no requirement here to decrypt the value). The underwinders need to be able to handle both encrypted and unencrypted values.
[ ] Add PAC-RET support to the jit Once CoreCLR is protected, the next step is to protect code generated by CoreCLR. Enable via a config value. Ensure return values are encrypted/decrypted in prolog/epilog. Fix up any rewriting of the stack - for example return address hijacking in the GC. Suggested implementation order: 1) Encrypt the return address with a salt of 0, decrypt by stripping - testing this will ensure all stack examiners/editors are found 2) decrypt fully 3) encrypt using the stack address as the salt.
[ ] Debugging and Diagnostics Along with stack unwinding, we need to ensure that debugger can decrypt the function addresses (if needed) and display the debug info correctly.
[ ] NativeAOT / R2R Lastly, we definitely should validate the working of PAC with NativeAOT and R2R to guarantee that it will work as expected.
Stretch items
[ ] Harden the config variable. An attacker could potentially overwrite the config variable and disable PAC. Either always enable PAC if the hardware supports it or ensure the config variables moved to read-only memory after startup.
[ ] Harden hijacking stub addresses The addresses used in the return address hijacking should be kept in memory as encrypted values. When rewriting the stack, the value is loaded from memory to register, decrypted, encrypted again, then stored to the stack.
Possible future work items