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
11 changes: 11 additions & 0 deletions src/backtrace/libunwind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ 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.
// Corresponding LLVM libunwind issue:
// https://github.com/llvm/llvm-project/issues/223698
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.

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.

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.

cc @bjorn3 would you agree that this is the best option available to us?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The changes and comments added in this PR read as though this is just the way it has to be for pauthtest.

However, it seems to me that _Unwind_FindEnclosingFunction is implemented wrong, and if it were fixed then the changes in this PR would not be needed (but I don't know much about libunwind and even less about pointer authentication).

If that's the case, then this PR is adding a workaround that is the same as what we do for Apple, so that seems fine, but maybe it would be better if the comments indicated it is only a workaround. For example, instead of long explanations in the tests, it would be better as short note that it is a workaround for _Unwind_FindEnclosingFunction with a link to an issue.

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've created an upstream libunwind issue here: llvm/llvm-project#223698
I'm not certain that it is genuinely a libunwind bug though. There was somewhat similar discussion on an unrelated PR: https://github.com/llvm/llvm-project/pull/202772/changes#r3633237542

One could argue that the reasoning behind that comment:

If you are fabricating a new sp that means you are taking on responsibility for constructing that new sp value correctly. Removing that requirement means that the stack pointer returned by __unw_get_reg would necessarily have lost its PAC signature.

Should be applied here as well.

} 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_abi = "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_abi = "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);
Comment thread
ChrisDenton marked this conversation as resolved.
if let Some(child_ref) = child_ref {
assert!(sp >= child_ref);
}
Expand Down
Loading