Intro
Hello everyone!
You’ve probably heard that Rust is a memory-safe language, where many traditional exploitation techniques no longer apply.
But things get interesting when a program panics.
In this post, we’ll take a look at Rust’s panic mechanism and see how abusing the panic hook can turn a crash into a shell.
What is a Rust panic?
First, we take a look at Rust panic.
In Rust, a panic occurs when the program can no longer continue running safely.
For example, a panic can be triggered by a failed unwrap() or expect(), or by an explicit call to panic!() by the developer.
Panic is different from normal error handling using Result and Option. While error handling is meant for recoverable errors, panic represents a state where the program cannot continue running normally. With the default configuration, a panic prints an error message and terminates the program with a non-zero exit code (typically 101).
Because of this, when a panic occurs in Rust, the program’s control flow diverges from its normal execution path. Instead of returning from functions as usual, the runtime takes over and begins executing the panic handling routine.
In most cases, panic is simply viewed as a way to terminate the program. Once a panic happens, the program prints an error message and exits, and developers rarely think about what happens beyond that point.
However, when we take a closer look at what happens internally, the panic process reveals a surprisingly interesting execution flow. Before the program actually terminates, several runtime components are involved in handling the panic.
This internal flow is not something developers usually need to consider during normal development, but it becomes highly relevant from an exploitation perspective.
The official Rust documentation also describes panic as a runtime mechanism that stops program execution, but it does not go into detail about how this process unfolds internally.
Reference: Rust’s Panic documentation
What happens when Rust panics?
When a panic occurs, the program does not exit immediately. Instead, the Rust runtime begins executing a series of panic-handling steps.
Here’s a simplified view of the call flow (symbol names may vary by build):
std::panicking::begin_panic()
└─ std::sys::backtrace::__rust_end_short_backtrace()
  └─ std::panicking::begin_panic::{{closure}}()
    └─ std::panicking::rust_panic_with_hook()
Looking at the execution flow, all functions leading up to rust_panic_with_hook() act as thin wrappers.
The actual panic behavior is determined inside rust_panic_with_hook().
As the name implies, this function controls the panic path based on the presence of a panic hook.
To understand why this matters, we need to look at this function in detail.
Panic hook explained
Now, let’s dive into the internal of rust_panic_with_hook().
|
|
The first operation performed by this function is a call to std::panicking::panic_count::increase().
The implementation of increase() look as follows.
|
|
The call to _InterlockedIncrement64() increments std::panicking::panic_count::GLOBAL_PANIC_COUNT and returns the incremented value. This value is then checked determine whether the current thread is already in a panicking state.
The check at v3 - 0x20 corresponds to thread::panicking().
In other words, Rust verifies whether a panic has already occurred on the current thread. If this is the first panic, the thread is marked as panicking, and the function returns a value indicating “first panic” (here, 2).
The return value of increase() determines how rust_panic_with_hook() proceeds. There are three possible cases.
- 0 or 1
The panic message is constructed, printed, and the process exits. - 2
This indicates the first panic on the current thread. In this case, Rust proceeds to invoke the panic hook.
In the following analysis, we focus on the case where the return value is 2. The code path is shown below.
|
|
The code first checks whether std::panicking::HOOK[0] exceeds 0x3FFFFFFD.
If the value is within range, it attempts to increment the hook counter using an atomic compare-and-exchange (CAS), which serves as the fast path.
If the value exceeds the limit or the CAS operation fails due to concurrent modification, execution fails back to RwLock::read_contended(), the slow path used to acquire the read lock under contention.
The code then checks the value of std::panicking::HOOK[4].
- If
HOOK[4]is NULL, Rust callsdefault_hook(). - If
HOOK[4]is non-zero, Rust performs an indirect call:(*(HOOK[6] + 0x28))(HOOK[4]).
The HOOK array resides in the .bss section and is writable at runtime. This means that if an attacker processes an Arbitrary Address Write (AAW) primitive, they can overwrite the fields inside std::panicking::HOOK and redirect execution during panic handling.
In other words, a panic can be turned into a reliable control-flow transfer point.
Before moving on, let’s briefly observe the difference between cases where a panic hook is registered and where is it not, using a simple example program.
The following example programs are used to compare the behavior with and without a panic hook.
- set_hook version
|
|
- non-hook version
|
|
We’ll begin by analyzing the non-hook version first.
Before starting the binary, we set a breakpoint at std::panicking::begin_panic():
b std::panicking::begin_panic. After running the binary, execution stops at the breakpoint.

From here, we keep stepping through the call chain until execution reaches rust_panic_with_hook().

Inside the function, the first call instruction invokes std::panicking::panic_count::increase().
Examining the return value shows that it is 2. As discussed earlier, this causes execution to enter the panic hook handling path.

Within the hook handling logic, the code first checks whether HOOK[0] exceeds 0x3FFFFFFD, and then attempts to increment the counter.

Inspecting the HOOK array at this point shows that the value has been incremented as expected.

Since HOOK[4] is NULL in this case, the default panic hook is invoked.
The error message is printed, and the binary exits.

Next, we analyze the binary compiled with panic::set_hook().
The execution flow is largely identical up to this point.
However, when the code checks the value of HOOK[4], we observe that it is now set to 1.

Because HOOK[4] is non-zero, execution follows the custom hook path. As a result, the user-defined panic hook is executed instead of the default hook.

Reference: Rust Panic Hook documentation
Turning Panic into Control Flow
Based on the analysis so far, we now assume that an AAW primitive available and attempt to turn it into control-flow hijacking.
The following program will be used for the demonstration.
|
|
This program takes two inputs from the user:
- an address (
addr) - a byte sequence (
value)
It then performs the equivalent of memcpy(addr, value, 0x20), followed by a call to panic!().
Before overwriting the hook, we first need its address. This can be obtained either by inspecting the binary in IDA or by resolving the symbol in GDB.
In this case, the address of std::panicking::HOOK is 0x45BA18

Now that we know the hook address, we can construct a proof-of-concept payload that redirects execution to an arbitrary address. As a test, we set RIP to 0xdeadbeefcafebabe.
The poc script is shown below.
|
|
After running the poc script, we examine the state of the HOOK structure and verify that RIP is successfully redirected.
As shown below, a fake panic hook is written into the HOOK structure, and the indirect call invokes 0xdeadbeefcafebabe.

At this point, we have successfully transformed an AAW into an Arbitrary Address Call.
From Panic to Shell
After achieving reliable RIP control, we proceed to turn it into shell execution.
To spawn a shell, we need to invoke system("sh").
As discussed earlier, when a panic hook is present, the call site takes the following form:
|
|
This means that:
- the function pointer is derived from
HOOK[6] - the argument is taken from
HOOK[4]
Therefore, by setting:
- the call target to
system - the argument to a pointer to
"sh"
With this setup, shell execution becomes possible.
Since calling system() requires its runtime address, the program is slightly modified to resolve it via dlsym().
|
|
The exploit script is shown below.
|
|
As shown below, execution reaches system("sh").

A shell successfully spawned.

Summary
In this post, we explored how Rust’s panic hook mechanism can be abused to achieve an Arbitrary Address Call.
By overwriting the panic hook structure, we showed that an Arbitrary Address Write can be reliably transformed into control-flow hijacking.
Because panic handling is a core part of Rust’s runtime, panic-related structures are always present in compiled binaries.
In particular, the panic hook resides in the .bss section and remains writable during execution.
As a result, when an Arbitrary Address Write primitive is available, the panic hook can serve as a reliable exploitation target.
Through this, we confirmed that runtime behavior in Rust can be leveraged for exploitation.