首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Pwn
发新帖
1
0
[原创]C++ 虚表全解剖:从对象内存布局到 glibc IO 攻击链路
发表于: 6小时前
49
[原创]C++ 虚表全解剖:从对象内存布局到 glibc IO 攻击链路
inquisiter
2
6小时前
49
# C++ 虚表全解剖:从对象内存布局到 glibc IO 攻击链路 > 一条从「对象首 8 字节」出发,一路走到 `setcontext` 的完整路径。 > 全文以 Itanium C++ ABI(GCC / Clang)为准,MSVC 差异单独标注;glibc 部分以 2.23 ~ 2.37 为时间轴。 --- ## 目录 - [一、引子:虚表解决的是什么问题](#一引子虚表解决的是什么问题) - [二、正常运行机制:两次纯数据解引用](#二正常运行机制两次纯数据解引用) - [三、`_vptr` 到底是什么](#三_vptr-到底是什么) - [四、构造函数与析构函数在虚表里的位置](#四构造函数与析构函数在虚表里的位置) - [五、对象可以没有虚函数](#五对象可以没有虚函数) - [六、到底有多少张虚表,创建规则是什么](#六到底有多少张虚表创建规则是什么) - [七、vtable 劫持:正常机制与攻击机制的对比](#七vtable-劫持正常机制与攻击机制的对比) - [八、glibc IO 的「虚表」:C 手写的跳转表](#八glibc-io-的虚表c-手写的跳转表) - [九、防护与绕过的军备竞赛](#九防护与绕过的军备竞赛) - [十、速查清单](#十速查清单) - [附录 A:术语与符号对照表](#附录-a术语与符号对照表) - [附录 B:可执行验证命令](#附录-b可执行验证命令) --- ## 一、引子:虚表解决的是什么问题 C++ 要在**只知道基类指针**的前提下,调用到**派生类的实现**。这件事在编译期无法确定,只能推迟到运行期。虚表(vtable)就是这套延迟绑定的实现方案: - 每个**多态类**在只读数据段里有一张**函数指针数组**; - 每个**多态对象**在内存开头藏一个指针,指向那张表; - 调用虚函数时,编译器生成的是「**取出指针 → 加固定偏移 → 间接调用**」,偏移在编译期就算死。 反过来说,这也决定了它的攻击面:**表是只读的,但对象首字段是可写的**。这个不对称,是近二十年虚表攻防的全部战场。 ```mermaid flowchart LR A["对象 obj<br/>偏移 0x00 = _vptr"] -->|"1. mov rax,[rdi]"| B["_ZTV7Derived<br/>.data.rel.ro 只读段"] B -->|"2. call [rax+0x10]"| C["~Derived D1 / f()<br/>.text 可执行段"] ``` **图 1 正常虚调用的两次解引用** 关键点:**整个过程没有分支、没有校验,是纯数据解引用**。这正是它能被劫持、也正因此,加固思路只能是「约束跳转目标」而不能是「加检查指令」。 --- ## 二、正常运行机制:两次纯数据解引用 ### 2.1 分派三要素 | 要素 | 内容 | 谁决定 | |---|---|---| | vptr 由谁写 | 构造函数,按编译期算好的偏移写入 | 编译器插桩 | | vtable 放哪 | `.data.rel.ro`,链接后经 RELRO 变只读 | 链接器 | | 表项内容 | 真实函数地址(或 thunk),偏移常量硬编码进指令 | 编译期 | 三个要素里,**只有「表基址」和「表内容」是可攻击变量**——偏移是编译期常量,谁也改不了。所以所有虚表劫持手法,最终都落在两个动作上:**改 vptr 指向别的表**,或者**往可写内存里放一张伪造的表**。 ### 2.2 有没有虚函数的对象布局对比 ```cpp struct Plain { int x; }; // 非多态 struct Poly { virtual void f(); int x; }; // 多态 ``` | 维度 | `Plain`(无虚函数) | `Poly`(有虚函数) | |---|---|---| | 偏移 0x00 | `int x` | **`_vptr`**(8 字节) | | `sizeof`(64 位) | 4 | 16(`_vptr` + `x` + padding) | | 调用方式 | 编译期直接 `call`,可内联 | `[vptr+偏移]` 间接跳转,难内联 | | RTTI | 无(`typeid` 编译期定值) | 有 `_ZTI`,支持 `dynamic_cast` | | 二进制安全 | 可 `memcpy`,可与 C struct 互通 | 属 UB,跨编译器/ABI 不保证 | | ABI 敏感度 | 加成员即变布局 | **加一个 `virtual` 就整体重排** | > **工程上的硬结论**:给已发布的库里的类补一个 `virtual`(哪怕只是虚析构),等于改 ABI,所有调用方必须重编。所以「要不要虚函数」是设计期的一次性决定。 --- ## 三、`_vptr` 到底是什么 ### 3.1 它不是「指向自己」的指针 这是最容易搞混的一点。准确表述是:**`_vptr` 是「住在对象里、却指向对象外面」的指针**。 - **它的地址**在对象偏移 `0x00`,数值上恰好等于 `&obj`; - **它存放的值**是 vtable 的地址,位于 `.data.rel.ro`,**全类所有对象完全相同**; - 所以 `*(void**)obj != obj`。指针方向恒为 **对象 → 表 → 实现**,全部向外,没有自指。 | 东西 | 性质 | 存的值 | 是否指向自己 | |---|---|---|---| | `this` | 隐式形参(寄存器传递),**不是成员** | 当前对象首地址 | **是**,唯一真正自指的 | | `_vptr` | **对象成员**,偏移 `0x00` | vtable 地址点 | 不是(指向类共享只读表) | | `offset-to-top` | vtable 负索引里的一项 | **有符号偏移**(可为负) | 不是指针,是「相对 this 的位移」 | **表 1 this / _vptr / offset-to-top 对照** ### 3.2 地址点:vptr 指向表内的「中间位置」 很多人以为 vptr 指向表头,其实**它指向表内的第一个虚函数槽**,这个位置叫 **address point(地址点)**: | 相对地址点 | 内容 | 说明 | |---|---|---| | `-0x10` | `offset-to-top` | 由基类子对象 `this` 回到**整体对象**首地址的位移,可为负 | | `-0x08` | `&_ZTI7Derived` | RTTI 类型信息指针 | | `+0x00` | `~Derived` (D1) | **地址点,vptr 指向这里** | | `+0x08` | `~Derived` (D0) | 删除析构 | | `+0x10` | `Derived::f()` | 后续虚函数按声明顺序排列 | 而符号 `_ZTV7Derived` 是从 `-0x10` 那一格开始算的——**所以 vptr 的值 = `&_ZTV7Derived + 16`**。这解释了两个现象: 1. gdb 打印 `_vptr$Derived = 0x... <vtable for Derived+16>`,那个 `+16` 是**相对 `_ZTV` 符号**的偏移,**不是对象偏移**; 2. `vptr[-1]` 就是 `&_ZTI`,这是在调试器里判断对象真实类型最直接的技巧。 > 含虚基类时,地址点再往负方向(`-0x18`、`-0x20` …)还会排上**虚基类偏移**。 ### 3.3 为什么不做成「自指 / 自包含」 如果每个对象都存一份自己的类型信息加函数指针: 1. **内存爆炸**:一个虚函数 8 字节,十个就是 80 字节,乘以对象数量; 2. **失去唯一那层保护**:表跟着对象走,就必然落在可写段,「改不了」这个前提直接消失。 现行设计的本质是:**每个对象付 8 字节,换 O(1) 派发 + 一张受只读保护的共享表**。代价就是这 8 字节成了唯一的攻击点。 --- ## 四、构造函数与析构函数在虚表里的位置 ### 4.1 构造函数**不在**虚表里——它是 vptr 的写入者 C++ 没有「虚构造函数」这个概念。虚调用的前提是「对象已存在、首字段已有 vptr」,而构造函数运行的目的恰恰是**建立这个对象**。所以构造符只会出现在调用点的直接 `call` 或 PLT 里,永远不会占虚表槽位。 但编译期会在每个构造函数体前插一句: ```asm mov qword ptr [this], offset _ZTV7Derived ; 写入 vptr ``` 由此得到两条常被搞混的语义:**构造/析构期间调用虚函数,只会派发到「当前这一层子对象」的实现**——基类构造体里调 `virtual f()`,走的是 `Base::f`,不是 `Derived::f`。 ### 4.2 虚析构函数**在**表里,而且占两格 | 符号(GCC / Itanium) | 含义 | 是否进虚表 | |---|---|---| | `_ZTV6Animal` | 虚表本体 | — | | `_ZTI6Animal` | RTTI 类型信息 | 占 `-0x08` 格 | | `_ZN6AnimalC1Ev` | 完整对象构造 | ✗ | | `_ZN6AnimalC2Ev` | 基类对象构造 | ✗ | | `_ZN6AnimalD1Ev` | **完整对象析构** | ✓ 槽 N | | `_ZN6AnimalD0Ev` | **删除析构**(内含 `operator delete`) | ✓ 槽 N+1 | | `_ZN6AnimalD2Ev` | 基类对象析构(被 D1 直接调用) | ✗ | MSVC 侧对应:`??_7Animal@@6B@`(vftable)、`??0Animal@@`(构造)、`??1Animal@@`(D1)、`??_GAnimal@@`(标量删除析构 D0)。 **两个实务提醒**: - 一旦声明 `virtual ~T()`,它之后的虚函数槽位会**整体后移 8 字节**——IDA 里对不上偏移时先查这里; - 只有声明 `virtual ~T()`,`delete base_ptr` 才能经表找到正确的 **D0**,跳过正确的析构层级并以正确的 `this` 调 `operator delete`。 ### 4.3 vptr 在对象生命周期里被改写 4 次 ```mermaid flowchart TB A["① 进入 Base 构造函数体前<br/>vptr ← _ZTV4Base"] --> B["② 进入 Derived 构造函数体前<br/>vptr ← _ZTV7Derived"] B --> C["③ 进入 ~Derived 析构体<br/>vptr 保持 Derived 表"] C --> D["④ 编译器插桩:进入 ~Base 前<br/>vptr 回退 ← _ZTV4Base"] ``` **图 2 vptr 在生命周期中的四次改写** **攻击侧推论**:既然只有析构在表里,那么「虚构造劫持」这种手法在理论上就不存在——想劫持构造只能走 PLT/GOT 覆写或直接改代码。而**虚析构槽(尤其 D0)是劫持最自然的落点,因为它自带一次释放动作**。 --- ## 五、对象可以没有虚函数 ### 5.1 判据是「类」,不是「对象」 虚函数是**类**的属性:类里有虚函数 ⇒ 该类的**每一个**对象都带 `vptr`。不存在「同一类的对象有的带有的不带」。栈上、全局、`new` 出来的、作为成员子对象嵌着的——四种布局**逐字节相同**。 顺带澄清一个高频误解:**`_vptr` 不是「`new` 出来的对象的内部指针」**。`new Derived` 展开成三步: ```cpp void* raw = operator new(sizeof(Derived)); // 裸内存,_vptr 是垃圾值 Derived* p = new (raw) Derived(); // 编译器生成的构造调用在此写 _vptr // 返回 p ``` 真正由 `new` 间接带来、且「在对象之外」的元数据是 **malloc chunk header**(用户指针之前 16 字节),不是 `_vptr`。这个区别在 UAF 场景下很关键:`free` 之后块前 16 字节会被 tcache 的 `next` / `key` 覆盖,**`_vptr` 位置恰好被冲掉**——所以想劫持虚表,通常得先把对象原样「占回来」。 ### 5.2 三条让类「被迫」变成多态的路径 ```mermaid flowchart TB T["class T { ... }"] --> D{"类里出现虚函数?"} D -->|"显式声明"| P1["virtual void f();"] D -->|"虚析构"| P2["virtual ~T();"] D -->|"继承多态基类"| P3["struct D : Base {};"] P1 --> R["T 成为多态类型<br/>is_polymorphic = true"] P2 --> R P3 --> R ``` **图 3 三条变多态的路径** 三个容易踩的点: 1. **虚析构最隐蔽**:只想「`delete` 派生类不泄漏」,写了 `virtual ~T()`,类立刻变多态,`sizeof` 跟着变; 2. **派生类不需要再写 `virtual`**:继承链上有虚函数,整条链所有类的对象都带 `vptr`; 3. **多继承**:只要任一基类多态,派生类就是多态,而且**每个多态基类子对象各带一个 `vptr`**。 ### 5.3 特例:有 `vptr` 不等于多态 含**虚基类**、但一个虚函数都没有的类,GCC/Itanium 下也可能生成一张只记录虚基类偏移的 vtable、并在对象头放指针。但它**没有虚派发、没有 RTTI**,`std::is_polymorphic` 仍为 `false`。 > **多态的判据是「有没有虚函数」,不是「对象里有没有指针」。** ### 5.4 多态也可以被主动关掉 - `obj.Base::f()` 显式限定 → 直接 `call`; - `final` 类 + 类型静态可知 → **去虚化**(devirtualization); - 派生类对象按值传给基类参数 → **对象切片**; - `-fno-rtti`;模板 / CRTP 提供的是**编译期多态**,一个虚函数都不用。 **虚函数只是「运行时多态」的一种实现手段,不是多态的必要条件。** --- ## 六、到底有多少张虚表,创建规则是什么 ### 6.1 数量规则 三句话定调:**按「类 × 子对象位」生成、编译期静态确定、链接后进程内每类只有一份**。100 万个对象共享 1 张表。 设类 `D` 有 `k` 个多态基类: | 情形 | 该类的 vtable 数量 | |---|---| | 无虚函数、无虚基类 | **0 张**(对象里也没有 `vptr`) | | 单继承 / 仅 1 个多态基类 | **1 张主表** | | 多继承 `k` 个多态基类 | **`k` 张**(1 主表 + `k-1` 张次表) | | 含虚基类(构造期需切换) | 另加**构造虚表 `_ZTC...`**(每个虚基类一张)+ 一张 **VTT `_ZTT...`** | | 抽象类(含纯虚) | 仍 1 张,纯虚槽内填 `__cxa_pure_virtual` | | 虚函数全内联(无关键函数) | 每个使用的 TU 各发一份 weak 符号,链接期合并 | ### 6.2 多继承:多出的是「表段」,GCC 下未必多出符号 以 `class D : public A, public B`(A、B 均多态)为例: ```mermaid flowchart LR V1["对象 D · vptr①<br/>偏移 0x00"] -->|"指向"| S1["主表段 · 供 A 位使用<br/>offset-to-top = 0"] V2["对象 D · vptr②<br/>偏移 0x08"] -->|"指向"| S2["次表段 · 供 B 位使用<br/>offset-to-top = -0x08"] ``` **图 4 多继承下两个 vptr 与两段表** - **GCC / Itanium**:把同一类的所有表段**打进同一个 `_ZTV1D` 符号**,每段各自带一份 offset-to-top + RTTI 头,各自有自己的地址点(次表槽里常放 **thunk** 而非真实函数地址,负责 `this` 调整); - **MSVC**:每个基类各出一个符号 `??_7D@@6BA@@@` / `??_7D@@6BB@@@`。 > 上图中 `vptr②` 落在 `0x08`,与次表的 `offset-to-top = -0x08` 互为印证:从 B 子对象的 `this` 加上 `-0x08` 正好回到整体对象 D 的首地址。 ### 6.3 关键函数(key function)规则——表由谁发出 ```mermaid flowchart TB A["类含虚函数 ⇒ 需要 vtable"] --> B{"有没有关键函数?<br/>第一个非内联、非纯虚的虚函数"} B -->|"有"| C["只在关键函数定义所在 TU 发出<br/>只此一份,.data.rel.ro"] B -->|"没有(全内联 / 纯虚)"| D["每个使用的 TU 各发一份<br/>weak 符号,待链接器合并"] C --> E["链接器按 COMDAT 合并<br/>⇒ 进程内该类只有一份"] D --> E ``` **图 5 关键函数规则** 这条规则的实际意义:它决定了你的某个 `.cpp` 是「表的主人」还是「表的借用者」,也决定了改一个虚函数定义会触发多少目标文件重编。 ### 6.4 表内容怎么填 - 按「**基类声明顺序 → 每个基类内虚函数声明顺序**」铺槽; - `override` 是**同槽替换**,所以槽位在继承链上必须一致——**新虚函数只能加在末尾,否则槽位全线错位**(即 ABI 破裂); - 协变返回、多继承 `this` 调整由 **thunk** 承担; - 表本体位于 `.data.rel.ro`,重定位解析完成后整段变只读(RELRO)——这是「表基址改不了」的物理来源。 ### 6.5 「到底有多少张」的量级 一个普通进程(含 libstdc++、libc 等)通常有**成百上千张** `_ZTV`。但注意:**别把 glibc 的 `_IO_*_jumps` 数进去**——那是 C 手写的数据结构,只是与 C++ vtable 同构。见第八节。 --- ## 七、vtable 劫持:正常机制与攻击机制的对比 ### 7.1 机制层对比 | 维度 | 正常运行 | 攻击利用 | |---|---|---| | `vptr` 由谁写 | 构造函数,按编译期偏移写入 | 攻击者用写原语覆盖对象首 8 字节 | | vtable 位置 | `.data.rel.ro`,链接后只读 | 堆 / BSS / 任意可控缓冲区(**必须可写**) | | 表项内容 | 真实函数地址 + `&typeinfo` | `system` / one_gadget / 栈迁移 gadget | | 偏移来源 | 编译期常量,硬编码进 `call [rax+0x10]` | **改不了**——只能改表基址,不能改偏移 | | 调用过程 | 两次纯数据解引用,无分支无校验 | 同样两次解引用,只是落点被换 | | 合法性依据 | 只有「表页只读」一条 | 有写原语即失效 | | 失败表现 | — | 表不可写 → 伪造表;地址未知 → 先泄露;目标受限 → 借合法表 | ### 7.2 三个必要条件 1. **有写原始语**:能改到对象首字段——UAF 后被复用、堆溢出、越界写、类型混淆; 2. **有可写落点放伪表**:原表只读,所以伪表必须放堆 / BSS;若开了 CFI,伪表内的目标函数还得通过类型检查; 3. **有触发点**:`delete` 虚析构、任意一次虚派发、`dynamic_cast`,或 glibc FSOP 里的 `_IO_OVERFLOW(fp, EOF)`。 ### 7.3 手法分类 | 手法 | 要点 | 前置条件 | |---|---|---| | 经典 `vptr` 覆盖 | 首字段指向堆上伪表 | 任意堆写 + 一次虚调用 | | 低字节部分覆盖 | 只改 `vptr` 低 2 字节,留在同一映射段 | 目标表与当前表同页/邻近(PIE 低 12 位固定) | | 类型混淆 | 按错误类型解释同一对象,取到另一张表 | UAF + 对象复用 | | 组合式劫持 | 不伪造表,借对象自身状态机跳到合法入口 | 无写原语,靠字段组合 | | **受限劫持(glibc IO)** | 表必须落在 `__libc_IO_vtables` 段内 | 一次能改 `_IO_FILE_plus.vtable` 的写原语 | --- ## 八、glibc IO 的「虚表」:C 手写的跳转表 ### 8.1 它不是 C++ 虚表,但机制同构 glibc 是 C 写的,没有编译器帮你生成 `_vptr`,所以它在结构体里**显式放了一个字段**: ```c struct _IO_FILE_plus { struct _IO_FILE file; /* 到 0xd8 为止 */ const struct _IO_jump_t *vtable; /* 偏移 0xd8,一个普通字段 */ }; ``` | 对比项 | C++ 虚表 | glibc IO 跳转表 | |---|---|---| | 指针在哪 | `_vptr`,编译器**隐藏**成员,偏移 0 | `vtable`,**显式结构体字段**,偏移 `0xd8` | | 表本体 | `_ZTV*`,`.data.rel.ro` | `_IO_*_jumps`,`__libc_IO_vtables` 段 | | 表结构 | offset-to-top / RTTI + 虚函数槽 | `__dummy` / `__dummy2` + 19 个槽,固定 21 格 | | 槽位顺序 | 按虚函数声明顺序,可变 | 按 `_IO_jump_t` 定义,固定 | | 析构对应物 | 虚析构 D1 / D0(两格) | `__finish`(一格) | | 主派发入口 | 任意虚函数 | `__overflow`(FSOP 唯一的弹点) | | 类型信息 | `_ZTI`,供 `dynamic_cast` | 无 RTTI,靠「指向哪张表」表达身份 | | 一致性校验 | CFI / VTV | `IO_validate_vtable` 区间判断 + `_IO_vtable_check` 兜底 | **机制证据(glibc 2.31 源码)**——每张 `_IO_*_jumps` 都是用 `libio_vtable` 宏声明的,这个宏把变量塞进名为 `__libc_IO_vtables` 的自定义段: ```c /* libio/libioP.h */ #define libio_vtable __attribute__ ((section ("__libc_IO_vtables"))) symbol_set_declare (__libc_IO_vtables) /* 每次 IO 调用都会先过这个函数 */ static inline const struct _IO_jump_t * IO_validate_vtable (const struct _IO_jump_t *vtable) { uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables; uintptr_t offset = (uintptr_t) vtable - (uintptr_t) __start___libc_IO_vtables; if (__glibc_unlikely (offset >= section_length)) _IO_vtable_check (); /* 越界 → 正常路径下 __libc_fatal */ return vtable; } /* 调用点:先取表、再取槽 */ #define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1) /* 其中 _IO_JUMPS_FUNC(THIS) 即 IO_validate_vtable(...) */ ``` **这就是「`_IO_str_jumps` 是合法目标」的机制级原因**:不是因为它特殊,而是因为它和所有 `_IO_*_jumps` 一样住在这段里。 **「指向哪张表」就是 FILE 的动态类型**,而这一步在源码里是一次**赋值**——不是调用。`fopen` 的实现(`libio/iofopen.c:72-76`)就四行: ```c _IO_no_init (&new_f->fp.file, 0, 0, &new_f->wd, &_IO_wfile_jumps); /* 宽字符表 → _wide_data->_wide_vtable */ _IO_JUMPS (&new_f->fp) = &_IO_file_jumps; /* ★ 主表 → fp+0xd8(_IO_JUMPS(THIS) 即 (THIS)->vtable) */ _IO_new_file_init_internal (&new_f->fp); /* 直接调用:_IO_link_in 挂入 _IO_list_all */ if (_IO_file_fopen ((FILE *) new_f, filename, mode, is32) != NULL) /* 直接调用具体实现(libc_hidden_ver),不查表 */ return __fopen_maybe_mmap (&new_f->fp.file); ``` **fopen 全程 0 次虚表派发**:`__open`、`_IO_mask_flags`、`_IO_link_in`、`_IO_SYSSEEK` 全是直接调用/直接系统调用;表是留给「以后」的——源码注释写得很明白,`_IO_file_jumps_maybe_mmap` 的作用是「把 mmap 还是普通 IO 的决策**延迟到首次 read**」。真正第一次派发发生在 `fread` → `__xsgetn`、`fwrite` → `__xsputn`、`fclose` → `_IO_OVERFLOW`。 各构造入口登记的(即写入 `+0xd8` 的)表: | 构造入口 | 写入的表 | 源码位置 | |---|---|---| | `fopen` | `_IO_file_jumps` | `libio/iofopen.c:73` | | `__fopen_maybe_mmap` | `_IO_file_jumps_maybe_mmap` | `libio/iofopen.c:45` | | `fopencookie` | `_IO_cookie_jumps` | `libio/iofopncook.c:155` | | `fmemopen`(≥ 2.22) | `_IO_cookie_jumps`(内部经 `_IO_fopencookie`) | `libio/fmemopen.c:214` | | `open_memstream` | `_IO_mem_jumps` | `libio/memstream.c:90` | | `obstack_vprintf` | `_IO_obstack_jumps` | `libio/obprintf.c:136` | | `_IO_str_init_readonly` | `_IO_str_jumps` | `libio/strfile.h:94` | | `fwide` | `_IO_wfile_jumps`(把 `_wide_data->_wide_vtable` 提升为主表) | `libio/iofwide.c:99` | ⚠️ **勘误(三处)**: 1. 本节早期版本写「`fmemopen` 给 `_IO_str_jumps`」——**在 2.31 源码里不成立**。`fmemopen`(`__fmemopen`,GLIBC_2_22)与兼容符号 `__old_fmemopen`(GLIBC_2_2,`libio/oldfmemopen.c:262`)**都走 `_IO_fopencookie` → `_IO_cookie_jumps`**。`_IO_str_jumps` 在 2.31 的唯一写入点是 `strfile.h:94` 的 `_IO_str_init_readonly()`(只读字符串流)。 2. `fwide` 的动作是**复制/提升**而非"指向新表":`_IO_JUMPS_FILE_plus (fp) = fp->_wide_data->_wide_vtable;` —— 把构造时(`_IO_no_init` 第 5 参,`genops.c:581`)就已存进 `_wide_data` 的宽表**提升**为 `+0xd8` 处的主表。所以同一个字段一生被写两次:构造时窄表,`fwide` 之后宽表。这正是 `house_of_apple` / `house_of_cat` 借宽字符路径的地基。 3. 一个利用相关的细节:`_IO_obstack_jumps` 的 19 个槽里**除 `__overflow` / `__xsputn` 全是 NULL**(`libio/obprintf.c:94-114`,连 `__finish` 都是 NULL)——借表打 finish 链时它用不了,只有 `_IO_str_jumps` 一类才有可用的 `__finish` 槽。选表不只是"过校验",还要看**目标槽是否非 NULL**。 伪造 FILE 时选哪张表,就等于「把这个对象伪装成哪个类」。 ### 8.2 `_IO_jump_t` 的 21 个槽 `__dummy` / `__dummy2` / `__finish`(0x10) / **`__overflow`(0x18, FSOP 唯一弹点)** / `__underflow`(0x20) / `__uflow`(0x28) / `__pbackfail` / `__xsputn`(0x38) / `__xsgetn` / `__seekoff` / `__seekpos` / `__setbuf` / `__sync` / `__doallocate` / `__read` / `__write` / `__seek` / `__close`(0x88) / `__stat` / `__showmanyc` / `__imbue`(0xa0) ### 8.3 为什么这层差别在安全上是决定性的 C++ 侧的保护有两条腿:**表在只读段** + **`vptr` 是编译器管理的隐藏成员、难以精确命中**。IO 侧只剩一条腿——表确实也进了 `__libc_IO_vtables` 只读段,但**指针是个普通字段,偏移 `0xd8` 公开写死**,任何能写到 `_IO_FILE_plus` 的原语都能直接改它。 所以 GNU 必须额外补一层 `_IO_vtable_check`:用链接器生成的段边界符号校验 `vtable` 是否落在本段内,把「任意劫持」压成「在 18 张合法表里挑一张、再挑一格」。 --- ## 九、防护与绕过的军备竞赛 ### 9.1 防护谱系 | 版本 | GNU 动作 | 攻击侧对策 | |---|---|---| | ≤ 2.23 | 无检查 | vtable 整体搬到堆上改 | | 2.24 | `_IO_vtable_check` 越界即拒 | 改用段内合法跳表 `_IO_str_jumps` / `_IO_wstr_jumps`,借 `__finish` → `_free_buffer` 完成一次间接调用 | | **2.26** | `_IO_str_finish` 改为**直接 `free`**;`_allocate_buffer` / `_free_buffer` 字段改名 `_allocate_buffer_unused`(保留占位以维持 ABI) | `_IO_str_jumps` 的 `__finish` 链**报废** | | 2.36 / 2.37 | 仍未给宽字符跳表加保护 | `house_of_apple` / `house_of_cat` | > **版本勘误(已核对源码)**:把这个改动记作 2.31 的说法流传很广,但对比 glibc 各 release 分支的 `libio/strops.c` 可以确认边界在 **2.26**——2.25 里是 > `(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base)`,2.26 起变成 `free (fp->_IO_buf_base)`;`_IO_str_overflow` 同理,从 `(*fp->_s._allocate_buffer)(new_size)` 改为 `malloc (new_size)`。 > 即:**`_IO_str_jumps` 的经典打法只在 ≤ 2.25 有效**。 更一般的加固代际:**只读化(Full RELRO)→ 区间校验(`_IO_vtable_check`)→ 指针加密(PTR_MANGLE / vptr guard)→ 类型校验(CFI / VTV / CFG+XFG / kCFI)**。 ### 9.2 三个可复用的「抓手」 1. **合法区间只有 `0xd60`**——`_IO_helper_jumps` → `_IO_str_jumps` 之间共 18 张跳表。所以 vtable 劫持从「任意写」退化成「**在合法跳表里挑一张、再挑其中一个 entry**」;`_IO_obstack_jumps`、`_IO_str_jumps`、`_IO_help_jump_0` 就是这么被反复借用的。 2. **可以正面打检查函数本身**——`IO_accept_foreign_vtables` 基本恒为 0,泄露 `ptr_guard` 反算后改掉,或直接把 guard 改成 `&_IO_vtable_check`,就能让检查函数「自己放行自己」。**代价是需要 ld 文件**。 3. **宽字符跳表是保护缺口的量化证据**——2.36 下 19 个 `_IO_W*` 宏里,真正有引用的**只有 4 个**:`_IO_WOVERFLOW`、`_IO_WUFLOW`、`_IO_WSETBUF`(仅 `setbuf` 用)、`_IO_WDOALLOCATE`。 > **作者结论**:GNU 一定会沿 2.37 思路继续收紧 IO,**编译级 / 巧合级手法会陆续死掉,只剩数据结构层面的硬控制能活**。 #### 9.2.1 「挑 entry」限制的是入口,不是终点 第 1 条常被误读成「只能拿既有函数拼链路」。准确说法是:`_IO_vtable_check` 校验的对象是**表指针这个字段的落点**(第一跳入口),**不是表内容、也不是最终 RIP**。把链路拆成四跳就很清楚: | 跳 | 动作 | 是否受约束 | |---|---|---| | ① | `fp->vtable` 字段的落点 | **受限**:值必须落在 `__libc_IO_vtables` 段内 | | ② | 表项 = 函数地址 | **冻结**:段只读,内容是既有函数(`_IO_cookie_read` / `_IO_obstack_overflow` / `_IO_str_finish`…) | | ③ | 既有函数体内的间接调用 `call [对象字段]` | **自由**:`cookie->read`、`_s._free_buffer`、`_wide_data` 相关字段都在堆上 | | ④ | 最终 RIP | **自由**:`setcontext` 路径甚至没有跳转,全是 `movq oXXX(%rdx)` 内存读 | 所以「表」的角色是**跳板库**(给你一个合法的起步点),不是「攻击函数候选池」。第一跳的函数签名固定为 `int f(FILE *, int)`,你只能决定"是哪张表的哪个槽",不能决定"这个函数干什么"——而这已经够用,因为**参数(你的伪造对象)是你自己写的**: ```c /* libio/iofopncook.c:33-45 —— 表项是既有函数,但函数体会去对象里取函数指针 */ static ssize_t _IO_cookie_read (FILE *fp, void *buf, ssize_t size) { struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp; cookie_read_function_t *read_cb = cfile->__io_functions.read; /* ← 值来自伪造对象 */ #ifdef PTR_DEMANGLE PTR_DEMANGLE (read_cb); /* ← 启用指针加密时先解混淆 */ #endif return read_cb (cfile->__cookie, buf, size); /* ← 任意地址调用 */ } ``` 于是真正的门槛从「能不能造函数」变成三件事: 1. **挑得到合适的槽**——不仅要"不越界",还要**非 NULL**:`_IO_obstack_jumps` 的 19 槽里只有 `__overflow` / `__xsputn` 非空(`libio/obprintf.c:94-114`),连 `__finish` 都是 NULL; 2. **摆得动对象字段**——对象在堆上、字段随你写,但必须能被那个既有函数**按固定偏移取到**(结构要匹配:`_IO_cookie_file.__io_functions`、`_IO_strfile._s._free_buffer`); 3. **注意字段级加密**——`cookie->read` 这类字段被包在 `#ifdef PTR_DEMANGLE` 里,启用 pointer guard 的构建上必须先泄露 guard 再解出可用地址。 「任意构造攻击函数」也是能做到的,但那要走第 2 条的正面路线:改掉检查函数 / guard 变量,让表指针指向堆上**自造伪表**,此时第一跳也自由——代价是需要 ld 文件泄露。 ### 9.3 FSOP:把「数据」变成「控制流」的开关 ```mermaid flowchart LR A["exit() / main 返回"] --> B["_IO_flush_all_lockp<br/>从 _IO_list_all 出发"] B --> C{"沿 fp->_chain 遍历<br/>条件:_mode ≤ 0 且 write_ptr 大于 write_base"} C -->|"命中"| D["_IO_OVERFLOW(fp, EOF)<br/>经 vtable 取函数指针"] D -->|"劫持后"| E["落到 __libc_IO_vtables 段内<br/>某张合法跳表的某个槽"] ``` **图 6 FSOP 触发链** 触发时机注意:`abort` 在 2.27 后不再刷新,`__malloc_assert` 在 2.36 后不再刷新,这两条老路已废。 ### 9.4 终点:借虚表把控制流送进 `setcontext` `setcontext+53` 是老 pwn 的看家手段,靠它一次性给 `rdi/rsi/rdx/rsp/rip` 赋值再 `mprotect` + ORW。但 glibc 2.31 之后变成 `setcontext+61`,**上下文指针从 `rdi` 换到了 `rdx`**——而 IO_FILE 攻击链天然只能控制第一个参数 `rdi`,`rdx` 够不到。 `house_of_KIWI` / `house_of_cat` 的思路是「碰运气」,去挑那些编译器**恰好**用了 `rdx` 的代码(如 `_IO_switch_to_wget_mode`),属于**编译级别的耦合**,编译器换版本就废。 真正的破局点在 `__setcontext` 的汇编里: ```asm ENTRY(__setcontext) pushq %rdi # 先存下参数 leaq oSIGMASK(%rdi), %rsi movl $SIG_SETMASK, %edi movl $__NR_rt_sigprocmask, %eax syscall # 信号掩码系统调用 popq %rdx # ★ rdi 变 rdx 的根源(GNU 有意为之) ... /* 以下即 setcontext+61 */ movq oRSP(%rdx), %rsp # 全部是「(%rdx)+偏移」的直接内存读 movq oRBX(%rdx), %rbx movq oRBP(%rdx), %rbp movq oR12(%rdx), %r12 ... oR15 movq oRSI(%rdx), %rsi movq oRDI(%rdx), %rdi movq oRCX(%rdx), %rcx movq oR8(%rdx), %r8 ... oR9 movq oRIP(%rdx), %r10 → push %r10; ret movq oRDX(%rdx), %rdx ``` **这里没有一次 `call [reg]`,没有虚表,没有任何间接跳转**——纯数据结构解引用。所以只要能让 `rdx` 指向自己伪造的 `ucontext`,就等于拿到全套寄存器,且**与编译器、与版本演进都无关**。 伪造 `ucontext_t` 只需管 4 个字段: | 字段 | 偏移 | 注意 | |---|---|---| | `uc_mcontext.gregs` | `0x28` | 提供寄存器值(含 RIP / RSP) | | `fpregs` 指针 | `0xe0` | 会因 `fldenv [rcx]` 被写,**必须指向可写内存** | | `uc_sigmask` | `0x128` | 填 0 即可 | | `MXCSR` | `0x1c0` | 填 0 即可 | 完整利用范式: ```mermaid flowchart LR A["largebin_attack / UAF<br/>伪造 _IO_FILE_plus"] --> B["改写 vtable 字段<br/>指向段内合法跳表"] B --> C["触发 _IO_* 调用<br/>控制流进入 setcontext"] C --> D["rdx 摆成堆上 ucontext 地址<br/>一次性接管全部寄存器"] D --> E["RIP=mprotect, RDI=heap<br/>RSI=0x20000, RDX=7"] E --> F["堆变 RWX<br/>跳 shellcode 执行 ORW"] ``` **图 7 house_of_一骑当千 完整攻击链** **一句话概括这套攻击的核心**:**虚表劫持只是引信,`setcontext` 才是弹头**。虚表负责把控制流从 IO 路径「借道」进 libc,`setcontext` 负责一次性接管寄存器并绕开所有编译器耦合。 ### 9.5 `ucontext` 是什么:它不是攻击函数,是「第二级参数载体」 **`__setcontext` 是攻击函数(借来的、合法的、不用伪造);`ucontext` 是攻击参数(你伪造的、纯数据)。** 而且它不是普通标量参数——`rdx` 里存的是它的**地址**,真正的寄存器值全在那块内存里,所以它是「间接参数」,本质是一份**寄存器快照结构体**。 ```c /* glibc sysdeps/unix/sysv/linux/x86/sys/ucontext.h(x86-64) */ typedef struct ucontext_t { unsigned long int uc_flags; /* +0x000 */ struct ucontext_t *uc_link; /* +0x008 */ stack_t uc_stack; /* +0x010 ← setcontext 不读 */ mcontext_t uc_mcontext; /* +0x028 ← 主战场 */ sigset_t uc_sigmask; /* +0x128 被 rt_sigprocmask 读 */ struct _libc_fpstate __fpregs_mem; /* +0x1a8 fldenv 的写入目标 */ unsigned long long int __ssp[4]; /* +0x3a8 CET 影子栈开启时才读 */ } ucontext_t; /* sizeof = 0x3c8 */ ``` | 对比项 | 攻击函数 | 攻击参数(`ucontext`) | |---|---|---| | 本质 | 机器码 | 纯数据:14 个 64 位整数 + 指针 / 掩码 | | 位置 | libc `.text`(`R-X`) | 堆(`RW-`,完全由攻击者掌控) | | 来源 | 既有导出符号 `setcontext`,无需伪造 | 本来就由攻击者伪造 | | 约束 | 第一跳要落在 `__libc_IO_vtables` 段内 | 只要求地址能被 `rdx` 指到 | | 作用 | 把控制流交给你 | 决定交出之后**整套 CPU 状态** | 对照 9.2 的结论会更清楚:**`_IO_FILE_plus` 之于 IO 跳表项,与 `ucontext` 之于 `__setcontext`,是同一范式套了两层**——"借来的既有函数,去读你写的结构体"。 ```mermaid flowchart LR A["伪造 _IO_FILE_plus<br/>堆 · RW-"] -->|"表项读对象字段"| B["IO 跳表项<br/>libc · R-X"] B -->|"控制流借道"| C["__setcontext<br/>libc · R-X"] D["伪造 ucontext<br/>堆 · RW-"] -->|"rdx 传址"| C C -->|"14 槽 + RAX 清零"| E["新 CPU 状态<br/>RIP / RSP / RDI / RSI / RDX"] ``` **图 8 代码与数据的两次借道** #### 为什么必须用「参数」而不能直接构造「代码」 沙盒下要执行 ORW,第一步得 `mprotect(heap, 0x20000, 7)`——这要求 `RIP`、`RDI`、`RSI`、`RDX` 四个寄存器**同时**正确。ROP 得一个 gadget 一个 gadget 去凑,而 `__setcontext` 是 libc 里唯一「一次性给整套寄存器赋值」的原子操作。**它天生只接受一份「寄存器花名册」作为输入**,所以攻击的最终产物必然是**数据结构**,不可能是代码。这正是「编译级 / 巧合级手法会死,只剩数据结构层面的硬控制能活」的机制性原因。 #### `rdx` 从哪来:源码注释说明这是「有意为之」 ```asm /* Pop the pointer into RDX. The choice is arbitrary, but leaving RDI and RSI available for use later can avoid shuffling values. */ popq %rdx ``` GNU 明说"选 `rdx` 是任意的",理由是**要把 `RDI` / `RSI` 腾出来留给自己后面恢复用**——恰好白送攻击者一个完全可控的寄存器。而 IO 攻击链本来就握有对象指针(常以 `rdi` 传入),把对象某个字段摆成 ucontext 地址即可让 `rdx` 拿到它。 #### 三个反直觉点(都有源码依据) | # | 事实 | 依据 | |---|---|---| | 1 | **`uc_stack` 完全不参与**——新栈顶来自 `gregs[REG_RSP]`(`0xa0`),不是 `uc_stack.ss_sp` | `setcontext.S` 全文没有任何 `uc_stack` 引用 | | 2 | **不是 23 个槽全读**:`RAX` / `R10` / `R11` / `EFL` 不恢复,且 `rax` 在结尾被 `xorl %eax,%eax` 强制清零 | 恢复列表仅 RSP/RBX/RBP/R12~R15/RSI/RDI/RCX/R8/R9/RIP/RDX | | 3 | **新 `RSP` 必须可写**:源码用 `pushq %rcx` 把新 RIP 压进「新栈」再 `ret` | `pushq oRIP` 写的是切换**后**的栈 | 另外两个必须预埋的内存条件:`fpregs`(`0xe0`)是**指针**,`fldenv (%rcx)` 会**写**它指向的内存 ⇒ 必须指向可写内存(最省事就是指向自身的 `__fpregs_mem`,`0x1a8`);`uc_sigmask`(`0x128`)在寄存器恢复**之前**就被 `rt_sigprocmask` 读走,填 0 即可。 #### 完整 `gregs` 偏移表(x86-64 glibc 2.31 核对) | 槽 | 偏移 | setcontext 动作 | 槽 | 偏移 | setcontext 动作 | |---|---|---|---|---|---| | `R8` | `0x28` | 恢复 | `RBP` | `0x78` | 恢复 | | `R9` | `0x30` | 恢复 | `RBX` | `0x80` | 恢复 | | `R10` | `0x38` | **不读** | `RDX` | `0x88` | 恢复 | | `R11` | `0x40` | **不读** | `RAX` | `0x90` | **不读**,结尾清零 | | `R12` | `0x48` | 恢复 | `RCX` | `0x98` | 恢复 | | `R13` | `0x50` | 恢复 | `RSP` | `0xa0` | 恢复(新栈顶) | | `R14` | `0x58` | 恢复 | `RIP` | `0xa8` | 恢复(新指令地址) | | `R15` | `0x60` | 恢复 | `EFL` | `0xb0` | **不读** | | `RDI` | `0x68` | 恢复(`mprotect` 第 1 参) | `CSGSFS` ~ `CR2` | `0xb8` ~ `0xd8` | 内核字段,用户态不读 | | `RSI` | `0x70` | 恢复 | `fpregs` | `0xe0` | 取指针后 `fldenv` 写入 | 偏移核对方式:按上表结构用 `ctypes` 复算,与 glibc 自带的 `sysdeps/unix/sysv/linux/x86_64/ucontext_i.sym` 逐项一致(`oSIGMASK 0x128`、`oFPREGS 0xe0`、`oMXCSR 0x1c0`、`oSSP 0x3a8`)。 #### 现代变数:CET 影子栈 2.31 的 `setcontext.S` 里有整段 `#if SHSTK_ENABLED` 分支:会读 `oSSP(%rdx)`(`0x3a8`)与 `oSSP+8`,做 `rdsspq` / `rstorssp` / `incsspq` 的比对与展开;CET 构建下 RIP 也走 `%r10` 而非 `%rcx`。**在开启 SHSTK 的发行版上,伪造 ucontext 还必须让影子栈状态自洽**——这是 `setcontext` 这条终点路线在现代环境下的新门槛。 --- ## 十、速查清单 **对象与表** - [ ] 判据是「**类**有没有虚函数」,不是「对象怎么来的」; - [ ] `_vptr` 在偏移 `0x00`,**它的地址 = `&obj`,但它的值 ≠ `&obj`**; - [ ] `vptr` 指向表内**地址点**,故 gdb 显示 `vtable for T+16`; - [ ] `vptr[-1]` = `&_ZTI`(RTTI),`vptr[-2]` = `offset-to-top`; - [ ] 同类所有对象的 `vptr` 值**完全相同** → 泄露一次 ≈ 全类失守。 **构造 / 析构** - [ ] 构造函数**不在**虚表里,它是 `vptr` 的**写入者**; - [ ] 虚析构**在**表里且**占两格**(D1 / D0),声明后后续槽位后移 8 字节; - [ ] 生命周期中 `vptr` 被改写 4 次,构造/析构期虚调用只派发到当前层; - [ ] 没有「虚构造劫持」这种手法。 **数量与生成** - [ ] 无虚函数 ⇒ 0 张表、无 `vptr`;多继承 `k` 个多态基类 ⇒ `k` 段表; - [ ] GCC 把多段表打包进**一个** `_ZTV` 符号,MSVC **每基类一个**符号; - [ ] 关键函数规则决定表在哪个 TU 发出;全内联/纯虚类每 TU 各发一份,链接期 COMDAT 合并; - [ ] 表在 `.data.rel.ro`,RELRO 后只读;偏移是编译期常量,**只能改表基址与表内容**。 **利用与加固** - [ ] 三个必要条件:可写 `vptr` 的原语 + 可写落点放伪表 + 一个触发点; - [ ] glibc IO 的 `_IO_FILE_plus.vtable`(`0xd8`)是**普通字段**,被 `_IO_vtable_check` 限定在 `__libc_IO_vtables` 段内(18 张表 / `0xd60`); - [ ] FSOP 唯一弹点是 `__overflow`,触发于 `exit` / `main` 返回; - [ ] 绕过终点是**转打数据面**:`setcontext+61` 的 `movq oXXX(%rdx), %reg` 全是直接内存读,不走间接跳转,因此不受 CFI 约束。 --- ## 附录 A:术语与符号对照表 | 概念 | Itanium(GCC/Clang) | MSVC | |---|---|---| | 虚表符号 | `_ZTV<Class>` | `??_7Class@@6B@`(每基类一个) | | RTTI | `_ZTI<Class>` | `??_R0` 系列 | | 类型名 | `_ZTS<Class>` | `??_R0` 内的字符串 | | 完整对象构造 | `C1` | `??0Class@@` | | 基类对象构造 | `C2` | `??0Class@@` 变体 | | 完整对象析构 | `D1` | `??1Class@@` | | 删除析构 | `D0` | `??_GClass@@`(标量删除析构) | | 基类对象析构 | `D2` | — | | 构造虚表 | `_ZTC...` | — | | VTT | `_ZTT...` | — | | 对象内指针名 | `_vptr$Class`(DWARF) | `__vfptr`(PDB) | | 纯虚槽填充 | `__cxa_pure_virtual` | `_purecall` | | glibc IO 跳转表 | `struct _IO_jump_t`,21 槽 | — | | glibc IO 表字段 | `_IO_FILE_plus.vtable`,偏移 `0xd8` | — | --- ## 附录 B:可执行验证命令 ```bash # 数一数某个动态库里的虚表符号 nm -C --defined-only libstdc++.so.6 | grep -c ' _ZTV' readelf -sW your_binary | grep -E '_ZTV|_ZTC|_ZTT' # 逐类列出 vtable 布局(含 offset-to-top / RTTI / 槽位) g++ -fdump-lang-class a.cpp clang++ -Xclang -fdump-vtable-layouts -fsyntax-only a.cpp # 看某个对象的 vptr 与它指向的表 gdb -q ./a.out (gdb) p *obj # 会打印 _vptr$T = 0x... <vtable for T+16> (gdb) p ((void**)(*(void**)obj))[-1] # vptr[-1] → &_ZTI,判断真实类型 (gdb) x/8gx *(void**)obj # 直接 dump 地址点前后几格 # 把虚表所在的段确认为只读 readelf -SW a.out | grep -E 'data.rel.ro|relro' ``` **最小验证样例**(用于观察 `_ZTV` 的负索引格与虚析构两格): ```cpp #include <cstdio> struct Base { virtual void f() { std::puts("Base::f"); } virtual ~Base() {} }; struct Derived : Base { void f() override { std::puts("Derived::f"); } ~Derived() override {} }; int main() { Derived d; Base* p = &d; p->f(); return 0; } ``` --- ## 参考 - Itanium C++ ABI, §5.2.3 Virtual Table Layout - glibc `libio/libioP.h`(`_IO_jump_t` 定义)、`libio/vtables.c`(`_IO_vtable_check`) - glibc `sysdeps/unix/sysv/linux/x86_64/setcontext.S` - 看雪 Pwn 系列《无路远征——GLIBC 2.37 后时代的 IO 攻击之道》(零 / 一 / 二 / 三 / 四 / 五) - 2022 强网拟态决赛 `_vpn`(`pminote_mc`)题解 --- > 本文所有结论均基于 ABI 规范与公开题解整理,用于防御性安全研究。文中涉及的具体利用手法请勿用于未授权场景。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
1
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
inquisiter
2
14
发帖
63
回帖
140
RANK
关注
私信
他的文章
[原创]C++ 虚表全解剖:从对象内存布局到 glibc IO 攻击链路
46
[原创] Android PIN 解锁全链路:Gatekeeper · TEE · KeyMint 与 FBE 密钥解封
165
[原创]CVE-2022-0995分析(内核越界 watch_queue_set_filter)
15856
CVE-2021-26708 利用四字节释放特定地址,修改内存
23326
[原创][原创]cve-2019-15666 xfrm_policy 提权漏洞
11526
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部