关于现代 glibc 下的 FSOP 攻击艺术的研究:_IO_wfile_jumps 执行流劫持再审视

来自近期做题的灵感,这篇文章想解决一个很具体的问题:在 glibc 2.34 以后,老一代 __malloc_hook__free_hook 基本退出利用舞台,为什么很多人把注意力转回了 libio,而且集中盯上 _IO_wfile_jumps 这条路径

先说结论。现在主 vtable 的检查已经很严格,直接伪造主跳表通常会被拦住;但宽字符侧链里的 _wide_vtable 在关键路径上还没有做同级校验。于是就会出现一种很典型的组合:入口看起来完全合法,真正的控制流变化发生在后面的侧链分发。

如果你之前做过堆题,应该对 __malloc_hook__free_hook 不陌生。以前拿到任意写,改这些位置触发起来很直接。变化点在 glibc 2.34,官方在 NEWS 里明确写了这些分配器 hook 已经从 API 语义上移除。兼容符号可能还在,但新程序已经不能再靠它们影响核心分配流程。于是利用思路自然往“程序一定会走到的运行时路径”迁移,stdio 清理链路就是其中之一

exit() 这条线在源码里很清楚:exit -> __run_exit_handlers -> _IO_cleanup,再到 libio/genops.c_IO_flush_all。这里的价值不在于花哨,而在于稳定。正常 main 返回和显式 exit() 都会经过它。反过来,_exit()abort()、致命信号终止不保证走这条链,所以很多复现失败不是链子错了,而是触发条件没选对

关键分水岭在 vtable 检查。libioP.h 里的 JUMP1 会走 IO_validate_vtable,而 IO_validate_vtable 要求主 vtable 必须在 __io_vtables 这块合法区域里,不然就会进 _IO_vtable_check,最后直接报 fatal。简单说,主跳表这条路现在卡得很死

_IO_wfile_jumps 的作用就在这里:它本来就是 libc 自带、合法的跳表,所以能通过主校验。以 Ubuntu 24.04 的 glibc 2.39 为例,readelf 可以看到 _IO_wfile_jumps.data.rel.ro,运行时受 GNU_RELRO 保护;而 _IO_2_1_stderr_.data,依然可写。也就是“主表难改,状态和侧链更可能被动手脚”。

真正有意思的点是,_IO_flush_all 在处理宽字符流时,只要满足 fp->_mode > 0fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base,就会调用 _IO_OVERFLOW(fp, EOF)。如果主表是 _IO_wfile_jumps,落点就是 _IO_wfile_overflow。而 _IO_wfile_overflow 发现 wide_data->_IO_write_base == NULL 时,会去 _IO_wdoallocbuf,再进一步走 _IO_WDOALLOCATE(fp)

问题就出在 _IO_WDOALLOCATE 这一跳。它是 WJUMP0(__doallocate, fp),解引用的是 fp->_wide_data->_wide_vtable。这条宽字符侧链在当前实现里没有像主 vtable 那样经过 IO_validate_vtable。所以我们会看到一种结构:主路径完全合法,但侧链指针如果被改写,最终调用目标就会被带偏。

只看文字会有点抽象,下面把结构关系画开。

                           (主路径,受校验)
          +-------------------------------------------------+
          |                 struct _IO_FILE_plus           |
          | +0x0a0 _wide_data -------------------------+ |
          | +0x0c0 _mode                                 | |
          | +0x0d8 vtable ----------------------------+ | |
          +--------------------------------------------|--|--+
                                                      | |
                                                      v |
                                        __io_vtables 中的合法表
                                        _IO_wfile_jumps
                                                      |
                                                      v
                                          _IO_wfile_overflow
                                                      |
                                                      v
                          (侧路径,当前实现里不做同级校验)
          +--------------------------------------------|-----+
          |           struct _IO_wide_data           |     |
          | +0x018 _IO_write_base                     |     |
          | +0x020 _IO_write_ptr                     |     |
          | +0x0e0 _wide_vtable ----------------------+-----+
          +--------------------------------------------------+
                                                      |
                                                      v
                                        wide jump table 的 __doallocate

触发流程可以再压成一条时间线:

exit()
-> __run_exit_handlers
    -> _IO_cleanup
      -> _IO_flush_all
          -> if (fp->_mode > 0 &&
                fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)
                _IO_OVERFLOW(fp, EOF)
                  -> _IO_wfile_overflow
                      -> if (wide_write_base == NULL) _IO_wdoallocbuf
                        -> _IO_WDOALLOCATE(fp)
                            -> fp->_wide_data->_wide_vtable->__doallocate(fp)

在 x86_64 的 glibc 2.39 上,我本地测到的偏移是:struct _IO_FILE 大小 0xd8_wide_data0xa0_mode0xc0_IO_FILE_plusvtable0xd8struct _IO_wide_data 里,_IO_write_base0x18_IO_write_ptr0x20_wide_vtable0xe0struct _IO_jump_t__doallocate 槽位是 0x68。这组数据和常见的“三段布局”能够对上。

这里也顺便强调一个常被忽略的细节:在 _IO_flush_all 这个触发点,宽字符分支要求的是“严格大于”,所以 _IO_write_ptr 不能和 _IO_write_base 相等。很多人把这两个都置零,结果路径根本进不去。

为了让理解更直观,我放了一个无害演示程序,只模拟控制流,不执行系统命令。它演示的是:主 vtable 不动,改侧链 _wide_vtable 后,调用就会落到另一个 doallocate

#include <stdio.h>

typedef struct fake_file fake_file;
typedef int (*overflow_fn)(fake_file *fp, int ch);
typedef int (*doallocate_fn)(fake_file *fp);

typedef struct { overflow_fn overflow; } fake_jump_t;
typedef struct { doallocate_fn doallocate; } fake_wide_jump_t;
typedef struct {
   char *write_base;
   char *write_ptr;
   fake_wide_jump_t *wide_vtable;
} fake_wide_data;

struct fake_file {
   char label[32];
   int mode;
   fake_jump_t *vtable;
   fake_wide_data *wide_data;
};

static int safe_doallocate(fake_file *fp) {
   printf("[normal] doallocate on %s\n", fp->label);
   return 0;
}

static int demo_hijack_sink(fake_file *fp) {
   printf("[demo] control reaches wide_vtable->doallocate on %s\n", fp->label);
   printf("[demo] this is where a real attack would try to abuse control flow\n");
   return 0;
}

static int wdoallocbuf(fake_file *fp) {
   return fp->wide_data->wide_vtable->doallocate(fp);
}

static int wfile_overflow(fake_file *fp, int ch) {
  (void)ch;
   if (fp->mode <= 0)
       return 0;
   if (fp->wide_data->write_base == NULL) {
       puts("[path] write_base is NULL -> call wdoallocbuf");
       return wdoallocbuf(fp);
  }
   return 0;
}

static int flush_one(fake_file *fp) {
   if (fp->mode > 0 && fp->wide_data->write_ptr > fp->wide_data->write_base) {
       puts("[flush] wide condition satisfied -> call overflow");
       return fp->vtable->overflow(fp, -1);
  }
   return 0;
}

int main(void) {
   fake_jump_t legal_wfile_jumps = { .overflow = wfile_overflow };
   fake_wide_jump_t normal_wide_vtable = { .doallocate = safe_doallocate };
   fake_wide_jump_t tampered_wide_vtable = { .doallocate = demo_hijack_sink };
   fake_wide_data wd = { .write_base = NULL, .write_ptr = (char *)1, .wide_vtable = &normal_wide_vtable };
   fake_file stderr_like = { .label = "stderr_like", .mode = 1, .vtable = &legal_wfile_jumps, .wide_data = &wd };

   puts("=== case A: normal path ===");
   flush_one(&stderr_like);
   puts("\n=== case B: side pointer tampered ===");
   wd.wide_vtable = &tampered_wide_vtable;
   flush_one(&stderr_like);
   return 0;
}
gcc -Wall -Wextra -O2 fsop_wfile_chain_demo.c -o fsop_wfile_chain_demo
./fsop_wfile_chain_demo
=== case A: normal path ===
[flush] wide condition satisfied -> call overflow
[path] write_base is NULL -> call wdoallocbuf
[normal] doallocate on stderr_like

=== case B: side pointer tampered ===
[flush] wide condition satisfied -> call overflow
[path] write_base is NULL -> call wdoallocbuf
[demo] control reaches wide_vtable->doallocate on stderr_like
[demo] this is where a real attack would try to abuse control flow

从防守角度看,这条链最有启发的地方是:不能只看“主 vtable 已校验”就下结论。当前真正的风险点在主流程后的侧链一致性。更稳妥的方向,是把 _wide_vtable 也纳入同等级来源校验,或者在关键路径增加结构体状态一致性检查,减少“合法主表 + 异常侧表”这种组合出现的可能。

现在可能看起来不那么新奇,但我认为还是有价值的,因为它正好卡在 libc 兼容性和安全性之间的缝隙。我认为理解这条链的意义,不只是为了做利用,更是为了把防守假设补完整。

参考源码与文档

本文所有技术细节仅用于防御研究与漏洞修复验证,不应用于未授权测试环境
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇