介绍
本篇将重点讲解漏洞的利用开发过程,介绍在此过程中遇到的各种挑战,以及如何将这些挑战解决并最终实现远程代码执行,进而获得 Shell。
在深入漏洞和利用的细节之前,值得花点时间了解一下 HTTP 分块请求的基本原理和漏洞的基础知识。
HTTP 分块编码回顾
HTTP 请求通过设置Transfer-Encoding: chunked
HTTP 头来告诉服务器,请求体应该分块处理。每个块遵循一个通用的编码方案:一个包含块大小(以十六进制表示)的头部,后面是实际的块数据。由于是 HTTP,字符序列\r\n
用作块大小头部和块数据之间的分隔符。最后一个块(终止块)总是一个零长度的块。
使用分块编码的典型请求看起来像这样:
POST /somepath HTTP/1.1
Transfer-Encoding: chunked
4
AAAA
10
BBBBBBBBBBBBBBBB
0
上述请求包含两个块:一个 4 字节的块和一个 16 字节的块(块大小以十六进制解析),然后是零长度的终止块。服务器会解析块大小,并使用这些信息构造一个由连接的块数据组成的单一数据块,去掉大小元数据。
漏洞概述与初步原语
让我们回顾一下漏洞的基本原理以及它提供的原语。触发内存损坏的相关代码片段如下所示:
char *chunkstart, *chunk, *endptr, *endbuf;
// `chunk`、`endbuf` 和 `chunkstart` 都开始指向请求体的起始位置
chunk = endbuf = chunkstart = h->req_buf + h->req_contentoff;
while ((h->req_chunklen = strtol(chunk, &endptr, 16)) > 0 && (endptr != chunk) )
{
endptr = strstr(endptr, "\r\n");
if (!endptr)
{
Send400(h);
return;
}
endptr += 2;
// 这个 memmove 调用使用了由 strtol() 解析的块大小
memmove(endbuf, endptr, h->req_chunklen);
endbuf += h->req_chunklen;
chunk = endptr + h->req_chunklen;
}
重要细节回顾:
-
strtol()被用来解析请求体中的 HTTP 分块大小(这是我们完全控制的部分)。strtol() 返回的值被保存在
h->req_chunklen
中。 -
h->req_chunklen被用作
memmove()
函数中的大小参数,但没有进行边界检查。 -
memmove()
的目标(dest
)和源(src
)参数都是指向请求缓冲区的偏移量;理论上,它们分别应该指向分块大小的第一个数字和紧随其后的实际数据块的第一个字节。 -
包含我们数据的请求缓冲区是在堆上分配的。
由于上面代码缺少边界检查(以及导致该问题的验证逻辑错误),漏洞提供了一个 OOB(越界)读写原语,可以任意设置大小。此时,我几乎无法控制写入的内容以及写入的位置。由于损坏发生在堆上分配的数据上,这引入了直接攻击应用程序数据或目标堆元数据的选项,从而可以推导出更强大的原语。
理解内存损坏机制
注意:以下章节中提到的所有“块”都指的是 HTTP 分块,而不是堆分块。
memmove()
的影响
我们从对漏洞开发影响最大的细节开始:使用memmove()
将一个 HTTP 分块的结尾连接到下一个分块的开头。上面代码片段中的while
循环每次迭代都旨在处理请求体中的一个 HTTP 分块;假设存在多个分块(对于有效请求来说,这是始终成立的),代码需要将当前正在处理的分块的开头连接到已经处理过的分块的尾部,并删除它们之间的分块大小元数据。应用程序在同一个缓冲区内执行这个操作,而不是创建新的分配来存储最终的数据块;它根据请求中找到的分块大小,选择当前正在处理的分块相关的字节范围,然后将这些字节“左移”x字节,x 是分块大小字段的总长度(即strlen(chunk_size_line)
)。
实际影响
这一操作引入了以下条件和约束:
-
由于我们只能控制读写的大小,而无法控制读写的位置,我们只能相对于请求缓冲区中分块的位置,向内存更高的位置进行读写。
-
数据左移的字节数是由传递给
memmove()
的目标(endbuf
)和源(endptr
)参数之间的距离决定的(在上面的漏洞代码片段中分别为endbuf
和endptr
)。
可视化操作
这个漏洞的具体细节和它对利用的影响并不是非常直观(至少对我来说是这样),所以尝试可视化它可能会很有帮助。在开发漏洞利用时,我用 Google Sheets 创建了下面的图形,帮助我理解细节,希望它能在这里有帮助。
下面的“前”和“后”两行代表一个连续的内存块,包含 HTTP 请求的内容,在进行memmove()
操作之前和之后的状态,使用请求数据开始部分的分块大小(23)进行操作。我们可以想象这行字节是一个固定轨道上的带子;通过从左侧的read_src
开始“拉动”带子,我们可以将字节向左移动(我们固定在write_dest
)。向右的字节可以不限量地被左移,但我们只能按(read_src - write_dest)
的大小来进行移动。网格槽(即地址)是固定的,因此,如果我们希望某些有效载荷最终出现在特定的目标地址,我们需要能够将该有效载荷的字节至少左移(target_addr - payload_addr)
。
细分:
-
红色边框的单元格显示的是会被分块大小为 23 选择的字节(如行首所见)。
-
绿色高亮的区域是
memmove()
将字节写入的位置(作为第一个参数传递的endbuf
指针)。 -
紫色高亮的区域是
memmove()
将开始读取的地方(作为第二个参数传递的endptr
指针)。
推断
从上面的示例中,我们可以看出,仅仅改变分块大小几乎不会对数据相对于我们目标的位置产生影响——较大的分块大小将会覆盖更远的内存区域,但它们会导致这些字节被移动相同的距离。这意味着,对于位于地址x
的目标和位于x+20
的有效载荷数据,无论选择字节范围为x+20
还是x+100
,在调用memmove()
后,最终会将相同的字节写入x
位置。
解释:
-
影响的大小:分块的大小(如 23 字节)决定了数据将从哪个位置开始处理,但它并不改变移动的相对距离。例如,在处理不同大小的块时,虽然处理的内存区域变大,但所有字节会被按相同的相对位置进行移动。
-
固定的内存偏移:因此,不管分块的大小如何,数据依然会在内存中相对于
write_dest
被偏移相同的距离。所以,如果我们已经确定了某个目标地址(如x
),有效载荷的字节将被移动到目标地址,前提是它们的相对偏移量不会变化。
换句话说,虽然可以调整分块的大小以覆盖更大范围的内存,但由于移动的距离是固定的(由read_src - write_dest
决定),它对数据写入目标地址的影响几乎是没有变化的。
控制移动距离
如前所述,选择的字节范围被移动的距离是由两个指针之间的字节数决定的:一个是将字节写入的位置(endbuf
),另一个是从中读取数据的指针(endptr
)。根据解析逻辑,这个距离最终是由请求体中块大小的第一个字节与实际块数据的第一个字节之间的字节数来决定的。在代码中,这是通过将指针&endptr
作为第二个参数传递给strtol()
,在解析请求中的块大小值时,strtol()
会将遇到的第一个不可解析的值的位置存储到这个指针中。
在正常的请求中,这个不可解析的值通常是紧随块大小后的\r
字符。代码检查从这个指针开始是否存在\r\n
字符序列,以确认该序列确实存在。如果找到,代码会将指针向前偏移 2 位,跳过这两个字符。然后该指针就应指向实际块数据的起始位置。
相关代码如下:
while ((h->req_chunklen = strtol(chunk, &endptr, 16)) > 0 && (endptr != chunk) )
{
endptr = strstr(endptr, "\r\n");
if (!endptr)
{ ... }
endptr += 2;
memmove(endbuf, endptr, h->req_chunklen);
...
}
这意味着,为了控制数据的移动距离,我们需要在两个指针之间引入额外的字节,同时不会让strtol()
提前停止解析。查看strtol()
的手册页时,以下内容引起了我的注意:
字符串可以以任意数量的空白字符开始(由
isspace(3)
决定)。[...] 字符串的其余部分将以显而易见的方式转换为长整数值。
通过在块大小值前面添加空格,我们可以引入几乎任意数量的字节,从而影响memmove()
被调用时endbuf
和endptr
之间的距离。另一种方法是通过在块大小前面添加零(0
)来达到相同的效果。
示例
以下示例展示了一个没有添加前导空格的请求。在第一次处理时:
-
endbuf
位于索引/地址 489 -
endptr
位于索引/地址 493 -
块大小是 23,因此将移动 23 个字节
-
(489 - 493 = -4),所以每个需要移动的字节将向下移动 -4 个字节
-
我们希望从索引 501 开始覆盖 4 个字节(高亮的红色单元格)
-
我们希望用位于索引 512 的有效载荷数据(高亮的黄色单元格)来覆盖这些字节
-
覆盖目标与数据源之间的距离是 -11 字节
-
由于移动的偏移量问题,数据没有成功地移动到目标位置
在引入前导空格的情况下,endbuf
和endptr
之间的距离发生了变化。这种变化可以让我们调整数据在内存中的位置,以便实现更加精确的内存覆盖。具体来说:
-
endbuf
现在位于索引/地址 482。 -
endptr
仍然位于索引/地址 493。 -
计算出的偏移量是 (482 - 493 = -11),因此每个字节都会被向下移动 -11 个字节。
覆盖操作
-
我们希望从 索引 501开始覆盖 4 个字节。
-
用于覆盖的数据起始于 索引 512。
-
覆盖目标与数据源之间的距离是 -11 字节,这与之前的操作一样。
解析
由于endbuf
和endptr
之间的距离发生了变化,我们成功地控制了内存中数据移动的精确位置。通过将前导空格添加到块大小值,我们将数据覆盖的距离增加了11字节。这使得我们能够把数据从源位置(512)准确地覆盖到目标位置(501),达成了我们想要的内存修改效果。
基于此,我得出结论:必须在块大小之前插入足够的空格,以使endbuf - endptr == overwrite_target - payload_data
。
基于堆的内存破坏
破坏发生在堆分配的数据上,因此有可能破坏相邻堆块的元数据。根据目前描述的条件,实际上无法避免至少破坏紧挨着我们的那个块。这是因为,对于一个包含空块字段的最小请求(例如本文开头提供的示例请求),memmove()
操作将在靠近分配缓冲区末尾的数据上执行,几乎立即溢出到下一个块。虽然这增加了额外的攻击面和利用选项,但也带来了一些限制,即需要绕过 Glibc 的安全性和完整性检查,以避免在利用过程完成之前触发abort()
或其他崩溃。
堆风水
注意:以下提到的“块”指的是堆块,而不是 HTTP 块。
考虑到堆分配的缓冲区,我们将专注于直接利用堆(即针对堆块元数据)。基于堆的利用通常需要(或直接要求)对堆的布局进行某种程度的控制,以便使目标对象和有效载荷数据能够在可预测的位置分配。根据上面描述的条件,这在此案例中对于成功的利用是绝对必要的。具体来说,这需要满足以下要求:
-
覆盖目标的目标地址必须位于内存中高于触发破坏的请求缓冲区的位置
-
用于覆盖目标地址的有效载荷数据必须位于内存中高于目标地址的位置
根据这些要求,理想的内存布局看起来会像这样:
0x2000
---
.....................
...request_buffer.... <-- 用于触发破坏的缓冲区
.....................
.....................
...overwrite_target.. <-- 我们想要用受控数据覆盖的对象/地址
.....................
.....................
....payload_data..... <-- 我们希望写入的受控数据
.....................
---
0x2600
对于这个理论布局,我们将提供一个足够大的块大小,以便穿越overwrite_target
,并达到有效载荷数据的最后一个字节。
在实践中,要实现上述布局,必须做到以下几点:
-
将受控数据分配到堆上
-
防止分配过早被
free()
释放
4A评测 - 免责申明
本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。
不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。
本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。
如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!
程序来源网络,不确保不包含木马病毒等危险内容,请在确保安全的情况下或使用虚拟机使用。
侵权违规投诉邮箱:4ablog168#gmail.com(#换成@)