Skip to content

Commit d2141bc

Browse files
committed
[BOLT] Added docs/PacRetDesign.md
1 parent 3b73dda commit d2141bc

File tree

1 file changed

+154
-0
lines changed

1 file changed

+154
-0
lines changed

bolt/docs/PacRetDesign.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Optimizing binaries with pac-ret hardening
2+
3+
This is a design document about processing the `DW_CFA_AARCH64_negate_ra_state` DWARF instruction in BOLT. As is describes internal design decisions, the intended audience is BOLT developers. The document is an updated version of the [RFC posted on the LLVM Discourse](https://discourse.llvm.org/t/rfc-bolt-aarch64-handle-opnegaterastate-to-enable-optimizing-binaries-with-pac-ret-hardening/86594).
4+
5+
6+
`DW_CFA_AARCH64_negate_ra_state` is also referred to as `.cfi_negate_ra_state` in assembly, or `OpNegateRAState` is BOLT sources. In this document, I will use **negate-ra-state** as a shorthand.
7+
8+
## Introduction
9+
10+
### Pointer Authentication
11+
12+
Refer to the [pac-ret section of the BOLT-binary-analysis document](BinaryAnalysis.md#pac-ret-analysis).
13+
14+
### DW_CFA_AARCH64_negate_ra_state
15+
16+
The negate-ra-state CFI is a vendor-specific Call Frame Instruction defined in the [Arm ABI](https://github.com/ARM-software/abi-aa/blob/main/aadwarf64/aadwarf64.rst#id1).
17+
18+
```
19+
The DW_CFA_AARCH64_negate_ra_state operation negates bit[0] of the RA_SIGN_STATE pseudo-register.
20+
```
21+
22+
This bit indicates to the unwinder whether the current return address is signed or not (hence the name). The unwinder uses this information to authenticate the pointer, and remove the Pointer Authentication Code (PAC) bits. Incorrect negate-ra-state placement can lead to the unwinder trying to authenticate an unsigned pointer (which segfaults), or skipping authenticating a signed pointer, and trying to access an incorrect location (also leading to a segfault).
23+
24+
(Note: not *all* unwinders do this. Some use the `xpac` instruction to strip the PAC bits without authenticating the pointer. This is incorrect, as it allows control-flow modification in the case of unwinding.)
25+
26+
There are no DWARF instructions to directly set or clear the RA State. However, two other CFIs can also affect the RA state:
27+
- `DW_CFA_remember_state`: this CFI stores register rules onto an implicit stack.
28+
- `DW_CFA_restore_state`: this CFI pops rules from this stack.
29+
30+
Example:
31+
32+
| CFI | Effect on RA state |
33+
| ------------------------------ | ------------------------------ |
34+
| (default) | 0 |
35+
| DW_CFA_AARCH64_negate_ra_state | 0 -> 1 |
36+
| DW_CFA_remember_state | 1 pushed to the stack |
37+
| DW_CFA_AARCH64_negate_ra_state | 1 -> 0 |
38+
| DW_CFA_restore_state | 0 -> 1 (popped from the stack) |
39+
40+
The Arm ABI also defines the DW_CFA_AARCH64_negate_ra_state_with_pc CFI, but it is not widely used, and is [likely to become deprecated](https://github.com/ARM-software/abi-aa/issues/327).
41+
42+
### Where are these CFIs needed?
43+
44+
In all locations, where two consecutive instructions have different RA state, this need to be indicated to the unwinder. This happens at pointer signing and authenticating. The other case where two consecutive instructions have different RA state, but neither of them is signing or authenticating means that they are not next to each other in control flow. One is part of an execution path with signed RA, the other is part of a path with an unsigned RA.
45+
46+
In the example below, the first BasicBlock ends in a conditional branch, and jumps to two different BasicBlocks, each with their own authentication, and return. The instructions on the border of the second and third BasicBlock have different RA states. The `ret` at the end of the second BasicBlock is in unsigned state. The start of the third BasicBlock is after the `paciasp` in the control flow, but before the authentication. In this case, a negate-ra-state is needed at the end of the second BasicBlock.
47+
48+
```
49+
+----------------+
50+
| paciasp |
51+
| |
52+
| b.cc |
53+
+--------+-------+
54+
|
55+
+----------------+
56+
| |
57+
| +--------v-------+
58+
| | |
59+
| | autiasp |
60+
| | ret | // RA: unsigned
61+
| +----------------+
62+
+----------------+
63+
|
64+
+--------v-------+ // RA: signed
65+
| |
66+
| autiasp |
67+
| ret |
68+
+----------------+
69+
```
70+
71+
> [!important]
72+
> The unwinder does not follow the control flow graph. It reads unwind information in the layout order.
73+
74+
Because these locations are dependent on how the function layout looks, negate-ra-state CFIs will become invalid during BasicBlock reordering.
75+
76+
## Solution design
77+
78+
The patch introduces two new passes:
79+
1. `MarkRAStatesPass`: assigns the RA state to each instruction based on the CFIs in the input binary
80+
2. `InsertNegateRAStatePass`: reads those assigned instruction RA states after optimizations, and emits `DW_CFA_AARCH64_negate_ra_state` CFIs at the correct places: wherever there is a state change between two consecutive instructions in the layout order.
81+
82+
To track metadata on individual instructions, the `MCAnnotation` class was extended. These also have helper function in `MCPlusBuilder`.
83+
84+
### Saving annotations at CFI reading
85+
86+
CFIs are read and added to BinaryFunctions in `CFIReaderWriter::FillCFIInfoFor`. At this point, we add MCAnnotations about negate-ra-state, remember-state and restore-state CFIs to the instructions they refer to. This is to not interfere with the CFI processing that already happens in BOLT (e.g. remember-state and restore-state CFIs are removed in `normalizeCFIState` for reasons unrelated to PAC).
87+
88+
As we add the MCAnnotations *to instructions*, we have to account for the case where the function starts with a CFI altering the RA state. If a function starts with a negate-ra-state CFI for example, we cannot save the annotation on the first instruction, because that itself should already be signed. This is why all BinaryFunctions have an `initialRAState` bool. If the `Offset` the CFI refers to is zero, we don't store an annotation, but set the `initialRAState` in `FillCFIInfoFor`. This info is then used in `MarkRAStates`.
89+
90+
### Binaries without DWARF info
91+
92+
In some cases, the DWARF tables are stripped from the binary. These programs usually have some other unwind-mechanism. To account for code that uses Pointer Authentication, but does not have DWARF CFIs, the passes only run on functions that had at least one negate-ra-state CFI. This is marked during CFI reading.
93+
94+
This also makes sure that the passes don't run on functions that do not store the return address to the stack, and don't need Pointer Authentication, saving on runtime overhead.
95+
96+
In summary:
97+
- pointer auth is not used: no change, the new passes do not run.
98+
- pointer auth is used, but DWARF info is stripped: no change, the new passes do not run.
99+
- pointer auth is used, and we have DWARF CFIs: passes run, and rewrite the negate-ra-state CFI.
100+
101+
### MarkRAStates Pass
102+
103+
This pass runs before optimizations reorder anything.
104+
105+
It processes MCAnnotations generated during the CFI reading stage to check if instructions have either of the three CFIs that can modify RA state:
106+
- negate-ra-state
107+
- remember-state
108+
- restore-state
109+
110+
Then it adds new MCAnnotations to each instruction, indicating their RA state. Those annotations are:
111+
- Signed
112+
- Unsigned
113+
114+
Below is a simple example, that shows the two different type of annotations: what we have before the pass, and after it.
115+
116+
| Instruction | Before | After |
117+
| --------------------------- | --------------- | -------- |
118+
| paciasp | negate-ra-state | unsigned |
119+
| stp x29, x30, [sp, #-0x10]! | | signed |
120+
| mov x29, sp | | signed |
121+
| ldp x29, x30, [sp], #0x10 | | signed |
122+
| autiasp | negate-ra-state | signed |
123+
| ret | | unsigned |
124+
125+
##### Error handling in MarkRAState Pass:
126+
127+
Whenever the MarkRAStates pass finds inconsistencies in the current BinaryFunction, it ignores it by calling `BF.setIgnored()`. This prevents BOLT from optimizing that function, but it will still be emitted as part of the original section (`.bolt.org.text`) in its original form.
128+
129+
The inconsistencies are as follows:
130+
- finding a `pac*` instruction when already in signed state
131+
- finding an `aut*` instruction when already in unsigned state
132+
- finding `pac*` and `aut*` instructions without `.cfi_negate_ra_state`.
133+
134+
Users will be informed about the number of ignored function in the pass, and the exact functions ignored.
135+
136+
### InsertNegateRAStatePass
137+
138+
This pass runs after the optimizations are done. In essence, it does the _inverse_ of MarkRAState pass:
139+
1. it reads the RA state annotations attached to the instructions, and
140+
2. whenever the state changes, it adds a PseudoInstruction that holds an OpNegateRAState CFI.
141+
142+
##### Covering newly generated instructions:
143+
144+
Some BOLT passes can add new Instructions. In InsertNegateRAStatePass, we have to know what RA state these have.
145+
146+
The current solution has the `inferUnknownStates` function to cover these, using a fairly simple strategy: unknown states are inherited from last known state. Testing so far has shown this implementation is sufficient, but to prove correctness, we would need to examine all passes that insert new instructions.
147+
148+
### Optimizations requiring special attention
149+
150+
Marking states before optimizations assure that instructions can be moved around freely. The only special case is function splitting. When a function is split, the split part becomes a new function in the emitted binary. For unwinding to work, it needs to "replay" all CFI that lead up to the split point. BOLT does this for other CFIs. As negate-ra-state is not read (only stored as an Annotation), we have to do this "manually" in InsertNegateRAStatePass. Here, if the split part starts with an instruction that has Signed RA state, we add a negate-ra-state CFI to indicate this.
151+
152+
## Option to disallow the feature
153+
154+
To aid debugging, we added the `--disallow-pacret` flag. If the flag is used, and a function `containedNegateRAState()` after `FillCFIInfoFor()`, BOLT exits with an error. With this flag, the feature is on by default.

0 commit comments

Comments
 (0)