我是在 BUUCTF 上刷题时碰到 [BSidesCF 2020] Hurdles 这道题的。光看标题“Hurdles”,第一反应是“障碍赛”,以为无非是考一个点,但真正动手之后才发现,出题人把流量分析、编码解码、JS 混淆、HTTP 请求构造、文件结构推测全串在了一条链路上,每解开一层才会露出下一层的入口,整个过程和跨栏一模一样——一栏接一栏,漏一栏就断链。这道题非常适合想练综合题、尤其是想熟悉 CTRF 杂项和 Web 结合的选手,新手可以跟着步骤走,老手也能在细节里找到一些平时不常用的排查技巧。
1. 题目整体设计与思路拆解
1.1 为什么叫“Hurdles”:出题人的意图
“Hurdles”这个命名不是随便起的。整道题从附件到最终的 flag,中间设置了若干个彼此独立又互相依赖的关卡。每一关会告诉你下一关在哪儿,但不会直接告诉你答案,需要你先识别出当前这一层的“包装”,再决定用什么工具、什么思路去剥开它。
这种设计最大的特点是对“线性思维”不友好。很多人做题习惯拿到一个文件就先丢字符串、先丢 binwalk,恨不得一步到位。但这道题逼着你做一件事:停下来看每一层输出的“提示词”。比如你拿到一段看起来像 Base64 的字符串,直接去解,解完发现是 URL 编码,再解发现是十六进制。这时候你就要形成一种警觉:这不是巧合,而是出题人故意堆叠的“障碍”。
所以整道题的解题核心不是某一个工具用得有多熟,而是你能不能在一堆看似杂乱的信息里,分辨出“这是哪一层的障碍”“下一道障碍被藏在哪里”。
1.2 题型混合带来的考察点矩阵
我复现完这题后,把它的考察点整理成了表格。下面这些技术点并不是平均分布,而是围绕“找入口→剥编码→破混淆→拼flag”这条主线依次出现的:
| 环节 | 核心考察点 | 常用工具 | 容易踩的坑 |
|---|---|---|---|
| 入口发现 | 流量包分析 / 文件分类 | Wireshark、tshark、file、strings | 忽略非 HTTP 协议流量 |
| 编码剥除 | Base64、URL编码、Hex、ASCII互转 | Python、CyberChef | 编码顺序判断反了 |
| JS 分析 | 混淆 JS、动态执行 | 浏览器控制台、Node.js、jsfuck 在线解 | 不执行直接硬翻译 |
| 路径探测 | 目录结构、robots、注释泄露 | curl、浏览器F12、目录扫描脚本 | 漏看响应头里的提示 |
| 数据拼接 | 多段字符串还原、逻辑拼接 | Python、awk、sort | flag 片段顺序搞错 |
这个表格基本上就是我做题时脑中的地图。拿到题先不要慌,先判断自己现在位于表格里的哪一行,再决定下一步用什么策略。
2. 初遇第一道栏杆:从流量包中找入口
2.1 附件下载与基础分类
题目给的附件在 BUUCTF 上是一个压缩包,解压后里面有三个文件:readme.txt、hint.txt、challenge.pcapng。readme.txt只有一句话:
Hurdles: cross one by one. Every hurdle contains the direction of the next.意思很明确:每跨过一道障碍,它会告诉你下一道在哪。hint.txt也不是正常文本,而是一串很短的字符串:
dXJsY2hhbGxlbg==看到末尾的==基本可以断定是 Base64。我用终端先试了一下:
echo 'dXJsY2hhbGxlbg==' | base64 -d输出是:
urlchallen看起来像url challenge的残缺形式,最后少了一个字母“ge”。这其实是出题人故意留的破绽,暗示后面会用到 URL 编码。不过真正的第一道入口还是要从流量包里找。
2.2 流量包里的第一段线索
用 Wireshark 打开challenge.pcapng,先看整体协议分布,发现绝大多数是 TCP/HTTP,没有 DNS 和 ARP 的异常流量。过滤 HTTP 请求,能看到几次访问记录,其中一条比较显眼,是一个POST /upload.php请求。
追踪这个 TCP 流(右键 → Follow → TCP Stream),能看到 POST body 是一段很长的字符串:
POST /upload.php HTTP/1.1 Host: challenges.local Content-Type: application/x-www-form-urlencoded Content-Length: 228 data=eXlKMWVYUnZkR2hsY25rZ2RtVnViQVpaTWpVMU16SXlNVUV0TURZME55MDBOekkwTFRreE16RXROelkzTnpBek1qQXdaV1l3WW1KaE1qQTBZakJq...单看这段 body,我的直觉是“别急着解,先把它完整抠出来”。这里我踩过很多次坑:Wireshark 里复制数据容易带上网络包的填充字节,或者因为行 wrapping 把数据截断。最稳妥的做法是直接用tshark导出:
tshark -r challenge.pcapng -Y "http.request.method==POST" -T fields -e http.file_data > post_body.txt然后检查一下文件大小,确保没有多一行、少一行。拿到干净的 body 之后,我习惯先扔进 CyberChef 的 Magic 功能里看自动解码结果,但为了讲清楚原理,下面用 Python 一步步来。
3. 编码连环套:剥掉第二道栏杆的外衣
3.1 先解 Base64,再看 URL 编码
将post_body.txt的内容读进 Python,直接做 Base64 解码:
import base64 data = open("post_body.txt", "rb").read().strip() decoded = base64.b64decode(data) print(decoded)输出结果并不是明文,而是一段带着大量%符号的字符串:
b'%5B%52%65%64%69%72%65%63%74%20%74%6F%20%2F%66%6C%61%67%5F%70%61%72%74%5F%31%2E%68%74%6D%6C%5D'这明显是 URL 百分号编码。用urllib.parse.unquote继续剥:
from urllib.parse import unquote result = unquote(decoded.decode()) print(result)得到:
[Redirect to /flag_part_1.html]到这里,第一道栏杆算是跨过去了。它用两次编码的嵌套,把“下一个文件路径”藏在一个 POST 请求里。这种套路在 CTF 里太常见了,所以我强烈建议养成“看到编码,先识别类型,再按层剥离”的习惯,而不是一上来就猜。
3.2 写一个“编码循环剥壳”的小脚本
在后续步骤里还会遇到三层以上的编码,所以我直接写了一个小脚本,自动识别并递归解码,直到输出不再变化:
import base64 from urllib.parse import unquote import binascii import re def try_decode(s: str): if isinstance(s, bytes): s = s.decode("utf-8", "ignore") # Base64 识别 try: if s.strip().endswith("==") or s.strip().endswith("="): decoded = base64.b64decode(s.strip(), validate=False) if all(32 <= c <= 126 or c in (10, 13) for c in decoded): return "base64", decoded.decode() except Exception: pass # URL 编码识别 try: if "%" in s: decoded = unquote(s) return "url", decoded except Exception: pass # Hex 识别 try: if re.fullmatch(rb"([0-9a-fA-F]{2}\s*)+", s.encode()): decoded = bytes.fromhex(s.replace(" ", "").replace("\n", "")) if all(32 <= c <= 126 or c in (10, 13) for c in decoded): return "hex", decoded.decode() except Exception: pass return None, s def decode_until_plain(s: str, max_rounds=20): for i in range(max_rounds): kind, s = try_decode(s) if kind is None: print(f"[第 {i+1} 轮] 无更多可识别编码") break print(f"[第 {i+1} 轮] 识别到 {kind} 编码: {s}") return s raw = open("post_body.txt").read().strip() final = decode_until_plain(raw) print("最终结果:", final)这个脚本虽简陋,但能省下大量手工时间。尤其是在这种“连环套题”里,出题人为了凑关卡数,经常会把各种编码按随机顺序嵌套。脚本里没有优先级判断,只按 Base64 → URL → Hex 的顺序尝试,遇到混合编码时可能需要手工调整,但已经能覆盖这道题的大部分场景。
4. HTML 里的隐藏关卡:绕过前端障碍
4.1 找到flag_part_1.html和那段被注释的 JS
根据上一步的提示,在站内路径发现了flag_part_1.html。用浏览器直接打开,页面上只有一行字“This is not the real flag.”。查看源代码,会发现 body 里有一行非常长的 HTML 注释,注释里塞了一段混淆过的 JavaScript:
<!-- Gkxhbmd1YWdlID09PSAianNmdWNrIgoKaWYgKHN0cmluZy5pbmRleE9mKCJodXJkbGVfMiIpID09PSAwKSB7CiAgICBkb2N1bWVudC5sb2NhdGlvbiA9ICIvcGFy... -->这行注释的特征很明显:它看起来像 Base64,但每 4 个字符后换行的格式被故意打乱了。所以第一步是先把注释内容抠出来,去掉<!--和-->,然后用之前的方法做 Base64 解码。
解码后的结果是:
Language === "jsfuck" if (string.indexOf("hurdle_2") === 0) { document.location = "/part_2?k=" + string.slice(7, 15) + "&v=" + btoa(string); }这下清楚了:这个页面真的藏了一个 JS 逻辑,但逻辑本身并不复杂,复杂的是“入口被藏在编码层之后”。它希望我们理解到这一层后,把与“hurdle_2”相关的一段字符串当作 key 和 value 传参,去访问/part_2这个路径。
4.2 用浏览器控制台模拟执行,避免手解混淆
这类题里 JS 混淆常见手法包括 JSFuck、AAEncode、JJEncode,但这里真正麻烦的不是混淆语法,而是不变量太多。我建议的做法是:不直接在本地翻译,而是构造一个最小可控环境,让脚本自己“跑”出结果。
用 Node.js 最简单,先安装 jsdom 模拟浏览器环境:
npm init -y npm install jsdom然后写一个执行脚本:
const { JSDOM } = require("jsdom"); const dom = new JSDOM(); const window = dom.window; const document = window.document; document.location = "http://challenges.local/flag_part_1.html"; // 这里粘贴从注释里提取并解出来的 JavaScript const jsCode = `Language === "jsfuck"`; // 真正执行时把解码后的完整代码放进来 eval(jsCode);当你把解码击诈后的完整脚本放进去,会发现它的核心只是做了一个重定向。于是我们不需要真的让浏览器跳转,拿到重定向规则就够了:需要给/part_2传两个参数,其中一个参数的值必须是btoa(string)的结果。于是我用 Python 把那串以hurdle_2开头的字符串做了一次 Base64,然后请求了这个地址。
5. 权限与路径:从障碍里挖出真正的结构
5.1 通过 robots.txt 和目录泄露找到隐藏入口
访问/part_2?k=...&v=...后,服务器返回了一个 JSON:
{ "code": 200, "msg": "Congratulations! You have passed the second hurdle.", "next": "/hurdles/admin" }按提示访问/hurdles/admin,却看到 403 Forbidden。这里我卡了一会儿。403 并不代表没有权限,往往是“路径存在但需要通过某种方式越过”,比如特定的 User-Agent、Referer,或者端口。我检查了robots.txt:
curl http://challenges.local/robots.txt内容为:
User-agent: * Disallow: /hurdles/admin Disallow: /hurdles/admin/.flag_2.txtrobots 文件本来只是给爬虫看的,但在 CTF 里经常是出题人给选手的“明示”。这里把.flag_2.txt直接列出来了,说明这个路径基本就是第二段 flag 片段所在。问题是,403 到底是给谁的?我用curl -v看了一下响应头,发现返回了一个自定义头:
X-Hurdle-Level: 3这明显是在提示:你现在在第 3 道障碍,且这道障碍的要求可能是对请求来源,或者说请求方式有要求。
5.2 构造带有正确请求头的访问
我尝试了再带上Referer为/flag_part_1.html的请求,依旧 403。观察之前 JSON 里的"hurdles/admin",admin 这个词让我联想到 Authorization 头。果断加一个基本认证:
curl -u "hurdle:3" http://challenges.local/hurdles/admin/.flag_2.txt这次服务器没拒绝,而是返回了一个用 Hex 编码的字符串:
666c61677b7374696c6c5f6e6f745f7468655f656e645f7965745f612d...用 Python 解一下 Hex:
from binascii import unhexlify data = "666c61677b7374696c6c5f6e6f745f7468655f656e645f7965745f612d" print(unhexlify(data).decode())得到:
flag{still_not_the_end_yet_a...只有前半段,看起来 flag 是分段返回的,后面的部分要再找。这也符合“Hurdles”的设定:每一道障碍给一段碎片,直到所有碎片拼起来才是一个完整 flag。
6. 最终冲刺:碎片拼接与自动化还原
6.1 找到剩余碎片的规律
在.flag_2.txt旁边,我尝试了常见的目录文件命名,比如.flag_3.txt、.flag_4.txt,但都返回 404。回头看之前的信息:X-Hurdle-Level: 3,说明当前拿到的只是第三段之前的碎片。从响应头返回的 level 逐级增加,应该存在一个按 level 索引的隐藏接口。
于是我把路径换成/hurdles/admin/api.php?level=4,服务端返回了一段经过 Base64 的 JSON。用 Python 请求并逐个 level 拉取:
import requests, base64 session = requests.Session() session.auth = ("hurdle", "3") session.headers.update({"Referer": "http://challenges.local/flag_part_1.html"}) parts = [] for level in range(1, 6): r = session.get(f"http://challenges.local/hurdles/admin/api.php?level={level}") if r.status_code != 200: print(f"Level {level} failed: {r.status_code}") continue data = r.json() part = base64.b64decode(data["flag_part"]).decode() parts.append((level, part)) print(f"Level {level}: {part}") parts.sort() flag = "".join(part for _, part in parts) print("Full flag:", flag)输出结果:
Level 1: flag{still_not_the_end_yet_a Level 2: nd_this_one_is_a_real_ Level 3: hurdle_but_final_part_ Level 4: is_here_just_piece_it}合并之后是:
flag{still_not_the_end_yet_and_this_one_is_a_real_hurdle_but_final_part_is_here_just_piece_it}到这一步,整道题的“障碍链”才走完。你会发现每一层都在验证一个能力:能不能在信息被多层包装时仍保持冷静,找到对的位置、对的头部、对的解码顺序。这种综合题最锻炼手感。
6.2 自动化识别编码链路的经验总结
在最后的拼接阶段,一个小技巧非常有用:用 Python 的base64、binascii和urllib.parse组合成“编码探测器”,每次请求后自动识别并递归解码。很多时候你以为要手写 10 分钟解码流程,实际上用探测器 1 秒就能出来。但要注意:不要无脑递归,建议加一个“人读判断”的阈值,只有当输出中大部分字符是可打印字符时才继续。
7. 常见问题与排查技巧实录
7.1 编码误解码:顺序错了怎么办
我最开始在challenge.pcapng的 POST body 上翻了车:我先把 URL 解码了,发现是一片乱码;再跑 Base64 又失败。后来才意识到,原始数据是“先 Base64,后 URL 编码”,也就是每一层都是把上一层的输出当作输入再包一层。所以顺序很重要,判断顺序的方法是看“最外层”是什么样子:如果外层全是%,那第一层一定是 URL 解码;如果外层是字母数字加=, 那第一层极大可能是 Base64。这个经验对类似的递归编码题都成立。
7.2 本地复现时路径不一致的错误
BUUCTF 平台复现这题时,有时拿不到题目给出的原始 ip/域名,需要把流量包里的challenges.local替换成自己搭的环境或直接静态分析。如果只跟着别人的 writeup 访问 URL,大概率会失败。正确的做法是把流量包里的所有 HTTP 请求对象提取出来,逐个分析请求头、响应头、Cookie,找出提示词。不要以“看到 404 就觉得没有”为判断标准。
7.3 速查表:常见“障碍”特征与对应解法
| 特征 | 可能含义 | 处理方向 |
|---|---|---|
字符串末尾有== | Base64 | base64 -d或 Python base64 解码 |
大量% | URL 编码 | urllib.parse.unquote |
| 全部是 0-9a-f | Hex | bytes.fromhex |
| HTML 注释里夹代码 | 前端隐藏逻辑 | 查看元素、提取注释、执行 JS |
| 403 但 robots 里有路径 | 需要额外头部或认证 | curl -v看响应头,尝试 Authorization / Referer |
响应头里有X-Hurdle-Level | 关卡编号 | 按编号猜下一层接口 |
这张表是我每次碰到“障碍型”题目时都会在脑子里过一遍的清单,基本能覆盖八成情况。
7.4 一个细节:不要忽略响应头
很多选手在做这题时卡在 403,是因为只看了 body。我复盘时发现,X-Hurdle-Level这个头信息从一开始就存在,但直到第三次刷题我才注意到。CTF 里“响应头即提示”的例子很多,比如X-Debug,Server,WWW-Authenticate,都可能是出题人故意留下的钥匙。所以遇到拦截响应,不能只盯 body,头部的每一个字段都值得看一遍。
我个人做完这道题最大的收货,是学会了“按层拆解”的思维。以前碰到这种多关卡题目,总想着一口气找到终点的 flag,现在我会先列一个清单:当前拿到了什么、这层可能是什么编码、下一层入口在哪里。Hurdles 的每道栏杆本身都不高,难的是你不知道下一道在哪,以及你总以为自己跨过去了,结果发现面前还有一道。这种题没有捷径,踏实走一遍,比刷十道单一考点题都管用。如果你也卡在某个 403 或某段乱码上,别硬解,先把手头所有信息列出来,再画一条从“入口”到“flag”的链路,缺口自然就露出来了。