考点:Node.js Buffer 未初始化内存泄露漏洞、代码执行长度限制、内存读取
打开题目,看到是一堆代码,而这些代码里不仅有python,还有C++,Javascript,而这些元素组合起来就是完整的 Node.js 源码。
Node.js 源码是什么?
Node.js 源码是开源跨平台 JavaScript 运行时环境的实现代码,它不是单一语言编写的,而是由C++(约 60%)、JavaScript(约 30%)和少量 Python(构建工具)混合组成,核心目标是让 JavaScript 能在服务器端运行。
这些代码不用完全看懂,直接看向带flag的那行代码:
eval("var flag_" + randomstring.generate(64) + " = \"hitcon{" + flag + "}\";")对其进行部分拆解:
最核心:randomstring.generate(64)
- 作用:生成一个 64 位的完全随机字符串
- 例子:每次运行都会生成不一样的,比如第一次是
abc123def456...,第二次是xyz789uvw012... - 目的:让变量名永远无法被猜到
2. 字符串拼接:"var flag_" + 随机字符串+ " = \"hitcon{" + flag + "}\";"
用+号把几个字符串拼在一起,最终会变成一行完整的 JavaScript 代码。
举个具体的例子:
- 假设
randomstring.generate(64)生成了abc123 - 假设真正的 flag 是
hitcon{123456} - 拼接后的结果就是:
"var flag_abc123 = \"hitcon{123456}\";"
3. 最关键:eval(...)
- 作用:把括号里的字符串,当成真正的 JavaScript 代码来执行
- 上面例子中,eval 执行后,就相当于在程序里写了这么一行代码:
var flag_abc123 = "hitcon{123456}";
先搞懂 3 个最基础的概念
1. 计算机内存到底是什么?
内存是计算机的 "临时工作台",所有正在运行的程序和数据都存在这里。
- 内存本质上是一个巨大的字节数组,每个字节有一个唯一的编号,叫做 "内存地址"
- 每个字节可以存储一个 0-255 之间的数字
- 所有的文字、图片、代码、flag,最终都会被转换成数字存在内存里
类比:内存就像一个有 10 亿个格子的储物柜,每个格子有一个编号,每个格子能放一张写着数字的小纸条。
2. 什么是 "内存分配"?
程序运行时需要存东西,就必须向操作系统 "申请" 一块内存,这个过程就叫内存分配。
- 操作系统有一个 "内存管理员",负责记录哪些格子已经被占用,哪些是空的
- 程序说:"我要 800 个格子存东西"
- 内存管理员找到连续 800 个空格子,把第一个格子的编号告诉程序
- 程序就可以往这 800 个格子里写数据了
3. 两个最关键的内存分配函数:mallocvscalloc
这是整个漏洞的核心中的核心。
| 函数 | 作用 | 行为 | 类比 |
|---|---|---|---|
malloc(size) | 分配size个字节的内存 | 只分配格子,不擦除格子里原来的纸条 | 租一个储物柜,管理员只给你钥匙,不打扫里面的东西,上一个租客留下的纸条还在 |
calloc(count, size) | 分配count * size个字节的内存 | 分配格子,并且把所有格子里的纸条都擦成 0 | 租一个储物柜,管理员会把里面打扫得干干净净,所有格子都是空的 |
malloc分配的内存是 "脏的",里面有之前的数据;calloc分配的内存是 "干净的",全是 0。
在 Node.js 8.0 之前,当你写
Buffer(800)时,底层会执行这样的 C++ 代码:
// 只做一件事:调用malloc分配800个字节的脏内存 char* data = malloc(800); // 没有任何清零操作 return Buffer::New(data, 800);- 它用的是 **
malloc**,所以分配到的内存里全是之前残留的数据 - 这就是为什么叫
AllocUnsafe(不安全分配),这就是漏洞的产生
总结:当服务器收到你的请求,执行Buffer(800)后,底层调用malloc(800)向内存管理员申请 800 个连续的格子,内存管理员在空闲格子里找,正好找到了刚才存放 flag 的那 800 个格子,把这 800 个格子的起始地址返回给 Buffer。
漏洞原理:
- 当你在 JS 中执行
Buffer(800)时,会调用上面的Buffer::New函数 AllocUnsafe使用malloc分配 800 字节的内存- malloc 只分配内存,不初始化内容,内存中保留着之前的数据
- 之前
eval("var flag_xxxx = 'hitcon{...}'")执行时,flag 被写入了内存 - 当新分配的 Buffer 正好覆盖了之前存放 flag 的内存区域时,就能读取到 flag
而代码里明确写了参数名是data:
if (req.query.data && req.query.data.length <= 12) {因此我们可以得出这样的paylaod。
?data=Buffer(800)把它拼接到靶机地址后面:
每运行一次就会下载一个文件,等下载到第三四次的时候就会看到flag(其实我之前一直在尝试用自动化脚本找到内存里的flag,但是一直出问题,所以才下载了这么多文件......最后只能改手动了)。