☰
07|Off-by-one:一个字节能改出什么
2026/10/28 1:42:12 网站建设 项目流程

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->fd

F + 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+

加了chunksize(p) != prev_size检查,必须伪造假头

2.32+

指针加密,部分覆写前要先泄堆地址

最后

一个字节能改的东西很少,但改对了地方——size——分配器的整个坐标系就跟着歪。

它不写代码,它只让分配器算错位置。剩下的活,是分配器自己干的。


📌 相关阅读
· 上一篇:《06|Chunk Extend:改一个 size,造出两块重叠内存》
· 下一篇:《08|Unsafe Unlink:为什么它需要伪造一个指向自己的指针》
· 实战配套:《Off-by-one 实战:清 library[0] 低字节(b00ks)》

全部 exp 与 gdb 脚本:<GitHub 链接>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询