Skip to content

[BOLT] Gadget scanner: reformulate the state for data-flow analysis #131898

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bolt/include/bolt/Core/MCPlusBuilder.h
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,16 @@ class MCPlusBuilder {
return Analysis->isReturn(Inst);
}

/// Returns the registers that are trusted at function entry.
///
/// Each register should be treated as if a successfully authenticated
/// pointer was written to it before entering the function (i.e. the
/// pointer is safe to jump to as well as to be signed).
virtual SmallVector<MCPhysReg> getTrustedLiveInRegs() const {
llvm_unreachable("not implemented");
return {};
}
Comment on lines +554 to +562
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if MCPlusBuilder is the right place for this to live...
The reason why I'm not sure is that to me it seems that MCPlusBuilder is mostly about querying the property of instructions, maybe at most relative to an assumed ABI.
It seems to me that getTrustedLiveInRegs might be encoding an assumed, implicit, threat model too?

Apologies for not explaining this very well. I'm just trying to make sure this function goes into the most appropriate place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the set of registers returned by getTrustedLiveInRegs on AArch64 can be derived from the fact that LR is set by branch-with-link instructions. To some extent, this does look more like a property of the ABI, but as far as I can see target-specific hooks are placed either to lib/Target/XYZ/XYZMCPlusBuilder.cpp or to lib/Target/XYZ/XYZMCSymbolizer.(h|cpp), so there doesn't seem to be many places where such target-specific hook can be defined.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, you're right, there aren't many places in Bolt that look target-specific... OK, let's just keep it in MCPlusBuilder.


virtual ErrorOr<MCPhysReg> getAuthenticatedReg(const MCInst &Inst) const {
llvm_unreachable("not implemented");
return getNoRegister();
Expand Down
7 changes: 3 additions & 4 deletions bolt/include/bolt/Passes/PAuthGadgetScanner.h
Original file line number Diff line number Diff line change
Expand Up @@ -212,17 +212,16 @@ struct GadgetReport : public Report {
// The particular kind of gadget that is detected.
const GadgetKind &Kind;
// The set of registers related to this gadget report (possibly empty).
SmallVector<MCPhysReg> AffectedRegisters;
SmallVector<MCPhysReg, 1> AffectedRegisters;
// The instructions that clobber the affected registers.
// There is no one-to-one correspondence with AffectedRegisters: for example,
// the same register can be overwritten by different instructions in different
// preceding basic blocks.
SmallVector<MCInstReference> OverwritingInstrs;

GadgetReport(const GadgetKind &Kind, MCInstReference Location,
const BitVector &AffectedRegisters)
: Report(Location), Kind(Kind),
AffectedRegisters(AffectedRegisters.set_bits()) {}
MCPhysReg AffectedRegister)
: Report(Location), Kind(Kind), AffectedRegisters({AffectedRegister}) {}

void generateReport(raw_ostream &OS, const BinaryContext &BC) const override;

Expand Down
166 changes: 110 additions & 56 deletions bolt/lib/Passes/PAuthGadgetScanner.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,16 @@ class TrackedRegisters {

// The security property that is checked is:
// When a register is used as the address to jump to in a return instruction,
// that register must either:
// (a) never be changed within this function, i.e. have the same value as when
// the function started, or
// that register must be safe-to-dereference. It must either
// (a) be safe-to-dereference at function entry and never be changed within this
// function, i.e. have the same value as when the function started, or
// (b) the last write to the register must be by an authentication instruction.

// This property is checked by using dataflow analysis to keep track of which
// registers have been written (def-ed), since last authenticated. Those are
// exactly the registers containing values that should not be trusted (as they
// could have changed since the last time they were authenticated). For pac-ret,
// any return instruction using such a register is a gadget to be reported. For
// PAuthABI, probably at least any indirect control flow using such a register
// should be reported.
// registers have been written (def-ed), since last authenticated. For pac-ret,
// any return instruction using a register which is not safe-to-dereference is
// a gadget to be reported. For PAuthABI, probably at least any indirect control
// flow using such a register should be reported.

// Furthermore, when producing a diagnostic for a found non-pac-ret protected
// return, the analysis also lists the last instructions that wrote to the
Expand All @@ -156,29 +154,62 @@ class TrackedRegisters {
// in the gadgets to be reported. This information is used in the second run
// to also track which instructions last wrote to those registers.

/// A state representing which registers are safe to use by an instruction
/// at a given program point.
///
/// To simplify reasoning, let's stick with the following approach:
/// * when state is updated by the data-flow analysis, the sub-, super- and
/// overlapping registers are marked as needed
/// * when the particular instruction is checked if it represents a gadget,
/// the specific bit of BitVector should be usable to answer this.
///
/// For example, on AArch64:
/// * An AUTIZA X0 instruction marks both X0 and W0 (as well as W0_HI) as
/// safe-to-dereference. It does not change the state of X0_X1, for example,
/// as super-registers partially retain their old, unsafe values.
/// * LDR X1, [X0] marks as unsafe both X1 itself and anything it overlaps
/// with: W1, W1_HI, X0_X1 and so on.
/// * RET (which is implicitly RET X30) is a protected return if and only if
/// X30 is safe-to-dereference - the state computed for sub- and
/// super-registers is not inspected.
struct State {
/// A BitVector containing the registers that have been clobbered, and
/// not authenticated.
BitVector NonAutClobRegs;
/// A BitVector containing the registers that are either safe at function
/// entry and were not clobbered yet, or those not clobbered since being
/// authenticated.
BitVector SafeToDerefRegs;
/// A vector of sets, only used in the second data flow run.
/// Each element in the vector represents one of the registers for which we
/// track the set of last instructions that wrote to this register. For
/// pac-ret analysis, the expectation is that almost all return instructions
/// only use register `X30`, and therefore, this vector will probably have
/// length 1 in the second run.
std::vector<SmallPtrSet<const MCInst *, 4>> LastInstWritingReg;

/// Construct an empty state.
State() {}

State(unsigned NumRegs, unsigned NumRegsToTrack)
: NonAutClobRegs(NumRegs), LastInstWritingReg(NumRegsToTrack) {}
State &operator|=(const State &StateIn) {
NonAutClobRegs |= StateIn.NonAutClobRegs;
: SafeToDerefRegs(NumRegs), LastInstWritingReg(NumRegsToTrack) {}

State &merge(const State &StateIn) {
if (StateIn.empty())
return *this;
if (empty())
return (*this = StateIn);

SafeToDerefRegs &= StateIn.SafeToDerefRegs;
for (unsigned I = 0; I < LastInstWritingReg.size(); ++I)
for (const MCInst *J : StateIn.LastInstWritingReg[I])
LastInstWritingReg[I].insert(J);
return *this;
}

/// Returns true if this object does not store state of any registers -
/// neither safe, nor unsafe ones.
bool empty() const { return SafeToDerefRegs.empty(); }

bool operator==(const State &RHS) const {
return NonAutClobRegs == RHS.NonAutClobRegs &&
return SafeToDerefRegs == RHS.SafeToDerefRegs &&
LastInstWritingReg == RHS.LastInstWritingReg;
}
bool operator!=(const State &RHS) const { return !((*this) == RHS); }
Expand All @@ -199,8 +230,12 @@ static void printLastInsts(

raw_ostream &operator<<(raw_ostream &OS, const State &S) {
OS << "pacret-state<";
OS << "NonAutClobRegs: " << S.NonAutClobRegs << ", ";
printLastInsts(OS, S.LastInstWritingReg);
if (S.empty()) {
OS << "empty";
} else {
OS << "SafeToDerefRegs: " << S.SafeToDerefRegs << ", ";
printLastInsts(OS, S.LastInstWritingReg);
}
OS << ">";
return OS;
}
Expand All @@ -217,10 +252,16 @@ class PacStatePrinter {
void PacStatePrinter::print(raw_ostream &OS, const State &S) const {
RegStatePrinter RegStatePrinter(BC);
OS << "pacret-state<";
OS << "NonAutClobRegs: ";
RegStatePrinter.print(OS, S.NonAutClobRegs);
OS << ", ";
printLastInsts(OS, S.LastInstWritingReg);
if (S.empty()) {
assert(S.SafeToDerefRegs.empty());
assert(S.LastInstWritingReg.empty());
OS << "empty";
} else {
OS << "SafeToDerefRegs: ";
RegStatePrinter.print(OS, S.SafeToDerefRegs);
OS << ", ";
printLastInsts(OS, S.LastInstWritingReg);
}
OS << ">";
}

Expand Down Expand Up @@ -257,14 +298,22 @@ class PacRetAnalysis

void preflight() {}

State getStartingStateAtBB(const BinaryBasicBlock &BB) {
return State(NumRegs, RegsToTrackInstsFor.getNumTrackedRegisters());
State createEntryState() {
State S(NumRegs, RegsToTrackInstsFor.getNumTrackedRegisters());
for (MCPhysReg Reg : BC.MIB->getTrustedLiveInRegs())
S.SafeToDerefRegs |= BC.MIB->getAliases(Reg, /*OnlySmaller=*/true);
return S;
}

State getStartingStateAtPoint(const MCInst &Point) {
return State(NumRegs, RegsToTrackInstsFor.getNumTrackedRegisters());
State getStartingStateAtBB(const BinaryBasicBlock &BB) {
if (BB.isEntryPoint())
return createEntryState();

return State();
}

State getStartingStateAtPoint(const MCInst &Point) { return State(); }

void doConfluence(State &StateOut, const State &StateIn) {
PacStatePrinter P(BC);
LLVM_DEBUG({
Expand All @@ -277,7 +326,7 @@ class PacRetAnalysis
dbgs() << ")\n";
});

StateOut |= StateIn;
StateOut.merge(StateIn);

LLVM_DEBUG({
dbgs() << " merged state: ";
Expand All @@ -297,8 +346,17 @@ class PacRetAnalysis
dbgs() << ")\n";
});

// If this instruction is reachable, a non-empty state will be propagated
// to it from the entry basic block sooner or later. Until then, it is both
// more efficient and easier to reason about to skip computeNext().
if (Cur.empty()) {
LLVM_DEBUG(
{ dbgs() << "Skipping computeNext(Point, Cur) as Cur is empty.\n"; });
return State();
}

State Next = Cur;
BitVector Written = BitVector(NumRegs, false);
BitVector Clobbered(NumRegs, false);
// Assume a call can clobber all registers, including callee-saved
// registers. There's a good chance that callee-saved registers will be
// saved on the stack at some point during execution of the callee.
Expand All @@ -307,36 +365,27 @@ class PacRetAnalysis
// Also, not all functions may respect the AAPCS ABI rules about
// caller/callee-saved registers.
if (BC.MIB->isCall(Point))
Written.set();
Clobbered.set();
else
// FIXME: `getWrittenRegs` only sets the register directly written in the
// instruction, and the smaller aliasing registers. It does not set the
// larger aliasing registers. To also set the larger aliasing registers,
// we'd have to call `getClobberedRegs`.
// It is unclear if there is any test case which shows a different
// behaviour between using `getWrittenRegs` vs `getClobberedRegs`. We'd
// first would like to see such a test case before making a decision
// on whether using `getClobberedRegs` below would be better.
// Also see the discussion on this at
// https://github.com/llvm/llvm-project/pull/122304#discussion_r1939511909
BC.MIB->getWrittenRegs(Point, Written);
Next.NonAutClobRegs |= Written;
BC.MIB->getClobberedRegs(Point, Clobbered);
Next.SafeToDerefRegs.reset(Clobbered);
// Keep track of this instruction if it writes to any of the registers we
// need to track that for:
for (MCPhysReg Reg : RegsToTrackInstsFor.getRegisters())
if (Written[Reg])
if (Clobbered[Reg])
lastWritingInsts(Next, Reg) = {&Point};

ErrorOr<MCPhysReg> AutReg = BC.MIB->getAuthenticatedReg(Point);
if (AutReg && *AutReg != BC.MIB->getNoRegister()) {
// FIXME: should we use `OnlySmaller=false` below? See similar
// FIXME about `getWrittenRegs` above and further discussion about this
// at
// https://github.com/llvm/llvm-project/pull/122304#discussion_r1939515516
Next.NonAutClobRegs.reset(
BC.MIB->getAliases(*AutReg, /*OnlySmaller=*/true));
if (RegsToTrackInstsFor.isTracked(*AutReg))
lastWritingInsts(Next, *AutReg).clear();
// The sub-registers of *AutReg are also trusted now, but not its
// super-registers (as they retain untrusted register units).
BitVector AuthenticatedSubregs =
BC.MIB->getAliases(*AutReg, /*OnlySmaller=*/true);
for (MCPhysReg Reg : AuthenticatedSubregs.set_bits()) {
Next.SafeToDerefRegs.set(Reg);
if (RegsToTrackInstsFor.isTracked(Reg))
lastWritingInsts(Next, Reg).clear();
}
}

LLVM_DEBUG({
Expand Down Expand Up @@ -397,14 +446,11 @@ shouldReportReturnGadget(const BinaryContext &BC, const MCInstReference &Inst,
});
if (BC.MIB->isAuthenticationOfReg(Inst, RetReg))
return nullptr;
BitVector UsedDirtyRegs = S.NonAutClobRegs;
LLVM_DEBUG({ traceRegMask(BC, "NonAutClobRegs at Ret", UsedDirtyRegs); });
UsedDirtyRegs &= BC.MIB->getAliases(RetReg, /*OnlySmaller=*/true);
LLVM_DEBUG({ traceRegMask(BC, "Intersection with RetReg", UsedDirtyRegs); });
if (!UsedDirtyRegs.any())
LLVM_DEBUG({ traceRegMask(BC, "SafeToDerefRegs", S.SafeToDerefRegs); });
if (S.SafeToDerefRegs[RetReg])
return nullptr;

return std::make_shared<GadgetReport>(RetKind, Inst, UsedDirtyRegs);
return std::make_shared<GadgetReport>(RetKind, Inst, RetReg);
}

FunctionAnalysisResult
Expand All @@ -425,6 +471,14 @@ Analysis::findGadgets(BinaryFunction &BF,
MCInstReference Inst(&BB, I);
const State &S = *PRA.getStateAt(Inst);

// If non-empty state was never propagated from the entry basic block
// to Inst, assume it to be unreachable and report a warning.
if (S.empty()) {
Result.Diagnostics.push_back(std::make_shared<GenericReport>(
Inst, "Warning: unreachable instruction found"));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I seem to remember that if you do this analysis on a large amount of code (for example all libraries in a linux distro), it will find unreachable code.
I'm happy to keep this warning, but it might be that this diagnostic turns out to be too noisy when scanning large amounts of code....
We can fix/tune that in another PR though...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial intention was not to skip instructions silently (in case it is skipped due to some error in PacRetAnalysis). On one hand, this should be caught by the tests, on the other hand, this warning helps implementing the tests. Anyway, this should be trivial to remove this warning later.

continue;
}

if (auto Report = shouldReportReturnGadget(BC, Inst, S))
Result.Diagnostics.push_back(Report);
}
Expand Down
4 changes: 4 additions & 0 deletions bolt/lib/Target/AArch64/AArch64MCPlusBuilder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ class AArch64MCPlusBuilder : public MCPlusBuilder {
return false;
}

SmallVector<MCPhysReg> getTrustedLiveInRegs() const override {
return {AArch64::LR};
}

ErrorOr<MCPhysReg> getAuthenticatedReg(const MCInst &Inst) const override {
switch (Inst.getOpcode()) {
case AArch64::AUTIAZ:
Expand Down
29 changes: 18 additions & 11 deletions bolt/test/binary-analysis/AArch64/gs-pacret-autiasp.s
Original file line number Diff line number Diff line change
Expand Up @@ -183,17 +183,14 @@ f_tail_called:
.globl f_nonx30_ret_non_auted
.type f_nonx30_ret_non_auted,@function
f_nonx30_ret_non_auted:
// FIXME: x1 is not authenticated, so should this be reported?
// Note that we assume it's fine for x30 to not be authenticated before
// returning to, as assuming that x30 is not attacker controlled at function
// entry is part (implicitly) of the pac-ret hardening scheme.
// It's probably an open question whether for other hardening schemes, such as
// PAuthABI, which registers should be considered "clean" or not at function entry.
// In other words, which registers have to be authenticated before being used as
// a pointer and which ones not?
// For a more detailed discussion, see
// https://github.com/llvm/llvm-project/pull/122304#discussion_r1923662744
// CHECK-NOT: f_nonx30_ret_non_auted
// x1 is neither authenticated nor implicitly considered safe at function entry.
// Note that we assume it's fine for x30 to not be authenticated before
// returning to, as assuming that x30 is not attacker controlled at function
// entry is part (implicitly) of the pac-ret hardening scheme.
//
// CHECK-LABEL: GS-PAUTH: non-protected ret found in function f_nonx30_ret_non_auted, basic block {{[0-9a-zA-Z.]+}}, at address
// CHECK-NEXT: The instruction is {{[0-9a-f]+}}: ret
// CHECK-NEXT: The 0 instructions that write to the affected registers after any authentication are:
ret x1
.size f_nonx30_ret_non_auted, .-f_nonx30_ret_non_auted

Expand Down Expand Up @@ -230,6 +227,16 @@ f_callclobbered_calleesaved:
ret x19
.size f_callclobbered_calleesaved, .-f_callclobbered_calleesaved

.globl f_unreachable_instruction
.type f_unreachable_instruction,@function
f_unreachable_instruction:
// CHECK-LABEL: GS-PAUTH: Warning: unreachable instruction found in function f_unreachable_instruction, basic block {{[0-9a-zA-Z.]+}}, at address
// CHECK-NEXT: The instruction is {{[0-9a-f]+}}: add x0, x1, x2
b 1f
add x0, x1, x2
1:
ret
.size f_unreachable_instruction, .-f_unreachable_instruction

/// Now do a basic sanity check on every different Authentication instruction:

Expand Down
3 changes: 1 addition & 2 deletions bolt/test/binary-analysis/AArch64/gs-pacret-multi-bb.s
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ f_crossbb1:
.size f_crossbb1, .-f_crossbb1
// CHECK-LABEL: GS-PAUTH: non-protected ret found in function f_crossbb1, basic block {{[^,]+}}, at address
// CHECK-NEXT: The instruction is {{[0-9a-f]+}}: ret
// CHECK-NEXT: The 2 instructions that write to the affected registers after any authentication are:
// CHECK-NEXT: The 1 instructions that write to the affected registers after any authentication are:
// CHECK-NEXT: 1. {{[0-9a-f]+}}: ldp x29, x30, [sp], #0x10
// CHECK-NEXT: 2. {{[0-9a-f]+}}: autiasp

// A test that checks that the dataflow state tracking across when merging BBs
// seems to work:
Expand Down
Loading