Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/backtrace/libunwind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ impl Frame {
// clause, and if this is fixed that test in theory can be run on macOS!
if cfg!(target_vendor = "apple") {
self.ip()
} else if cfg!(target_abi = "pauthtest") {
// NOTE: `_Unwind_FindEnclosingFunction` creates a fresh unwind
// cursor and, on the pointer-authentication-enabled AArch64
// reference ABI, authenticates/re-signs the supplied IP using that
// cursor's SP. The original frame's SP is not available through
// this API, so the current libunwind implementation cannot be used
// here: it would attempt to authenticate the IP using the wrong SP.
// Return the raw instruction pointer instead.
self.ip()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is ip not signed? It is directly the result of _Unwind_GetIP. It should be possible to pass that to _Unwind_FindEnclosingFunction. How else would you even be able to use _Unwind_FindEnclosingFunction otherwise?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Why is ip not signed? It is directly the result of _Unwind_GetIP.

A raw ip is expected. _Unwind_GetIP authenticates the pointer and returns a stripped version (via ptrauth_auth_data here).

It should be possible to pass that to _Unwind_FindEnclosingFunction. How
else would you even be able to use _Unwind_FindEnclosingFunction otherwise?

This is the crux of the issue. _Unwind_FindEnclosingFunction is fundamentally incompatible with the pointer authentication model. The problem is that _Unwind_FindEnclosingFunction does not follow PAC semantics, it blindly creates a fresh unwind cursor and then resets the instruction pointer here: __unw_set_reg(cursor, UNW_REG_IP, pc);. As if to say: "treat this pc as if it belonged to this new cursor's frame".

__unw_set_reg does apply PAC logic internally (authenticates) under the assumption that the ip behaves like a return address for the current frame.

This cannot be fixed on the Rust side. _Unwind_FindEnclosingFunction constructs a new cursor (and therefore a new sp), so there is no way to correctly sign pointer: any signing we would perform would use the wrong context and would still fail authentication inside libunwind.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That sounds like a bug in libunwind to me. What purpose does _Unwind_FindEnclosingFunction have at all if you can't pass the result of _Unwind_GetIP to it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm not sure, the documentation seems to be pretty scarce, and one I found is rather quite lax about possible outcomes of the call, explicitly allowing failure.

I personally don't read it as a bug. To me it is more that _Unwind_FindEnclosingFunction was designed before pointer authentication existed, and its API implicitly assumes that instruction pointers are context-free values. But in PAC-aware code PC is only meaningful in the context of the (original) SP.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why does _Unwind_FindEnclosingFunction need an authenticated pointer? It doesn't dereference the pointer, only looks it up in a side table with the result not having any security relevance. The only useful thing you can do with it afaik is looking up debuginfo for the function. Due to inlining and outlining you can't guarantee that it points to any particular function.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Please let me know if my description of why uw::_Unwind_FindEnclosingFunction is incompatible with pointer authentication is insufficient. Alternatively, if you have any ideas on how it should be addressed, I would be very keen to hear them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@bjorn3 If you have any questions related to how pointer authentication is working in terms of libunwind, I would be happy to answer - I was previously working on that from Linux perspective, while most work was done by folks from Apple.

I confirm @jchlanda 's reasoning in the thread above and I think that it should be good enough to just return self.ip() for pauthtest target.

Also note that the signing/auehtntication behavior from libunwind is mostly shared between Apple's arm64e and Linux's pauthtest (when not platform-specific, e.g. personality pointers are stored and signed differently on Darwin and Linux). So, while I do not believe that we have any bug in libunwind's behavior and API design, even if we do - it would require a big discussion involving folks from Apple. And at this point, it's probably better just to conform to status-quo here in backtrace-rs.

So, we would very much appreciate your feedback on this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think using self.ip() here will break the internal frame omission for backtraces:

backtrace-rs/src/capture.rs

Lines 300 to 303 in 06b2cc5

// clear inner frames, and start with call site.
if frame.symbol_address() as usize == ip {
frames.clear();
}

And in tokio: https://github.com/tokio-rs/tokio/blob/dac81bf8c8de0a3e35f1626643674ba9faf9569c/tokio/src/runtime/task/trace/trace_impl.rs#L16

I think it would also effectively break the scoped-trace crate: https://github.com/jswrenn/scoped-trace/blob/e299c46905a2063957414c34d76264281852461b/src/lib.rs#L126

Is there really no way to retain this support? Even if just stripping the pointer signature? _Unwind_FindEnclosingFunction can be fixed to accept arbitrary pointer signatures without compromising security, right?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I will need to dig into it again to provide a more detailed answer, but for now, a couple of quick observations:

  • returning self.ip() is exactly what happens on Apple, so the precedence is already there;
  • I'm not familiar with neither Tokio nor scoped-trace, so I can't comment on their relative importance in the wider ecosystem. What I can say with confidence, though, is that the current implementation (uw::_Unwind_FindEnclosingFunction(self.ip())) causes every program compiled for aarch64-unknown-linux-pauthtest that relies on backtrace-rs to encounter a pointer authentication failure. As a first approximation, that's every program that panics.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@bjorn3 I spent some time looking into this issue again.

To sum up, there are two stages:

  1. backtrace-rs obtains the instruction pointer.
    This is done with _Unwind_GetIP(context). On PAC-enabled AArch64, libunwind authenticates the stored PC and returns an instruction pointer value associated with the original frame context (including the frame's SP-based PAC discriminator). That value is what we keep in Frame::ip().
uint64_t Registers_arm64::getIP() const {
  uint64_t value = _registers.__pc;

  value = (uint64_t)ptrauth_auth_and_resign(
      (void *)_registers.__pc,
      ptrauth_key_return_address,
      &_registers.__pc,
      ptrauth_key_return_address,
      getSP());

  return value;
}
  1. backtrace-rs calls _Unwind_FindEnclosingFunction.
    The expectation is that libunwind returns the start address of the enclosing function. This call is implemented as:
    unw_cursor_t cursor;
    unw_context_t uc;
    unw_proc_info_t info;
    __unw_getcontext(&uc);
    __unw_init_local(&cursor, &uc);
    __unw_set_reg(&cursor, UNW_REG_IP, (unw_word_t)(intptr_t)pc);
    if (__unw_get_proc_info(&cursor, &info) == UNW_ESUCCESS)
        return (void *)(intptr_t) info.start_ip;

The root cause of the issue is in this snippet: the cursor is not the original frame that produced pc. It is a brand new unwind cursor initialized from the current thread context.

On PAC-enabled AArch64, the instruction pointer returned by _Unwind_GetIP() is not simply an arbitrary integer address. Although it is suitable for use as a code address by callers, it is still a value whose PAC validity depends on the original frame context (in particular, the SP discriminator used by the PAC scheme). When _Unwind_FindEnclosingFunction() imports this IP into its newly created cursor, it goes through __unw_set_reg(), which calls the AArch64 implementation of Registers_arm64::setIP(value):

value = (uint64_t)ptrauth_auth_and_resign(
    (void *)value,
    ptrauth_key_return_address,
    getSP(),
    ptrauth_key_return_address,
    &_registers.__pc);

_registers.__pc = value;

getSP() here returns the SP stored in the newly created cursor. It is not the SP belonging to the frame from which _Unwind_GetIP() obtained the instruction pointer. Therefore, setIP() attempts to authenticate an IP that belongs to the original frame using the SP of a different frame. The authentication step fails because the PAC discriminator does not match; the issue is not that the IP lacks a signature, but that it is being reintroduced into a different unwind context.

Is there really no way to retain this support?

Not from backtrace-rs, as far as I can see. By the time we call _Unwind_FindEnclosingFunction, all we have is the instruction pointer returned by _Unwind_GetIP(). _Unwind_FindEnclosingFunction immediately constructs its own unwind cursor and, as we know now, this must fail authentication - since the original SP is no longer available. And because that cursor is created entirely inside libunwind, backtrace-rs has no opportunity to provide the original SP that was associated with the instruction pointer returned by _Unwind_GetIP(). From our side, there doesn't appear to be any way to make this call succeed.

Even if just stripping the pointer signature?

_Unwind_GetIP() already returns the public instruction pointer representation expected by callers. The problem is not that backtrace-rs passes an unstripped/signed pointer incorrectly; the problem is that _Unwind_FindEnclosingFunction() tries to import that pointer into a new unwind context with a different SP, eventually calls Registers_arm64::setIP(), which authenticates/re-signs the supplied value using the SP of the newly-created cursor.

_Unwind_FindEnclosingFunction can be fixed to accept arbitrary pointer
signatures without compromising security, right?

I think it should be possible. I earlier said that _Unwind_FindEnclosingFunction is fundamentally incompatible with PAC enabled arch, that is probably not fully correct, I should have said: the current implementation in LLVM's libunwind doesn't work for this use case.

For example, if libunwind implemented _Unwind_FindEnclosingFunction without synthesizing a fresh unwind cursor, or otherwise avoided routing through Registers_arm64::setIP(), then the problem would not manifest. I am not in position to say if either of those is possible to implement.

And finally, from the perspective of backtrace-rs, the current implementation aborts on aarch64-unknown-linux-pauthtest, and we don't have enough information (most notably the original frame's SP) to make this call succeed with the current API.

Therefore, the only implementation that works reliably for pauthtest today is to use the instruction pointer already returned by _Unwind_GetIP() (self.ip()), which is also what backtrace-rs already does on Apple targets.

} else {
unsafe { uw::_Unwind_FindEnclosingFunction(self.ip()) }
}
Expand Down
6 changes: 6 additions & 0 deletions tests/skip_inner_frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const ENABLED: bool = cfg!(all(
target_os = "linux",
// On ARM finding the enclosing function is simply returning the ip itself.
not(target_arch = "arm"),
// On `aarch64-unknown-linux-pauthtest` `_Unwind_FindEnclosingFunction`
// cannot be used safely, because instruction pointers are not in a form
// suitable for authentication/resigning, so `symbol_address()` returns the
// raw IP instead. In this case the returned address may be inside the
// function body rather than at its entry.
not(target_env = "pauthtest"),
));

#[test]
Expand Down
12 changes: 11 additions & 1 deletion tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ fn get_actual_fn_pointer(fp: *mut c_void) -> *mut c_void {
}

#[test]
// This test relies on recovering precise symbol addresses from instruction
// pointers. On `aarch64-unknown-linux-pauthtest`, we cannot safely use
// `_Unwind_FindEnclosingFunction`, and instead fall back to using raw
// instruction pointers. As a result, symbol resolution cannot reliably
// recover a canonical function entry address, and `sym.addr()` will often be
// `None`. Therefore this test is disabled.
#[cfg_attr(target_env = "pauthtest", ignore)]
// FIXME: shouldn't ignore this test on i686-msvc, unsure why it's failing
#[cfg_attr(all(target_arch = "x86", target_env = "msvc"), ignore)]
#[inline(never)]
Expand Down Expand Up @@ -310,7 +317,10 @@ fn sp_smoke_test() {
let r = refs.pop().unwrap();
eprintln!("ref = {:p}", r);
if sp as usize != 0 {
assert!(r > sp);
// Stack grows down, however stack slots can be reused and
// frame pointers ommited, `r == sp` should be a valid edge
// case.
assert!(r >= sp);
if let Some(child_ref) = child_ref {
assert!(sp >= child_ref);
}
Expand Down
Loading