介绍

一道很有意思的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.readFileSyncpath 参数读取文件

image-20220812182519153

可以看到确实可以通过这两种实例来作为参数读取文件,并且使用 URL 实例可以用url编码从而绕过 flag 字符的检查,那我们的重点现在就是如何让 fs.readFileSync 把我们传入的path当作一个URL实例来运行。那么其内部是如何实现的呢。

我们用vscode调试进fs内部看看

fs.readFileSync 内部是如何实现的

启用vscode内部调试功能

在 Debug选项中创建好 launch.json 配置文件

image-20220812183003197

注释掉 配置文件 中的 "<node_internals>/**" 以便可以进入node内部包中

image-20220812183656333

打好断点后点击运行和调试即可开始调试

调试代码

我们用这段代码调试

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 内部看看

image-20220812184144568

这里可以进一步调试分析可知各个函数的功能(其实看名字和注释也能看出来) 注意调试时应全程注意传入的path变量

getOptions 获取读取文件的参数,它这默认是 ‘r’

后面的 isFd 函数是判断 path 是否是个文件描述符(可以理解为C语言中的文件指针一类的东西)

后续458行判断若不是 文件描述符 则获取文件描述符

获取到文件描述符后即可读取对应的文件了

openSync内部

所以我们的重点成了这个 fs.openSync 函数了,继续单步调试进入fs.openSync 中去

image-20220812190404794

这里面对path进行了 getValidatedPath 处理,从字面意思来看是获取验证路径

这里我们可以先 单步跳过, 跳到后面看看我们传入的 URL实例 最后变成了什么

起初的 path 变量

image-20220812191244193

经过 getValidatedPath 函数处理后

image-20220812191414442

可以发现我们传入的 URL实例对象转化成了经过url解码后的字符串。所以 getValidatedPath 这个对URL实例进行了怎样的处理很关键。

getValidatedPath内部

继续进入到 getValidatedPath 内部,首先有个 toPathIfFileURL 函数,字面意思理解,如果是file类型的URL实例则转成 path

image-20220812193822093

同样和上面一样,看看经过 toPathIfFileURL 处理后 path 值是什么,点击单步跳过

image-20220812194207264

可以看到 传入的 URL实例 fileURLOrPath 成功转为了字符串,而这个 validatePath 是验证 path 有效性的,不用管它。

进入到 toPathIfFileURL 一探究竟

toPathIfFileURL内部

进入可以看到 toPathIfFileURL 函数内部首先是调用上面的 isURLInstance 来判断传入的参数是否是一个 URL实例,这个判断的方法也真是很无语,如果传入的参数 fileURLOrPath 不为 null ,且对象中存在属性 href构造点 1)和 origin构造点 2)则该对象被认为是一个URL实例

image-20220812194550602

当我们传入的对象被认为是一个URL实例时,就会执行1564行的 fileURLToPath 函数,继续进入

fileURLToPath内部

image-20220812195520646

上文分析了path是个URL实例才会进入该函数,所以我们直接看第1483行的代码

URL实例 path 对象中必须含有 protocol: 'file:'构造点 3),否则会抛出一个异常。不触发异常的话我们正常执行到最后会1485行,return一个值,题目环境是Linux环境,进入到 getPathFromURLPosix 看看

getPathFromURLPosix内部

image-20220812200540088

这里首先我们传入的URL实例必须含有 hostname: ''构造点 4),否则会抛出一个异常,后续的for循环用于检验传入的URL实例中的属性 pathname 中是否包含 url编码后的 / ,若包含则抛出一个异常。

在最后1475行会将传入的URL实例中 pathname 中的值进行url解码并返回(构造点 5

这样就得到了在上文 openSync 函数中的最终 path

payload

由上文分析可知我们可以传一个对象实例,这个 file 对象满足如下条件

  1. .href 存在
  2. .origin 存在
  3. .protocol === 'file:'
  4. .hostname === ''
  5. .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

image-20220812202809733