MoeCTF 2026 Web 安全与渗透测试 Writeup(下)
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 | POST /fetch |
返回:
1 | Honey Vault |

它告诉我们 flag 藏在 /secret-honey 上。带路径再请求(注意 gopher 的 selector 要保留开头的斜杠,URL 里写成两个斜杠 //secret-honey):
1 | POST /fetch |
返回:
1 | moectf{9bbf74d3-9582-c6fd-efa8-1fe7dee46273} |

(本题需要先启动在线环境,再经平台 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 | GET /api/help 查看接口说明 |

GET /api/help 给出关键信息——/api/flag 只有在 isAdmin=true 且 canReadFlag=true 时才返回 FLAG:
1 | { |
先看 GET /api/me,返回当前配置和一个 inheritedProbe 字段:
1 | { |

注意 inheritedProbe 里 isAdmin、canReadFlag 都是 null,而 /api/flag 报错里反复出现「继承属性」四个字:
1 | { |
这就要理解 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 | POST /api/profile |
返回里 inheritedProbe 已经变成 true:
1 | { |
再访问 GET /api/flag,直接拿到 flag:
1 | { "ok": true, "flag": "moectf{0n3_pr070type_chan9e_@|l_06j3c7$_o8ey}" } |

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

关键源码(就是隐藏 div 打印出来的):
1 | $file = $_GET['file'] ?? 'chap1.html'; |

这就是典型的 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:

把返回的 base64 解压(PHP 的 zlib.deflate 是 raw deflate,Python 里要加 -zlib.MAX_WBITS):
1 | import base64, zlib |
还原出 flag.php 源码:
1 |
|
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 | id=1 and 1=1 → 返回记录(真) |

用 ORDER BY 探测列数——ORDER BY n 表示按结果第 n 列排序,n 超过实际列数时报错。order by 3 正常、order by 4 报错,说明共 3 列。
但接下来尝试 union select 全部失败(大小写、双写、/**/、/*!*/、%0a、union all 均被拦截)。这是典型的黑名单防护——把已知危险关键字(union 等)列入禁止名单、命中就拦截。它的问题是「永远补不全」,常见绕过有大小写、双写(ununionion 删中间后又拼回)、注释/空白变形、换等价写法等。本题这些绕 union 的招都不灵,但子查询 (select ...) 可以正常执行:
1 | id=1 and (select 1)=1 → 真 |
子查询是嵌套在 SQL 里的 (SELECT ...),先执行内层得到一个结果、外层再用它。既然 union 被禁、子查询能用,就把「想探测的内容」放进子查询、外面套一个恒真判断,把子查询结果「翻译」成真/假。这条路通向布尔盲注。
先理解什么是盲注:盲注指页面不会直接回显数据库内容的 SQL 注入。和 UNION 注入能把数据直接「显示出来」不同,盲注场景下只能得到「有差异的两种结果」——比如「有记录 vs 没有记录」——然后通过反复试探一点点把数据猜出来。本题判据:真 → 页面含「高等数学」,假 → 页面含「没有找到匹配记录」。
先确认数据库类型:
1 | id=1 and (select count(*) from information_schema.tables)>0 → 真 |
是 MySQL,于是可以继续用 information_schema(MySQL 内置元数据库,存着库/表/列结构信息)当导航地图。
盲注要读出一个字符串,需要逐字符、逐位地猜,常用二分法:把目标字符看成 ASCII 码(0~127 的整数),每次判断 ascii(substr(flag, 位置, 1)) > 中间值 的真假来折半缩小范围,把「猜一个字符」的代价从最多 127 次降到约 7 次(log2(127)≈7)。
核心探测函数:
1 | def blind(e): |
逐字符二分 ASCII 提取:
1 | def extract(sub): |
依次枚举库 → 表 → 列:
1 | 库名:database() = past_paper |
flag 就在 flag_table.flag 里,盲注提取 (select flag from flag_table limit 0,1):

Flag: moectf{C0ngr@+u1@7|0ns_8u+_+h3re_wou1d_not_6e_any_pas7_3x@m_pap3r_in_f4ct}
完整脚本 E:/claude/blind_jiang.py:
1 | import requests |
小结:数字型注入点,union 被黑名单拦截,改用 (select ...) 子查询做布尔盲注;通过 information_schema 依次枚举库名、表名、列名,最终在 flag_table 拿到 flag。盲注的本质是「构造一个返回真假的子查询」,二分 ASCII 逐字符还原。
5. 我爱发明(200 分)
MoeCTF 2026 · Web 安全与渗透测试 · 200 分
题目:我爱发明(「人工组与机械组的发明擂台,完成三轮擂台验收即可看到最终口令」)
打开是一个「我爱发明」发明擂台网站,左侧三个关卡导航,右侧「机械组倒计时」面板:
- 火眼金睛挑零件 —— 把三个零件编号抄进记录表
- 老师傅调样机 —— 把仪表读数抄进调试记录
- 千台样机大验收 —— 288 份档案,找「盖章通过的机械组验收片段」算最终口令

前两关答案直接写在页面上,纯属送分过场:
- 第一关零件编号:
B-17 S-09 T-04 - 第二关调试记录:
860/73/III/2.4
真正的考点在第三关:288 份档案、每页 12 份,机械组计时不会因翻页或刷新而重置——暗示这是一个有时间窗口的竞速题。
先过前两关:
1 | curl -X POST -H "Content-Type: application/json" \ |
访问第三关时,响应头里多了一个 cookie:
1 | GET /stage/3 |
这里要理解一下 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 | <div class="panel paper inspection-sheet" data-team="机械组" data-status="通过"> |

三个关键点:
data-team—— 所属组别,按id % 3固定循环:1→人工组、2→观众组、0→机械组,所以机械组是 id 为 3 的倍数的 96 份。data-status—— 验收状态,机械组里有通过(盖章通过)和返工两种。data-code—— 只有「机械组 + 通过」的档案才有 8 位验收片段码,其余都是未盖章。
实测「通过」的机械组档案恰好是 id 为 6 的倍数(共 48 份),它们的 data-code 才是我们要的片段。
规则是:把所有「盖章通过」的机械组片段按档案顺序用 | 连接,算 SHA-256 提交。SHA-256 是一种哈希函数,把任意长度输入映射成固定 256 位(64 个十六进制字符)的摘要,单向、抗碰撞,常用来做「口令校验」——服务端只存哈希、校验时把用户输入哈希后比对。这里「不直接提交原文、而是提交哈希」的设计,常见于防止没收集全片段就乱猜。
完整脚本 E:/claude/solve_1375.js(注意 finalize 也要带 race cookie):
1 | const crypto = require('crypto'); |
运行结果:
1 | stage1: {"ok": true, "message": "人工组完成装配,机械组仍在倒计时。"} |

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 | header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 |
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 | import jwt |
得到:
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 | smuggled = ( |
响应里走私出:
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 | req = ( |
响应里走私出:
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 里藏着的请求当成新的独立请求执行。





