前言:黄鹤杯中唯一的一道 pwn 题目,最近生了点小病导致学习进程一拖再拖。暑假一直在家躺着也不怎么想学习,但还是感觉自己和高手之间差了很多很多。黄鹤杯是 ai 打出来的,尽管黄鹤杯初赛并不限制 ai 的使用。于是决定赛后复现一下,看看有没有什么能够学到的知识。
这道题目没给 libc,只是单单给了一个 A-C 的二进制文件,先看看吧:

IDA 静态分析:

找到 main 函数简单看了一下:

dest 是 bss 段上的,也就是说最开始会在 dest 存放 ./flag,在 0x20 的位置存放一段字符串,dest+0x40 的地方存一个指针,该指针打印 a1+0x20 的内容,也就是 dest+0x20。
沙箱:

很明显是开沙箱了的,看一看开了什么:

大部分都能用,还好
菜单:

create:
create 主要是一个创建的功能,代码比较长,就不放出来了
主要是创建了这么一个结构体:
1 | struct note { |
- 最开始申请一个大小为 0x30 的对象结构体:
ptr = calloc(1, 0x30); - 然后规定了大小必须在 1~0x700 之间

运行一遍程序就可以知道个大概

不过这里存在一点问题: *(_QWORD *)ptr = malloc_usable_size(*((void **)ptr + 2));直接赋值了capacity,最后却使用 v1 = atoi();if ( v1 <= *(_QWORD *)ptr )来作为 if 判定,那么我们后面就可以利用capacity作为漏洞点进行溢出。
edit:

1 | v3 = (unsigned __int64 *)qword_5080[index]; |
从这几行代码我们不难知道,*v3 实际上指的就是结构体当中的capacity,和 create 的逻辑一样,依旧要保证自己输入的大小要小于capacity
show:


在 show 函数当中 printf 规定泄露数据大小时使用的是*(_QWORD *)v1,依旧是 capacity
delete:

delete 还好,并不存在什么值得注意的地方
pack:

问题主要出在 sub_1FF4,不细看还发现不了:
1 | __int64 __fastcall sub_1FF4(_BYTE *a1, unsigned __int64 a2, _BYTE *a3) |
举个例子,他会把连续相同的字节压缩成一个:因此这个函数叫做 pack 哈哈真阴啊
- 原始 data: aa aa aa bb bb cc aa aa
- pack 后: aa bb cc aa
在此之后 pack 会修改 *(v3 + 0x10) = dest;->data & *(v3 + 0x08) = size;->length
但是我们的capacity依旧没有修改,通过这一步,我们只需要构建连续重复字节就可以构建一个capacity足够大且其实际 chunk 大小很小以至于我们可以进行堆重叠的 chunk。
读取文件函数:

程序当中实则还有这么一个隐蔽的函数,是用来读取文件内容的
EXP 思路:
- create(0) 创建一个大 note,使 note0->capacity 记录大块 buf 的malloc_usable_size。
- pack(0) 将 note0->buf 压缩后重新分配为小 chunk,但没有同步更新note0->capacity
- 后续 edit(0)和 show(0) 仍按旧的 capacity 读写新 chunk,从而形成以 note0->buf 为起点的堆越界读写。
- create(1) 让 note1 结构体落入 note0 的越界读写范围内。
- 通过 edit(0) 越界覆盖 note1 的 capacity / len / buf 字段,将 show(1) /edit(1) 转化为任意地址读写原语。
- 利用任意读在堆上搜索 archive 对象的特征字符串 ./flag\x00,定位 archive 对象地址。
- 读取 archive + 0x40 处的 close 回调指针,泄露 sub_1311 真实地址并反推出 PIE 基址。
- 将 archive + 0x40 的回调指针改写为 sub_1330。
- 选择 exit 触发退出流程,程序调用被劫持后的 close 回调,即 sub_1330(archive),最终打开并打印 ./flag。
封装函数
封装过程不多讲,就是把几个有用的函数简单封装起来了:
1 | def choice(cho): |
构造越界
1 | create(0,b'note0',0x700,b'a'*0x700) |
我们知道在 pack(0)之后,实际 data 区只剩下 1 个字节的大小了,但是会保留最小堆大小为 0x20,

前面这里 create 每次会创建两个堆,一个 0x40 的用于存放 struct_note,另一个就是根据开辟大小确定的堆

我们可以看一看当前的堆情况:看似所有的大小确实改了,但是我们之前提的 capacity 依旧是没有修改的,
1 | struct note { |
我们看上面的图片,根据结构体看 capacity 的值:0x708 是依旧没有修改的,这就造成了越界。

泄露 data 指针定位堆地址:
data 指针说的就是我们存入数据的起始位置,也就是下一个 chunk 的 data 域,通过这个我们可以定位堆地址
1 | _, meta, leak = show(0) |

篡改 note 结构体,按页扫 flag:
由于 flag 在 sub_13E1 当中,是程序启动时提前在堆上分配的,这里面存在 ./flag\x00。他通常在一个 heap 区域当中,但是又不能写死,因此我们从 heap_base 页对齐开始扫,知道扫描出来 flag。由于是提前分配的,或许 flag 的地址本身就不在该页,因此扫不到我们就需要往前一页的低地址去找,这一点的话感觉 26 年 CISCN 初赛的那一道内核题目也是按页扫的 flag,有点相似。

我们想要按页泄露,就需要篡改 note 的结构体,修改其起始地址和长度:
1 | size = 0x1000 |
嗯,没的说,然后修改完直接去找就行了:
1 | leak_data = show(1)[2][:size] # 从 heap_base 地址开始,读取 size 字节,保存到 leak_data |
动调发现实际上还是在本页:

泄露 flag:
当前实质上拿到的仅仅是字符串’./flag\x00’的地址,并不是真正的 flag,上面我们分析过该字符串附近有指针:

dest 是 bss 段上的,也就是说最开始会在 dest 存放 ./flag,在 0x20 的位置存放一段字符串,dest+0x40 的地方存一个指针,该指针打印 a1+0x20 的内容,也就是 dest+0x20。(这里我直接把上面的分析重新粘贴过来了)
这时候我们可以想办法去泄露出该指针的内容,也就是指针内存放的地址,拿到之后我们就可以解决 pie 的问题

程序当中实则还有这么一个隐蔽的函数,是用来读取文件内容的,我们有了 pie 同样可以计算该函数的地址
1 | sub_13E1_ptr = flag_string_addr + 0x40 |
最后利用两次 edit 篡改 dest+0x40 处的地址指针为该读取文件的地址,第一次伪造 char *buf 为 sub_1311_ptr 指针,然后往其对应的内存(这个时候已经篡改了)覆盖读取文件的地址,利用 exit 触发:
1 | case 6LL: |
此时:dest = “./flag\x00”,dest + 8 = sub_1330,就是读取文件内容:
1 | edit(0,b'a'*0x20 + flat(8,8,sub_1311_ptr,b'a'.ljust(24,b'\x00'))) |
总 EXP:
1 | from pwn import* |

后记:一道 pwn 题目还是很有收获的,一个是按页扫 flag 字符串地址,一个是最开始审计代码的时候漏掉了能够读取文件内容的函数,题目最后有点类似于篡改 got 表的那种利用手法,也就是篡改指针内容然后利用。时不时自己写点 pwn 题目比 ai 开出来来的成就感多得多。之后要学好使用 ai 并向 ai 学习,有很多知识都是通过 ai 分析得出的,平时也需要问一问自己,如果断网,自己能否独立写出来这些题目。
说些什么吧!