栈回溯技术能实现无泄漏的代码执行
摘要
本文探讨了在glibc中使用DWARF字节码进行栈回溯如何被利用来执行代码,并通过CTF挑战和对POSIX系统中线程取消机制的分析加以阐释。
<p><a href="https://lobste.rs/s/0d6gcs/stack_unwinding_can_lead_leakless_code">评论</a></p>
查看缓存全文
缓存时间: 2026/09/22 02:32
来源:https://pepsipu.com/blog/2026-09-dwarf/
## 简介
几年前,我为 DiceCTF 2024 撰写了 boogie-woogie(https://heinen.dev/posts/boogie-woogie/)。其核心思路是将 `.data` 段中的相对字节交换转换为代码执行。该挑战本身并非本文重点,但它启发了 Enzo Cut(https://enzo.run/)为 LA CTF 2024 创作的 woogie-boogie(https://enzo.run/posts/lactf2024/)。该挑战同样使用了字节交换原语,但基于栈进行。近期 Enzo 向我展示了解题报告,我对 unvariant(https://unvariant.pages.dev/)的解法感到惊讶与兴趣:
> 还存在其他解法,例如利用 libc 的 `read` 返回 `main` 函数——因其调用了 `__libc_enable_asynccancel`,该函数最终会调用一个可被交换的混淆指针(感谢 unvariant 发现此方法)。这在栈上留下一个返回地址,允许你控制 `read` 的参数。
`read` 函数不仅仅是系统调用的包装器?这让我感到意外。
## 线程取消
`read` 是 POSIX 的“取消点”。当线程 A 对线程 B 调用 `pthread_cancel(thread)` 时,即请求线程 B 终止。但默认情况下,POSIX 线程取消是延迟执行的,线程将在下一个取消点(如 `read`)被取消。
`__libc_enable_asynccancel` 检查是否应发生取消,如果需要,它将终止线程 [^1]。unvariant 使用的混淆函数指针在此处被调用。
## 执行 DWARF 字节码
然而,终止线程并非简单之事。考虑一个假设的线程:在其调用栈深处,它发现必须在某个取消点被取消。如果栈上分配了 C++ 对象,其析构函数应如何执行?glibc *是否应该*执行它们?可能应该 [^2]。
不运行析构函数就丢弃对象可能导致文件描述符保持打开、引用计数增加或互斥锁被锁定。然而,遍历线程的栈帧以析构这些 C++ 对象,或执行“强制展开”(forced unwind),并非易事。计算前一个栈帧的地址(非快速路径)可能调用 [DWARF 表达式](https://en.wikipedia.org/wiki/DWARF)。这是因为高度优化的函数可能无法构建独立可解释的栈帧,如何找到栈帧的额外信息必须存储在栈外的 ELF `.eh_frame_hdr` 和 `.eh_frame` 段中。这些表达式可读取内存中的任意位置以计算所需值。此逻辑在 libgcc_s 中实现,glibc 将在展开期间 `dlopen` 它。
## 为何有用?
编写漏洞利用程序时,劫持[奇异计算机](https://en.wikipedia.org/wiki/Weird_machine)以执行自定义代码执行所需操作通常很有用。理想情况下,我们只需调用 [one_gadget](https://github.com/david942j/one_gadget) 或 `system("your command here")`,但这在媒体解码沙箱中并不实用。众所周知,[printf](https://www.usenix.org/system/files/conference/usenixsecurity15/sec15-paper-carlini.pdf) 和 [运行时重定位](https://ctf.harrison.green/2022/googlectf/eldar/) 可从 glibc 访问。然而,由于它们的“奇异”特性,往往不便于移植或难以编程。为了实现代码执行,我们能否直接为 DWARF 虚拟机编程?
## 实现原语
我们需要让程序认为它应该取消,并在展开时替换其使用的 `.eh_frame_hdr` 数据。令人惊讶的是,我们无需突破 ASLR 或指针混淆即可做到!
我们至少需要两个原语:控制大型 `malloc` 内容的能力,以及越界空字节写入(4 字节对齐的越界写入也可行)。值得注意的是,这已经是一个相当强大的原语,可能存在其他实现代码执行的途径。
我们将在当前最新 glibc(2.44)上实现此方法。
首先,创建一个我们将对其实施无泄露代码执行的示例 C 程序:
```c
#include <unistd.h>
#include <stdlib.h>
int main(void) {
size_t size, off;
unsigned char val;
read(0, &size, sizeof(size));
unsigned char *p = malloc(size);
while (1) {
read(0, &val, sizeof(val));
read(0, &off, sizeof(off));
if (off >= size) val = 0;
p[off] = val;
}
}
```
该程序分配一个我们指定大小的块。然后,在无限循环中,我们可以向块中写入任意数据,或进行越界空字节写入。
### 诱使主线程取消
为了执行 DWARF 字节码,程序需要认为它正在取消。以下是 `read` 和系统调用的线程取消包装器:
```c
ssize_t __libc_read(int fd, void *buf, size_t nbytes)
{
return SYSCALL_CANCEL(read, fd, buf, nbytes);
}
long int __internal_syscall_cancel (/* 省略 */)
{
long int result;
struct pthread *pd = THREAD_SELF;
/* 如果未启用取消,直接调用系统调用,并在线程终止时也如此,
以避免在执行清理处理程序时调用 __syscall_do_cancel。 */
int ch = atomic_load_relaxed (&pd->cancelhandling);
if (SINGLE_THREAD_P || !cancel_enabled (ch) || cancel_exiting (ch))
{
result = INTERNAL_SYSCALL_NCS_CALL (nr, a1, a2, a3, a4, a5, a6
__SYSCALL_CANCEL7_ARCH_ARG7);
if (INTERNAL_SYSCALL_ERROR_P (result))
return -INTERNAL_SYSCALL_ERRNO (result);
return result;
}
/* 调用架构特定的入口点,该入口点包含 SIGCANCEL 处理程序要检查的全局标记。 */
result = __syscall_cancel_arch (&pd->cancelhandling, nr, a1, a2, a3, a4, a5,
a6 __SYSCALL_CANCEL7_ARCH_ARG7);
/* 如果可取消的系统调用被 SIGCANCEL 中断且无副作用,则在线程启用取消时终止线程。 */
ch = atomic_load_relaxed (&pd->cancelhandling);
/* 此处的行为假设 EINTR 仅在无可见副作用时返回。POSIX 第 7 版尚未为 close 提供更强的保证,
理论上 close 系统调用可能返回 EINTR 并保持文件描述符打开(符合规范但资源泄漏)。
期望不存在使用此类内核的 glibc。 */
if (result == -EINTR && cancel_enabled_and_canceled (ch))
__syscall_do_cancel ();
return result;
}
```
为了触发取消,我们需要条件 `SINGLE_THREAD_P || !cancel_enabled (ch) || cancel_exiting (ch)` 为假。我们可以通过喷射和一些空字节写入实现:
1. 首先,在我们的 `malloc` 块中每页喷射一个伪 `struct pthread`,其大小可选以强制使用 `mmap` 分配。我们只需初始化偏移 `+0x308`(包含 `cancelhandling` 标志),因此不需要 ASLR 泄露。
2. 其次,将 `THREAD_SELF` 指针的低 3 字节设置为 0。
3. 第三,将 `__libc_single_threaded_internal` 设置为 0。
第二次写入的目的是将 `__internal_syscall_cancel` 读取的 `cancelhandling` 标志重定向到我们的喷射区域。此写入是否会将 `THREAD_SELF` 置于我们的喷射区域并不明显,实际上也并非必然。让我们计算概率。
### 成功线程取消的概率
首先,`THREAD_SELF` 包含在 ld.so 的 `__minimal_malloc` 执行的 `mmap` 分配中(一个在程序启动时使用的增量分配器),并指向该分配。由于 `mmap` 向低地址分配,另一个 `mmap`(如我们的喷射区域)分配在此 TLS 部分之下。这意味着它们是连续的,`THREAD_SELF` 将指向喷射区域后一页。对于真实程序,你的分配不太可能是第一次 `mmap`,因此概率可能较低。此时,进行 4 字节空字节写入可能更合理。
概念验证中的 3 字节写入对于 CTF 用途已足够——我们的喷射区域会更小但可靠性较低。3 字节写入实际上从 `THREAD_SELF` 指针中减去两个量:
- 一个已知的 12 位常数,包含在指针的最低 3 个半字节中,
- 一个未知的页对齐偏移量,包含在接下来的 3 个半字节中。
最大页偏移出现在高半字节为 `0xfff` 时,无页偏移出现在高半字节为 `0x000` 时。这意味着需要 16 MiB 的分配大小以捕获最大偏移。`THREAD_SELF` 指向 ld.so 增量分配器 VMA 偏移 `0x11c0` 处的值。这意味着 4096 个偏移量中有 2 个会使 `cancelhandling` 超出喷射区域:`0x000` 和 `0x001`。
对于 4 字节写入,候选数量是 3 字节的 256 倍,但仍有两个不良偏移。因此,使用 16 MiB 喷射的 3 字节写入取消概率为 \(\frac{4096 - 2}{4096} = 99.9512\%\),而使用 4 GiB 喷射的 4 字节写入取消概率为 \(\frac{1048576 - 2}{1048576} = 99.9998\%\)。
### 将 `.eh_frame_hdr` 段指针重定向到我们的喷射区域
现在我们的线程被取消,它将解析已加载二进制文件的 `.eh_frame_hdr` 段,以确定要运行哪些 DWARF 表达式。指向这些段的指针也存储在 ld.so 增量分配器的底层 `mmap` 中,因此我们也可以写入其低 3 或 4 字节的空字节。这将把 EH 帧头的读取重定向到我们的喷射区域,但概率会稍低。此时,我们的喷射区域与实际 `.eh_frame_hdr` 段之间的页数是 82 而非 2,因此概率为:
- 3 字节写入:\(\frac{4096 - 82}{4096} = 97.99805\%\)
- 4 字节写入:\(\frac{1048576 - 82}{1048576} = 99.9922\%\)
#### 代码执行
一旦我们自己的 `.eh_frame_hdr` 被解析,将其转化为代码执行的过程相当机械化。此处不深入细节,但简要来说,我们需要了解一些 [Itanium C++ ABI 中的异常处理](https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html)。我们喷射的 `.eh_frame_hdr` 将提供一个相对偏移,指向包含帧描述条目(FDE)和公共信息条目(CIE)的 `.eh_frame`。FDE 定义“语言特定数据区域”(LSDA),CIE 定义 LSDA 和“处理程序”。处理程序值将是 `__gxx_personality_v0`,我们可以从 libgcc_s 获取。`__gxx_personality_v0` 检查 LSDA 以确定“着陆垫”的位置。这些着陆垫的位置可以与它们进入时的寄存器状态一起动态计算。它基本上是一个 `setcontext`,其中你的寄存器可以使用 DWARF 表达式动态计算。
## powckle,DiceCTF 2026
我为 DiceCTF 2026 决赛设计了一个名为“powckle”的挑战。它从用户接收工作量证明挑战,并生成 32 个沙箱化 C 进程以搜索解决方案。为了构造漏洞(并诱导“特工”深入尝试使用 `find_class = None` pwn Python unpickler 的兔子洞),Python 和 C 程序使用 [pickle](https://docs.python.org/3/library/pickle.html) 通信。
总结如下:第一个漏洞是 C unpickler 跳过无法识别的 pickle 操作码。例如,通过为工作量证明指定一个巨大的难度数字,Python pickler 选择使用 `LONG4` 而非 `BININT2`。由于 `LONG4` 未被 C unpickler 注册,它跳过该操作码并将 `LONG4` 的操作数解释为操作码。这允许执行任意 pickle 操作码。
利用任意操作码,你可以通过 `FRAME` 操作码触发第二个漏洞——从存储 pickle 数据的缓冲区进行越界空字节写入。使用上述技术(我称之为“windy 之家”),你的 DWARF 字节码可以在栈上的环境变量中查找标志并将其写回 Python 程序。
祝贺解决该挑战的两个团队 [ley](https://ctftime.org/team/447404) 和 [SPL](https://ctftime.org/team/222966),以及为 ley 取得首血的 unvariant。对于其他团队消耗的令牌,我深表歉意。
## 结论
glibc 的 `read` 最终可能调用虚拟机,这让我觉得有趣,但我撰写此文是因为我认为在漏洞利用中运行 DWARF 字节码在 CTF 内外都有实用价值。仔细观察,我们本质上是在内存中喷射可执行指令,以便通过部分破坏函数指针跳转到它们。然而,与破坏真实函数指针相比,破坏 `.eh_frame` 指针的优势在于 BTI、CFI,以及最重要的非可执行内存,不会对此调用生效。
我本人尚未触发此漏洞,但理论上,展开也可以通过 C++ 异常访问,且 bionic 和 musl 都像 glibc 一样存储指向 `.eh_frame` 的内部指针。我认为我们在 CTF 中开发的许多挑战和解决方案用途有限;boogie-woogie 或 woogie-boogie 中的字节交换原语不太可能在实践中出现。然而,在分享、解决和构建它们的过程中,我们发现了新的、更强大的原语,这些原语是有用的。对我来说,这是 CTF 最有趣的部分之一,我将会怀念它。
[^1]: 该名称源于 `__libc_enable_asynccancel` 暂时允许线程异步取消。这样,如果线程挂起在 `read` 系统调用中,它可以被取消而无需等待调用返回。
[^2]: 依赖展开器进行线程取消存在权衡。如果 glibc 不依赖 libgcc_s 进行 `backtrace`,这种情况就不那么有说服力。musl 在线程取消时不展开是有道理的,因为对于轻量级 libc 来说这是一个大的依赖项。bionic 甚至不实现线程取消,放弃了 POSIX 兼容性(尽管其 `pthread_exit` 实现不展开,尽管静态链接了 libunwind。我不太了解代码库,不知道原因)。
相似文章
在GCC中无需可执行栈间接调用嵌套函数的技术
本文介绍了一种在GCC中无需可执行栈通过从蹦床读取指针来间接调用嵌套函数的技术,适用于较旧的GCC版本,并在noplate库中实现。
objdump -g 中的任意代码执行
objdump -g 中存在一个安全漏洞,由于 FR30 重定位处理程序缺少边界检查,通过精心构造的 FR30 目标文件可实现任意代码执行,单个漏洞利用即可绕过 ASLR 及其他缓解措施。
schrodingers-toctou: The binary you run is not the program you wrote
This research describes compiler-invented loads, where compiler optimizations create additional memory reads not present in source code, turning seemingly secure code into vulnerable binaries with TOCTOU races. It includes audits across kernels, hypervisors, enclaves, and firmware.
字节码虚拟机在意外场景中的应用 (2024)
本文探讨了字节码虚拟机的出人意料的应用,特别是Linux内核中的eBPF以及编译后二进制文件中用于调试信息的DWARF表达式。
Windows堆栈限制检查回顾,后续
Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。