Why Code Security Matters - Even in Hardened Environments
基础设施的强化使应用程序对攻击更加具有韧性。这些措施提高了攻击者的门槛,使得利用漏洞变得更加困难。然而,这些措施不应被视为万灵药,因为决心坚定的攻击者仍然可以利用源代码中的漏洞。
在这篇博客文章中,我们将通过展示一种攻击者可以利用的技术,强调基础代码安全的重要性,这种技术可以将 Node.js 应用程序中的文件写入漏洞转化为远程代码执行——即使目标的文件系统是以只读方式挂载的。这种技术通过利用暴露的管道文件描述符来获得代码执行,从而突破了在这种强化环境中施加的限制。
文件写入漏洞
在我们主要关注网络的漏洞研究中,我们遇到了各种不同类型的漏洞,例如跨站脚本攻击、SQL 注入、不安全的反序列化、服务器端请求伪造等等。这些漏洞类型的影响和利用难易程度各不相同,但对于其中一些漏洞,一旦识别出这种类型的漏洞,几乎可以肯定整个应用程序都已被攻陷。
其中一种关键的漏洞类型是任意文件写入漏洞。攻击者仍然需要弄清楚写入什么以及写在哪里,但通常有很多选项可以将其转化为代码执行,从而完全控制应用程序的服务器:
-
将 PHP、JSP、ASPX 或类似文件写入 Web 根目录。 -
覆盖由服务器端模板引擎处理的模板文件。 -
写入配置文件(例如,uWSG .ini 文件[1]或Jetty .xml 文件[2])。 -
添加 Python特定站点配置钩子[3]。 -
通过写入 SSH 密钥、添加定时任务或覆盖用户的.bashrc 文件来使用通用方法。
这些例子表明,攻击者通常会找到简单的方法将任意文件写入漏洞转化为代码执行。为了减少此类漏洞的影响,应用程序的基础设施通常会被强化——这使得攻击者利用它变得更加困难,但并非不可能。
强化环境中的文件写入
我们最近在一个 Node.js 应用程序中遇到了一个任意文件写入漏洞,结果发现其利用难度较低。该漏洞本身更为复杂,但可以归结为以下易受攻击的代码片段:
app.post('/upload', (req, res) => {
const { filename, content } = req.body;
fs.writeFile(filename, content, () => {
res.json({ message: 'File uploaded!' });
});
});
函数 fs.writeFile
用于写入文件,两个参数——filename
和 content
——都是完全由用户控制的。因此,这是一种任意文件写入漏洞。
在评估此漏洞的影响时,我们注意到运行应用程序的用户仅限于对特定上传文件夹的写入权限。文件系统上的其他所有内容都是只读的。 尽管这让我们觉得利用该漏洞似乎无路可退,但它引导我们提出了以下研究问题:
即使目标的文件系统是以只读方式挂载的,任意文件写入漏洞是否可能转化为代码执行?
只读文件写入
在像 Linux 这样的基于 Unix 的系统中,一切都是文件。与传统的文件系统(如 ext4,存储在物理硬盘驱动器上的数据)不同,还有其他文件系统服务于不同的目的。其中之一是 procfs 虚拟文件系统,[4] 通常挂载在 /proc
,并作为内核内部工作原理的窗口。procfs 不存储实际文件,而是提供对正在运行的进程、系统内存、硬件配置等实时信息的访问。
procfs 提供的一个特别有趣的信息是正在运行的进程的打开文件描述符,可以通过 /proc/<pid>/fd/
进行检查。进程打开的文件不仅可以是传统文件,还可以是设备文件、套接字和管道。例如,以下命令可以用来列出 Node.js 进程的打开文件描述符:
user@host:~$ ls -al /proc/`pidof node`/fd
total 0
dr-x------ 2 user user 22 Oct 8 13:37 .
dr-xr-xr-x 9 user user 0 Oct 8 13:37 ..
lrwx------ 1 user user 64 Oct 8 13:37 0 -> /dev/pts/1
lrwx------ 1 user user 64 Oct 8 13:37 1 -> /dev/pts/1
lrwx------ 1 user user 64 Oct 8 13:37 2 -> /dev/pts/1
lrwx------ 1 user user 64 Oct 8 13:37 3 -> 'anon_inode:[eventpoll]'
lr-x------ 1 user user 64 Oct 8 13:37 4 -> 'pipe:[9173261]'
l-wx------ 1 user user 64 Oct 8 13:37 5 -> 'pipe:[9173261]'
lr-x------ 1 user user 64 Oct 8 13:37 6 -> 'pipe:[9173262]'
l-wx------ 1 user user 64 Oct 8 13:37 7 -> 'pipe:[9173262]'
lrwx------ 1 user user 64 Oct 8 13:37 8 -> 'anon_inode:[eventfd]'
lrwx------ 1 user user 64 Oct 8 13:37 9 -> 'anon_inode:[eventpoll]'
...
从上面的输出可以看出,这也包括匿名管道(例如,pipe:[9173261]
)。与在文件系统中以命名文件形式暴露的命名管道不同,由于缺乏引用,写入匿名管道通常是不可能的。然而,procfs 文件系统允许我们通过其在 /proc/<pid>/fd/
中的条目来引用管道。与 procfs 下的其他文件相比,这种文件写入不需要 root 权限,可以由运行 Node.js 应用程序的低权限用户执行。
user@host:~$ echo hello > /proc/`pidof node`/fd/5
即使在只读挂载的 procfs 中(例如在 Docker 容器中),写入管道也是可能的,因为管道由一个称为pipefs
的独立文件系统处理,该文件系统由内核内部使用。
这为能够写入任意文件的攻击者揭示了新的攻击面,因为他们可以将数据提供给从匿名管道读取的事件处理程序。
Node.js 与管道
Node.js 建立在单线程的 V8 JavaScript 引擎之上。然而,Node.js 提供了一个异步和非阻塞的事件循环。为此,它使用了一个名为libuv[5]的库。该库使用匿名管道来信号和处理事件,这些事件通过 procfs 暴露,如我们在上面的输出中所见。
当 Node.js 应用程序容易受到文件写入漏洞时,没有任何东西可以阻止攻击者写入这些管道,因为它们可以被运行应用程序的同一用户写入。但是,写入管道的数据会发生什么呢?
在审计相关的 libuv 源代码时,一个名为uv__signal_event
的处理程序引起了我们的注意。它假设从管道读取的数据是类型为uv__signal_msg_t
的消息:
static void uv__signal_event(uv_loop_t* loop,
uv__io_t* w,
unsigned int events) {
uv__signal_msg_t* msg;
// [...]
do {
r = read(loop->signal_pipefd[0], buf + bytes, sizeof(buf) - bytes);
// [...]
for (i = 0; i < end; i += sizeof(uv__signal_msg_t)) {
msg = (uv__signal_msg_t*) (buf + i);
// [...]
uv__signal_msg_t
数据结构仅包含两个成员,一个是 handle
指针,另一个是名为 signum
的整数:
typedef struct {
uv_signal_t* handle;
int signum;
} uv__signal_msg_t;
handle
指针的 uv_signal_t
类型是 uv_signal_s
数据结构的类型定义,其中包含一个特别有趣的成员,名为 signal_cb
:
struct uv_signal_s {
UV_HANDLE_FIELDS
uv_signal_cb signal_cb;
int signum;
// [...]
这个 signal_cb
成员是一个函数指针,应该包含一个回调函数的地址,该函数将在事件处理程序中被调用,前提是两个数据结构的 signum
值匹配:
// [...]
handle = msg->handle;
if (msg->signum == handle->signum) {
assert(!(handle->flags & UV_HANDLE_CLOSING));
handle->signal_cb(handle, handle->signum);
}
以下图像可视化了事件处理程序所期望的数据结构:
这对攻击者来说是一个非常有利的情况:他们可以向管道写入任何数据,并且有一条快速路径可以调用函数指针。事实上,我们并不是唯一注意到这一点的研究人员。8 月 8 日,HackerOne 披露了这份精彩的报告[6],作者是Seunghyun Lee[7],他描述了一个不同的场景,在这个场景中,他能够利用 Node.js 程序中的开放文件描述符绕过任何基于模块和进程的权限——基本上是一种沙箱逃逸。
即使在他这里描述的场景中——这是我们没有想到的——这也不被视为安全漏洞,该报告被关闭为信息性。这意味着我们在接下来的部分中描述的技术仍然适用于最新版本的 Node.js,并且在不久的将来可能不会改变。
构建结构
攻击者利用文件写入漏洞来利用事件处理程序的一般策略可能如下所示:
-
向管道写入一个伪造的 uv_signal_s
数据结构。 -
将 signal_cb
函数指针设置为他们希望调用的任意地址。 -
向管道写入一个伪造的 uv__signal_msg_t
数据结构。 -
将 handle
指针设置为之前写入的uv_signal_s
数据结构。 -
将两个数据结构的 signum
值设置为相同的值。 -
获得任意代码执行权限。
假设攻击者只能写文件,所有这些都需要通过一次性写入来实现,而无法事先读取任何内存。
事件处理程序的缓冲区相当大,这使得攻击者可以轻松地将这两个数据结构写入管道。然而,有一个障碍:数据结构的地址是未知的,因为所有写入管道的数据都存储在栈上:
因此,攻击者无法使handle
指针引用伪造的uv_signal_s
数据结构。这引出了一个问题:攻击者是否能够引用任何数据?
栈、堆和所有库的地址都是通过 ASLR 随机化的。然而,Node.js 二进制文件本身的段并没有随机化。令我们惊讶的是,Node.js 的官方 Linux 构建没有启用 PIE(位置无关可执行文件):
user@host:~$ checksec /opt/node-v22.9.0-linux-x64/bin/node
[*] '/opt/node-v22.9.0-linux-x64/bin/node'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
这个原因显然是由于性能考虑,[8] 因为 PIE 的间接寻址增加了一些开销。对于攻击者来说,这意味着他们可以引用 Node.js 段中的数据,因为这个地址是已知的:
下一个问题是:攻击者如何能够在 Node.js 段中存储一个伪造的uv_signal_s
数据结构?寻找使 Node.js 在静态位置存储攻击者控制的数据(例如,从 HTTP 请求读取的数据)的方法是一种方法,但这似乎相当具有挑战性。
一种更简单的方法是直接使用现有的内容。通过检查 Node.js 内存段,攻击者可能能够在现有数据中识别出适合的uv_signal_s
伪结构的数据。
攻击者梦想中的数据结构看起来类似于这个:
这个数据结构以一个命令字符串("touch /tmp/pwned"
)开始,后面跟着system
的地址,位于正确的偏移量上,以与signal_cb
函数指针重叠。攻击者只需使signum
值与伪造的uv_signal_s
数据结构匹配,以便调用回调函数,这实际上调用了system("touch /tmp/pwned")
。
这种方法要求system
的地址存在于 Node.js 段中。全局偏移表(GOT)通常是一个候选项。然而,Node.js 并不使用system
函数,因此其地址不在 GOT 中。即使它存在,生成的伪造uv_signal_s
数据结构的开头也可能是 GOT 中的另一个条目,而不是一个有用的命令字符串。因此,另一种方法似乎更可行:经典的 ROP 链。
搜索数据结构小工具
每个 ROP 链的开始是搜索有用的 ROP 小工具。搜索 ROP 小工具的工具通常解析磁盘上的 ELF 文件,然后确定所有可执行部分。.text
部分通常是最大的可执行部分,因为它存储程序本身的指令:
现在,该工具遍历该部分中的字节,并寻找ret
指令,例如,因为这是 ROP 小工具的合适最后指令。然后,该工具从表示ret
指令的字节向后逐字节查找,以确定所有可能有用的 ROP 小工具:
然而,在这种情况下,这并不是攻击者所需要的。攻击者需要的是一个引用伪造uv_signal_s
数据结构的地址,该数据结构通过其signal_cb
函数指针引用一个 ROP 小工具。因此,有一个间接性:ROP 小工具(指令序列的地址)需要存储在引用的数据本身中:
为了识别像这样的合适数据结构,攻击者需要像经典 ROP 小工具查找工具一样搜索 Node.js 映像。不同之处在于,攻击者不仅对可执行部分(如.text
部分)感兴趣。伪造数据结构所在的内存不必是可执行的。攻击者需要一个指向小工具的指针。因此,他们可以考虑所有至少可读的段。此外,这种搜索可以在内存中进行,而不仅仅是解析磁盘上的 ELF 文件。这样,攻击者还可以找到在运行时仅创建的数据结构,例如在.bss
部分中。这可能导致误报或特定于环境的结构,但增加了他们获得有用发现的机会,这些发现可以手动验证。
这种内存中伪造数据结构搜索的基本实现实际上相当简单。
for addr, len in nodejs_segments:
for offset in range(len - 7):
ptr = read_mem(addr + offset, 8)
if is_mapped(ptr) and is_executable(ptr):
instr = read_mem(ptr, n)
if is_useful_gadet(instr):
print('gadget at %08x' % addr + offset)
print('-> ' + disassemble(instr))
该 Python 脚本遍历所有 Node.js 内存区域,并将每 8 个字节解释为一个指针,尝试引用该指针。如果地址被映射并引用可执行段中的内存,则确定存储在该地址的字节序列是否是一个有用的 ROP 小工具:
这是该 Python 脚本运行时的样子:
所有潜在有用的 ROP 小工具都会被输出,并可以作为回调函数被调用时执行的第一个初始 ROP 小工具。由于写入管道的所有数据都存储在栈上,因此找到一个合适的支点小工具对于这个第一个小工具来说是足够的。一旦攻击者将栈指针转移到受控数据,便可以使用经典的 ROP 链:
使用此技术利用任意文件漏洞时仍然存在一个警告。通常,用于写入文件的函数(在这种情况下为fs.writeFile
)限制为有效的 UTF-8 数据。因此,写入管道的所有数据必须是有效的 UTF-8。
克服 UTF-8 限制
由于 Node.js 二进制文件的巨大体积(最新的 x64 版本约为 110M),找到适合经典 ROP 链的有用 UTF-8 兼容小工具并不困难。然而,这一限制进一步限制了现有数据中伪造的uv_signal_s
的潜在适用数据结构。因此,需要在脚本中添加额外的检查,以验证伪造数据结构的基地址是否为有效的 UTF-8:
for addr, len in nodejs_segments:
for offset in range(len - 7):
if not is_valid_utf8(addr + offset - 0x60): continue
ptr = read_mem(addr + offset, 8)
# [...]
即使有了这个额外的检查,脚本仍然会产生合适的伪造数据结构,这些结构引用了如下的支点小工具:
...
0x4354ca1 -> 0x12d0000: pop rsi; pop r15; pop rbp; ret
...
这就是相关数据结构在内存中的样子:
这个伪造数据结构的基地址(0x4354c41
)是有效的 UTF-8,因此uv__signal_msg_t
数据结构中的handle
指针可以正确填充。然而,还有另一个与 UTF-8 相关的问题。这次是关于signum
值的:
signum
值的最后一个字节是0xf0
,这不是有效的 UTF-8。如果攻击者试图通过文件写入漏洞写入这个字节,它会被替换为一个替代字符,signum
值检查将失败。如果我们在我们的UTF-8 可视化工具[9]中输入0xf0
,我们可以看到这个字节引入了一个 4 字节的 UTF-8 序列:
因此,UTF-8 解析器期望在这个字节后面有 3 个续字节。由于uv__signal_msg_t
数据结构包含一个 8 字节的指针和一个 4 字节的整数,编译器添加了 4 个额外的填充字节以将结构对齐到 16 字节。这些字节可以用来添加 3 个续字节,从而构造一个有效的 UTF-8 序列:
例如,上面的软盘是一个有效的 4 字节 UTF-8 序列,以0xf0
开头。通过添加这些续字节,攻击者可以满足整个有效负载为有效 UTF-8 的要求,并使两个signum
值匹配:
在克服了最后一个障碍后,攻击者能够获得远程代码执行。
以下视频演示了针对易受攻击的示例应用程序的利用,该应用程序以低权限用户身份在具有只读根文件系统和只读 procfs的系统上运行:
学习与结论
在基于 Unix 的系统上,“一切皆文件”的哲学在利用文件写入漏洞时打开了不常见的攻击面。在这篇博客文章中,我们展示了一种可以将 Node.js 应用程序中的文件写入漏洞转化为远程代码执行的技术。由于事件处理程序代码来自libuv[10],因此该技术也可以应用于其他使用 libuv 的软件,如julia[11]。
这种通用方法甚至可以在没有 Node.js 和 libuv 的情况下适用。每当应用程序使用管道作为通信机制时,攻击者可能利用文件写入漏洞来针对通过 procfs 暴露的管道文件描述符。正如这个例子所示,这可能不会被视为常见的威胁模型,但可以使远程攻击者能够执行任意代码。
从防御的角度来看,这个例子强调了基础设施加固只能被视为额外的防御层,不能替代基本的代码安全。尽管采取了加固措施,决心坚定的攻击者仍然可以利用源代码中的漏洞。这极大地证明了代码安全的重要性,正如整洁代码[12]所暗示的那样,为什么漏洞应该在其源头被修复:源代码。
参考资料
uWSG .ini file: https://blog.doyensec.com/2023/02/28/new-vector-for-dirty-arbitrary-file-write-2-rce.html
[2]Jetty .xml file: https://x.com/ptswarm/status/1555184661751648256
[3]site-specific configuration hook: https://www.sonarsource.com/blog/pretalx-vulnerabilities-how-to-get-accepted-at-every-conference/#code-execution-via-sitespecific-configuration-hooks
[4]procfs virtual file system,: https://man7.org/linux/man-pages/man5/proc.5.html
[5]libuv: https://libuv.org/
[6]这份精彩的报告: https://hackerone.com/reports/2260337
[7]Seunghyun Lee: https://x.com/0x10n
[8]性能考虑,: https://github.com/nodejs/node/issues/33425
[9]UTF-8 visualizer: https://sonarsource.github.io/utf8-visualizer/#
[10]libuv: https://libuv.org/
[11]julia: https://julialang.org/
[12]Clean Code: https://www.sonarsource.com/
原文始发于微信公众号(securitainment):为什么代码安全在已加固环境中仍然重要
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论