07|Off-by-one:一个字节能改出什么
一个字节能干什么?
在堆上,它什么都写不了——写不了代码、写不了地址、连一个指针都凑不齐。
但它能改一个数。改完这个数,分配器看世界的坐标系就歪了。
这篇讲的就是这件事。
栈上的那一个字节
先说栈,短一点。
如果溢出发生在栈上,多出来的那个字节通常正好压住 Saved RBP 的最低一个字节,而且通常是\x00。
小端序下最低字节在内存最前面,所以0xbffff4c8被清零后变成0xbffff400——地址还在,只是往下对齐了 0xc8。
看起来没什么杀伤力,直到函数返回:
leave ; mov esp, ebp; pop ebp ← 被改过的 ebp 恢复进寄存器 ret当前函数返回时用的是改过的 ebp,ebp 已经跑到了你输入的那片缓冲区里;调用者再执行一次leave,mov esp, ebp直接把栈顶拉进缓冲区,接下来的ret就从你控制的内存里取返回地址。
这就是栈迁移(stack pivot)。一个字节,换一个栈。
堆上才是主战场
堆上的单字节溢出有个更常见的名字:Poison Null Byte。
原因是它多出来的那个字节几乎总是\x00——read之后补结束符、strcpy自动补、循环写成<=,出来的都是 0。
而它落的位置很刁:下一个 chunk 的 size 字段的最低字节。
为什么偏偏是 0x00
chunk 大小必须是 16 的倍数,所以 size 的低 4 位本来就没用,glibc 拿低 3 位当标志:
bit0 PREV_INUSE 前一块是否在用 bit1 IS_MMAPPED 是否 mmap 出来的 bit2 NON_MAIN_ARENA 是否属于非主分配区一个 0x110 的块,前一块在用,size 就是0x111。
\x00一盖,变成0x100。
两件事同时发生:
1. 三个标志位全清零,包括 PREV_INUSE → 分配器认为"前一块是空闲的" 2. 大小从 0x110 变成 0x100 → 凭空缩水了 0x10 字节第一件是我们想要的,第二件是我们不想要的。
缩水为什么致命
free的时候,分配器要用 size 去找下一块:
#define next_chunk(p) ((mchunkptr)(((char *)(p)) + chunksize(p)))它算出来p + 0x100,以为那里是下一个 chunk 的头。可实际上下一个 chunk 在p + 0x110——p + 0x100只是那个块内部的一堆用户数据。
读到一个不像样的 size,直接报错:
free(): invalid next size (normal)或者更晚一点,在合并的时候:
corrupted size vs. prev_size三种把账做平的办法
办法一:让缩水等于零
如果原来的 size 是0x101(也就是块大小正好是 0x100),清零之后是0x100——一点没缩,只是把标志位清了。
这是最干净的做法。所以做这种题的时候,第一件事就是盯着尺寸算:能不能让它落在一个 0x?00 上。
办法二:在缩水的位置伪造一个头
如果尺寸被迫是0x111,缩水成0x100,那就提前在块内部偏移 0x100 的地方,手写一个像样的 chunk 头。
分配器顺着错的 size 找过来,看见一个合法的头,也就认了。
办法三:不跟它讲道理,走 unlink
这就是下面那节的内容。
2.29 之后,多了一条命门
以前伪造一个巨大的prev_size,free 时分配器直接跳过去合并,不怎么问。
2.28/2.29 加了这一句:
if (__glibc_unlikely (chunksize(p) != prev_size(nextchunk))) malloc_printerr ("corrupted size vs. prev_size while consolidating");翻译过来:跳过去之后,先看一眼那个块的 size,和你写的 prev_size 对不上就退出。
老办法为什么死:你写的 prev_size 是个大数,落点那片内存里是普通数据,size 字段是一堆乱码,永远对不上。
死结在于——你要么能改那个位置的 size(做不到,太远),要么就得让那个位置本来就有个合法块。
破局:借 large bin 的残骸
既然凭空造一个块很难(因为 unlink 还要满足p->fd->bk == p和p->bk->fd == p,需要真实的堆地址),那就借。
关键观察:一个曾经在 large bin 里待过的块,被分配出来给用户用之后,fd_nextsize/bk_nextsize里残留的指针不会被清掉。
那是指向堆里的合法地址。
于是在这个块的内部重新划一个起点,把残留的fd_nextsize当成伪造块的fd、残留的bk_nextsize当成bk,再在自己能写的区域里填上伪造的size。
三个问题一次解决:
size → 自己在数据区写,想写多少写多少,和 prev_size 对齐即可 fd/bk → 直接用残留的合法堆地址 位置 → 就在自己块内部,溢出够得着剩下的是让 unlink 的两个校验成立。
校验一:p->fd->bk == p
难点是快速定位"bk"。这里有个很妙的错位:
假设把p->fd指向一个 fastbin 块的头地址 - 0x8。
p->fd = F - 0x8 p->fd->bk = *(F - 0x8 + 0x18) = *(F + 0x10) = F->fdF + 0x10正好是 fastbin 块的fd字段(fastbin 只有 fd,没有 bk)。
所以分配器以为自己读的是 bk,实际读到的是那个 fastbin 块的 fd。
只要事先把这个 fd 改成p的地址(单字节部分覆写就够,因为两者都在堆上、高位一样),校验就成立。
校验二:p->bk->fd == p
p->bk用的是残留值,我们不敢改(不知道堆地址,乱写会段错误)。
如果 large bin 里当时只有一块,那bk_nextsize指向它自己——于是p->bk->fd就是那个块的fd。
而那个fd的物理位置,正好落在我们伪造块时写脏的区域里。
脏了怎么办?用一个 fastbin 块把它洗一遍:在那个位置放一个 fastbin 块,glibc 会往那里写一个合法的链表指针,然后再单字节覆写成p。
两个校验都过了,unlink 就会乖乖执行,合并出重叠。
这个洞是怎么写出来的
两种写法最常见。
自己补后缀
void read_msg(char *p, int n) { read(0, p, n); p[n] = '\n'; /* 差一位:合法下标是 0 ~ n-1 */ }strncat 的隐式补零
strncat(dst, src, n); /* 复制 n 个字符,再补一个 '\0',共 n+1 字节 */很多人以为第三个参数是"目标剩余空间",其实是"最多从 src 拿几个字符"。结束符是额外送的。
一个完整流程长什么样
拿 0CTF 2017 BabyHeap 那类题举例,流程是固定的:
1. 布局:chunk0(溢出源)、chunk1(受害者)、chunk2(触发合并)、guard 2. free(chunk1) → 进 unsorted,chunk2 的 PREV_INUSE 被清 3. 申请一个略小的块,从 chunk1 里切走一块,剩下的那块 prev_size 被更新 4. 填满新块的 user 区,末尾的 \x00 压到剩余块的 size 低字节 5. 在剩余块内部伪造 prev_size / size,让合并时不报错 6. free(chunk2) → 向后合并,把正在使用的块也吞进去 7. 重新申请大块 → 拿到重叠区 8. 从重叠区里写 tcache/fastbin 的 next → 打 __malloc_hook这题的关键不在第 4 步,在第 5 步。只清标志位不做假头,2.29 上必崩。
版本
glibc | 情况 |
≤ 2.27 | 清 P 位 + 伪造 prev_size 基本就够 |
2.28+ | 加了 |
2.32+ | 指针加密,部分覆写前要先泄堆地址 |
最后
一个字节能改的东西很少,但改对了地方——size——分配器的整个坐标系就跟着歪。
它不写代码,它只让分配器算错位置。剩下的活,是分配器自己干的。
📌 相关阅读
· 上一篇:《06|Chunk Extend:改一个 size,造出两块重叠内存》
· 下一篇:《08|Unsafe Unlink:为什么它需要伪造一个指向自己的指针》
· 实战配套:《Off-by-one 实战:清 library[0] 低字节(b00ks)》
全部 exp 与 gdb 脚本:<GitHub 链接>