介绍
一道很有意思的node题,需要深入node的fs中去探索
题目很短, flag在/app/flag.txt里,给了源码和Dockerfile,可以在本地测试
const express = require("express");
const fs = require("fs");
const app = express();
const PORT = process.env.PORT || 3456;
app.use((req, res, next) => {
if([req.body, req.headers, req.query].some(
(item) => item && JSON.stringify(item).includes("flag")//ban 掉了 flag
)) {
return res.send("bad hacker!");
}
next();
});
app.get("/", (req, res) => {
try {
res.setHeader("Content-Type", "text/html");
res.send(fs.readFileSync(req.query.file || "index.html").toString());
}
catch(err) {
console.log(err);
}
});
app.listen(PORT, () => console.log(`web/simplewaf listening on ${PORT}`));
简单分析
用node写的一个简易web应用,发送get请求查询 ?file=/etc/passwd
可以访问任意文件,但 waf 禁掉了 flag
字符串,这似乎看起来是个相当容易绕过的 waf 。(这个waf将对象转换为json字符串来检查是否包含 flag
字符串)
但实际上它并不简单,node不像php有伪协议可以绕,也没办法用什么编码绕过等,因为url编码后的字符串传递给 fs.readFileSync
后其并不会对得到的字符串进行解码操作,它只是尝试检查文件系统上是否存在与该字符串完全匹配的文件,并且 Unicode 尝试不会起作用。
说到 js 首先想到的应该是 prototype pollution(原型链污染), 但是注意到我们就算传参污染也只能污染 req.query.file
的 __proto__
, 而且由于它没有与任何东西合并,你只能污染你自己对象的属性——你已经可以任意分配属性了,所以那也没用。
所以正确的方法是利用 Express 对查询参数的处理来构造一个精心设计的对象来绕过。这里要进入到nodejs的内部去才能一探究竟。
express 使用 qs
npm 模块来提供 req.query.file
(file
为查询字符串参数名) ,这意味着它可以与字符串以外的其他类型一起使用。
如:?file[]=1&file[]=2
或者 ?file=1&file=2
,这样最后 req.query.file
获取到的就是一个数组 ['1', '2']
; 还有 ?file[a]=b&file[c]=d
, req.query.file
获取到的是一个对象 {'a': 'b', 'c': 'd'}
那我们可以尝试构造这样的查询参数看看会发生什么,构造:
/?file[a]=b
可以看到后台报了这样的错误
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of Buffer or URL. Received an instance of Object
at Object.openSync (node:fs:582:10)
at Object.readFileSync (node:fs:458:35)
at /workspaces/dist/main.js:20:21
at Layer.handle [as handle_request] (/workspaces/dist/node_modules/express/lib/router/layer.js:95:5)
at next (/workspaces/dist/node_modules/express/lib/router/route.js:144:13)
at Route.dispatch (/workspaces/dist/node_modules/express/lib/router/route.js:114:3)
at Layer.handle [as handle_request] (/workspaces/dist/node_modules/express/lib/router/layer.js:95:5)
at /workspaces/dist/node_modules/express/lib/router/index.js:284:15
at Function.process_params (/workspaces/dist/node_modules/express/lib/router/index.js:346:12)
at next (/workspaces/dist/node_modules/express/lib/router/index.js:280:10) {
code: 'ERR_INVALID_ARG_TYPE'
}
分析报错原因可以发现 path
参数(传入的req.query.file
的值),必须是 字符串 或是 一个Buffer实例 或是 一个URL实例,而我们传入的是一个 Object(对象)。flag字符ban了,所以走字符串这条路是不行了。
本地测试下 通过Bufeer实例和URL实例作为 fs.readFileSync
的 path
参数读取文件
可以看到确实可以通过这两种实例来作为参数读取文件,并且使用 URL 实例可以用url编码从而绕过 flag
字符的检查,那我们的重点现在就是如何让 fs.readFileSync
把我们传入的path当作一个URL实例来运行。那么其内部是如何实现的呢。
我们用vscode调试进fs内部看看
fs.readFileSync 内部是如何实现的
启用vscode内部调试功能
在 Debug选项中创建好 launch.json
配置文件
注释掉 配置文件 中的 "<node_internals>/**"
以便可以进入node内部包中
打好断点后点击运行和调试即可开始调试
调试代码
我们用这段代码调试
const fs = require('fs');
let path = new URL('file:///app/fl%61g.txt');
console.log(fs.readFileSync(path).toString());//此处打断点
这里我们其实可以一路跟踪我们传入的参数 path
, 可以发现如下堆栈调用情况(linux环境下)
readFileSync -> openSync -> getValidatedPath (in `internal/fs/utils.js`)
-> toPathIfFileURL (in `internal/url.js`) -> fileURLToPath -> getPathFromURLPosix
这里我还是一步一步分析吧
readFileSync内部
先进去 readFileSync
内部看看
这里可以进一步调试分析可知各个函数的功能(其实看名字和注释也能看出来) 注意调试时应全程注意传入的path变量
getOptions
获取读取文件的参数,它这默认是 ‘r’
后面的 isFd
函数是判断 path
是否是个文件描述符(可以理解为C语言中的文件指针一类的东西)
后续458行判断若不是 文件描述符 则获取文件描述符
获取到文件描述符后即可读取对应的文件了
openSync内部
所以我们的重点成了这个 fs.openSync
函数了,继续单步调试进入fs.openSync
中去
这里面对path进行了 getValidatedPath
处理,从字面意思来看是获取验证路径
这里我们可以先 单步跳过, 跳到后面看看我们传入的 URL实例 最后变成了什么
起初的 path 变量
经过 getValidatedPath
函数处理后
可以发现我们传入的 URL实例对象转化成了经过url解码后的字符串。所以 getValidatedPath
这个对URL实例进行了怎样的处理很关键。
getValidatedPath内部
继续进入到 getValidatedPath
内部,首先有个 toPathIfFileURL
函数,字面意思理解,如果是file类型的URL实例则转成 path
同样和上面一样,看看经过 toPathIfFileURL
处理后 path
值是什么,点击单步跳过
可以看到 传入的 URL实例 fileURLOrPath
成功转为了字符串,而这个 validatePath
是验证 path 有效性的,不用管它。
进入到 toPathIfFileURL
一探究竟
toPathIfFileURL内部
进入可以看到 toPathIfFileURL
函数内部首先是调用上面的 isURLInstance
来判断传入的参数是否是一个 URL实例,这个判断的方法也真是很无语,如果传入的参数 fileURLOrPath
不为 null
,且对象中存在属性 href
(构造点 1)和 origin
(构造点 2)则该对象被认为是一个URL实例
当我们传入的对象被认为是一个URL实例时,就会执行1564行的 fileURLToPath
函数,继续进入
fileURLToPath内部
上文分析了path是个URL实例才会进入该函数,所以我们直接看第1483行的代码
URL实例 path 对象中必须含有 protocol: 'file:'
(构造点 3),否则会抛出一个异常。不触发异常的话我们正常执行到最后会1485行,return一个值,题目环境是Linux环境,进入到 getPathFromURLPosix
看看
getPathFromURLPosix内部
这里首先我们传入的URL实例必须含有 hostname: ''
(构造点 4),否则会抛出一个异常,后续的for循环用于检验传入的URL实例中的属性 pathname
中是否包含 url编码后的 /
,若包含则抛出一个异常。
在最后1475行会将传入的URL实例中 pathname
中的值进行url解码并返回(构造点 5)
这样就得到了在上文 openSync
函数中的最终 path
payload
由上文分析可知我们可以传一个对象实例,这个 file
对象满足如下条件
.href
存在.origin
存在.protocol === 'file:'
.hostname === ''
.pathname
是/app/flag.txt
URL 编码的(注意:这需要双 URL 编码,因为 Express 已经 URL 解码一次)
这样就能绕过 WAF 成功读取到flag了
最终得到
?file[href]=a&file[origin]=1&file[protocol]=file:&file[hostname]=&file[pathname]=/app/fl%2561g.txt