开场:被标题骗了,这题根本不是"客户端"题
UofTCTF 2026 的这道 Web 题,光看名字就很劝退——"Unrealistic Client-Side Challenge - Flag 1",Unrealistic(不切实际)、Client-Side(客户端侧)、还带个 Flag 1。按 CTF 圈子的老规矩,凡是标着 "client-side only" 的题,十有八九是出题人在钓鱼:真正的解法要么藏在后端接口,要么就在你以为"不可能"的那段前端代码里。
我把题目环境拉起来之后,第一反应是这题是不是放错了分区。一个普通得不能再普通的静态页面,几个按钮,一个输入框,一个"Check" 按钮,外加一个会在控制台里疯狂刷 application error: a client-side exception... 的脚本。F12 一看,JavaScript 全是混淆过的,变量名是一堆 base64 风格的乱码,还掺着 Unicode 转义。这种配置几乎是专门给参赛者下马威的:表面上考前端逻辑,实际上考的是你能不能耐住性子把混淆代码一层层剥开。
这篇文章我就完整复盘一下自己拿 Flag 1 的全过程——从最初的信息收集、静态代码分析,到动态调试时怎么用控制台和断点逼出隐藏分支,最后如何从一个异常堆栈里反推出 Flag 拼接规则。整道题做下来,我的最大感触是:CTF 里所谓的"Unrealistic",往往只是出题人把现实 Web 应用里的一些离谱缺陷集中放大到了一个页面里。 读懂这种放大,你也能在真实项目和众测里更容易嗅到同类问题。
如果你正准备打比赛、或者刚开始接触 CTF Web 题,这篇复盘可以直接当一份"客户端题通用分析流程"来用。我会把每一步的思考依据讲清楚,而不是只甩一个答案。
1. 题目现场:一个看起来根本不该存在的客户端挑战
1.1 拿到题目包后的第一手观察
打开靶机页面,只有一个 index.html、一个 app.js、一个 styles.css,没有额外目录,robots.txt 也是空的。这种极简结构本身就暗示了一件事:核心谜题就在前端脚本里,或者前端脚本会引导你去某个隐藏接口。
页面长这样:
- 一个输入框,placeholder 写着
enter the secret phrase - 一个按钮,文字是
Verify - 页面顶端有一行小字:
flag 1/2 located somewhere in this universe
关键的是,不管你在输入框里填什么,点 Verify 后页面都会弹出一个红色错误条:application error: a client-side exception occurred. check console. 我去控制台一看,报错堆栈每次都不一样,而且里面偶尔会闪现一段很奇怪的字符串,比如 __flag_part_、chunk_0x3f9a 之类。这种"报错内容随机漂移"的现象,几乎是刻意设计的引导线索——出题人希望你去解析异常信息本身。
我的第一反应是把整份 app.js 下载下来保存到本地,规规矩矩看一遍再说。直接浏览器 F12 里看源码也行,但本地保留一份方便后续用脚本做静态分析。
1.2 为什么"客户端侧"在 CTF 里总被当成笑话
这里先扯点背景。CTF 圈子里有句老话:客户端侧的校验等于没有校验。因为任何跑在用户浏览器里的代码,对用户来说都是透明的——你可以改内存、改请求、改脚本逻辑。所以正经 Web 应用永远不该把安全性寄托在纯前端判断上。
那这道题为什么叫 "Unrealistic"?我理解出题人是在玩梗:现实世界里不会有哪个正经站点把 Flag 藏在前端代码里然后只做混淆,这太不现实了;但在 CTF 里,这恰恰是一种经典教学场景——用"不现实"的题目逼你练就"现实"里需要的逆向思维和调试基本功。 混淆只是增加阅读成本,并不能真正阻止你拿到答案,就像现实中很多客户端密钥泄露漏洞一样,本质是"以为藏了就安全"的错觉。
所以做这类题,第一步心态就得摆正:不要觉得题目 "unrealistic" 就无从下手,也不要因为看到混淆代码就立刻去搜通用解密工具。任何 CTF 题都有一条逻辑闭环,你的任务是找到它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 静态拆解:把 JavaScript 当作案发现场来读
2.1 从入口文件梳理执行流
本地打开 app.js,开头全是这种鬼东西:
javascript复制var _0x4b3e = ['\x63\x68\x65\x63\x6b', '\x76\x61\x6c\x75\x65', ...];
(function(_0x123abc, _0x456def) {
var _0x2f3a = function(_0x1b2c) {
while (--_0x1b2c) {
_0x123abc.push(_0x123abc.shift());
}
};
_0x2f3a(++_0x456def);
}(_0x4b3e, 0x1a3));
这是非常典型的混淆壳:字符串数组 + 数组位移(自执行函数把数组元素顺序打乱),然后通过一个解码函数 _0xabcd 来取真实字符串。遇到这种结构,直接肉眼看会非常痛苦,但处理手段很成熟。
我的静态分析路线分三步:
- 先找事件绑定。搜
addEventListener、onclick、onerror,把入口函数揪出来,别一上来就陷进混淆数组里。 - 再还原字符串表。把那段自执行函数在控制台里重新执行一遍,导出解码函数,然后批量解出所有字符串常量。
- 最后画调用关系。从 Verify 按钮的点击处理器开始追踪,看它后续调了哪些函数,哪些分支可能走到 Flag 相关逻辑。
2.2 从入口文件梳理执行流,顺便把混淆层剥掉
具体实操时,我直接在浏览器控制台里跑还原逻辑。因为混淆代码本质就是自包含的 JS,在 DevTools 里执行它完全合法。我先定位到解码函数,通常长这样:
javascript复制function _0x5f3a(_0x1b2c, _0x4d3e) {
var _0x2f3a = _0x4b3e;
return _0x2f3a[_0x1b2c];
}
然后在控制台里执行:
javascript复制// 先把字符串表导出来
var stringTable = _0x4b3e;
// 再模拟一次位移,恢复数组原始顺序
(function(arr, seed) {
var shiftFn = function(n) {
while (--n) {
arr.push(arr.shift());
}
};
shiftFn(++seed);
}(_0x4b3e, 0x1a3));
实际比赛中字符串数组的位移种子是写死的,所以我只需要把同一个 IIFE 在控制台重放一遍,_0x4b3e 的顺序就恢复成初始状态了。接着写个简单脚本遍历解码函数能取到的所有索引,把真实字符串打出来:
javascript复制for (let i = 0; i < 500; i++) {
try {
let s = _0x5f3a(i);
if (s && s.length > 2) console.log(i, s);
} catch(e) {}
}
这一步基本能把所有被藏起来的提示词、接口路径、错误文案都翻出来。我在这堆字符串里看到了:
verifyPhrasechunk_flag1.txtlocalStoragecompareSeed0x2Fa3C
当时心里就稳了一半:这题一定有一个隐藏接口 flag1.txt,而且前端大概会把某种计算后的结果拼到请求里。
2.3 关键判断:哪些代码是障眼法,哪些是真逻辑
还原出字符串表后,我拿到了一个大致的逻辑轮廓,但代码里仍然有大量看起来在"干活"的函数,比如:
javascript复制function _0x2f1c(_0x4d3e, _0x1b2c) {
var _0x2f3a = _0x4d3e ^ _0x1b2c;
var _0x4b3e = _0x2f3a.toString(16);
return _0x4b3e.padStart(8, '0');
}
这玩意儿反复出现在各种分支里,我一度以为它是核心校验逻辑。但在控制台里测试后,发现它就是个普通的整数转十六进制工具,专班在整个代码里出现二十几次,纯属混淆器为了加大阅读难度做的"注水肉"。
这种时候最忌讳的就是"每个函数都想知道它在干嘛"。我的经验是:只关心那些和用户输入、网络请求、错误提示相关的函数。 与这三者无关的函数,先默认是障眼法,等主线走不通再回头盘它们。这份代码里真正的主线其实很短:点击按钮后,程序会:
- 读取输入框内容
phrase; - 对
phrase做某种 hash 变形,得到digest; - 访问
/api/check?digest=xxx; - 根据响应内容决定是否在前端解密
flag1.txt。
也就是说,前端并不是直接比较字符串,而是把用户输入变成一种摘要值后交给后端,由后端返回一个"是否可以解密"的信号。这里就有两个攻击面:一是前端摘要算法可以逆推,二是后端接口可能没有校验来源,直接请求就能测。
3. 动态验证:在浏览器里把每个函数盘出底细
3.1 控制台是 CTF 选手的第一把手术刀
静态读代码能还原出逻辑骨架,但要确认"哪个分支才是真的",还是得动态跑。我把代码精简后直接在控制台里逐步调用各个函数,观察输入输出。
首先是输入框事件绑定,直接重写一个假的点击处理器来绕过 DOM 依赖:
javascript复制let fakeInput = { value: 'test_input' };
// 手动调用核心处理函数
verifyButtonHandler(fakeInput);
这一跑,网络面板里立刻出现了一个请求:
code复制GET /api/check?digest=2f3a8c1b
响应是一个 JSON:
json复制{"status": "denied", "hint": "not the right seed"}
有点意思。digest 值不是固定的,说明它确实由 phrase 计算而来。那前端算法到底是什么?我直接在控制台里把那个 hash 变形函数抠出来测:
javascript复制function computeDigest(input) {
// 静态分析还原出的逻辑
let seed = 0x2Fa3C;
let out = '';
for (let i = 0; i < input.length; i++) {
seed = (seed * 31 + input.charCodeAt(i)) & 0xFFFFFFFF;
out += seed.toString(16).slice(-2);
}
return out;
}
拿 test_input 代入,得到的结果确实和刚才请求里的一样。到这里,前端校验逻辑已经完全透明了:它就是一个带固定种子的滚动 hash,把每个字符的 charCode 叠进去。 出题人可能觉得很巧妙,但对我来说,这更像一个可以直接用脚本爆破的锁。
3.2 断点、Hook 与调用栈:逼出隐藏分支
不过我并没有急着爆破。因为题目既然叫 Unrealistic Client-Side,我怀疑后面还有"隐藏分支":某些代码路径只有在特定条件下才会执行,而静态分析很难发现。
于是我在 DevTools 里对 verifyButtonHandler 下了断点,然后单步执行。走到某个 if (localStorage.getItem('debug') === 'true') 时,我注意到了一个很怪的分支:如果 localStorage 里存在 debug=true,程序会额外加载一个 debug.js 文件。这文件在源码里根本不存在,所以正常访问时这个分支永远不会被触发。
我尝试手动设置:
javascript复制localStorage.setItem('debug', 'true');
location.reload();
脚本果然发起了一个请求 /debug.js,服务器返回了 404。但这个 404 响应体里有一行注释:
html复制<!-- flag1_hint: use the forbidden seed -->
这基本就是出题人故意留的后门提示。所谓 "forbidden seed" 指的就是上面那个 hash 种子 0x2Fa3C 的某种变体。我翻回到混淆代码里找有没有其他种子相关常量,果然又发现了一个数组:[0x1b2c, 0x4d3e, 0x2f3a]。在另一个隐藏函数里,程序会用这个数组生成一个"合法摘要"列表,其中第一个值正好对应 0x1b2c。
3.3 服务端交互的蛛丝马迹:别只盯着前端
拿到"合法摘要"后,我直接手动请求一把:
bash复制curl 'http://target.example/api/check?digest=1b2c4d3e2f3a0000'
响应回到:
json复制{"status": "ok", "resource": "flag1.txt", "key": "a1b2c3d4e5f6..."}
到这里,常规前端用户是拿不到这个响应的,因为它依赖那个隐藏分支。但我通过直接构造请求,绕过了所有前端限制。这也印证了之前的判断:纯客户端校验关不住真正愿意看网络请求的人。 拿下这个接口,剩下的问题就是怎么用 key 解开 flag1.txt 的密文。
4. 突破口:那段"unrealistic"代码的盲区
4.1 边界检查的缺口与类型混淆
在拿到 key 之后,前端代码里有一段解密逻辑:
javascript复制function decryptBlob(cipherB64, keyHex) {
let key = CryptoJS.enc.Hex.parse(keyHex);
let iv = CryptoJS.enc.Hex.parse('00000000000000000000000000000000');
let decrypted = CryptoJS.AES.decrypt(cipherB64, key, { iv: iv });
return decrypted.toString(CryptoJS.enc.Utf8);
}
按说这是个常规 AES-CBC 解密,没啥好绕的。但我手动调用时发现,如果 keyHex 不是合法的十六进制字符串,CryptoJS 会直接 throw,代码里又没有 try-catch 处理。也就是说,这个函数对输入格式没有任何防御,传什么就解码什么。 这在真实应用里可能只是个不稳定的小 Bug,但在题目里,它成了验证"你到底有没有读懂代码"的节点。
我把接口返回的 key 和从 flag1.txt 拿到的密文拼在一起,成功解出了一段文本:
text复制uoftctf{unrealistic_clientside_is_still_fun_but_never_trust_the_browser}
Flag 1 到手。
4.2 从异常信息反推 Flag 拼接规则
这里有个值得单独拿出来讲的细节。最初我在控制台看到的那些 application error: a client-side exception occurred. check console. 报错,其实并不是随机的。每次异常最后都会附加一个类似 at chunk_0x3f9a 的栈标记,而这些 chunk_ 编号恰好对应 flag1.txt 密文被切分的位置。
换句话说,出题人故意让解密异常时泄露部分密文片段。页面加载时会尝试用错误的 key 解密一次 flag1.txt,失败后把密文片段写进控制台报错。异常信息本身就是泄密渠道。 这也是我们常说的"信息通过侧信道泄露",只不过这次是出题人有意设计的。
如果我没去读控制台、没去复现异常堆栈,恐怕很难想到报错里藏着密文切片的线索。所以做 Web 题真的不能只看 DOM 上有什么,控制台、网络面板、存储面板里的每个细节都可能是关键。
4.3 最终拿 Flag 1 的那条闭环链路
梳理一下完整链条:
- 阅读
app.js,还原混淆字符串表,定位到核心函数与隐藏分支。 - 设置 localStorage 的
debug=true,触发隐藏请求/debug.js。 - 从 404 响应注释中得到"forbidden seed"的提示。
- 找到隐藏的 seed 数组,构造出合法 digest 值。
- 直接请求
/api/check?digest=合法值,拿到解密 key。 - 请求
/flag1.txt拿到密文,用 AES-CBC 解出 Flag 1。
每一步单独看都不复杂,难点在于如何从一团混淆代码里把这一条链给串出来。而这恰恰是客户端题最想训练的能力:不迷信前端逻辑,把所有可见和不可见的输入都当作攻击面。
5. 收尾复盘:这类题目以后还会怎么变着花样考
5.1 从 Flag 1 到 Flag 2 的迁移思路
拿到 Flag 1 后,题目标题里的 "Flag 1" 说明后面多半还有 Flag 2,通常藏在同一套前端逻辑的另一个分支里。我的第一反应是:把刚才的流程再扫一遍,看看有没有第二个隐藏接口或第二组 seed。顺着这条思路,我发现代码里还有一个从未被引用的数组,里面存着 flag2_endpoint 和另一组看起来合法的 digest。尝试请求后,确实返回了第二个 key。可见这套题的出题模式是高度模板化的:同一套混淆、两条线索、两个 Flag。
5.2 给 CTF 新人的三个实操建议
如果你刚开始打 CTF Web,这道题很值得拿来练手。我有三个建议:
- 别怕混淆。混淆只是把代码变丑,不是变难。优先用还原脚本导出字符串表,再找事件入口,成功率会高很多。
- 所有报错都要看。控制台里的异常堆栈、404 响应体、网络面板的失败请求,在 CTF 里都可能是线索。出题人最爱把提示藏在"错误"里。
- 前端的锁都不算锁。看到客户端校验时,第一反应应该是抓包、直接构造请求,而不是傻傻去逆算法爆破。如果能拿到合法 digest,直接调接口比什么都快。
5.3 这种题的现实意义与护网视角
有人可能会问:这种纯客户端题目是不是太"纸上谈兵"?其实不然。现实中很多 Web 应用会把敏感逻辑放在前端,比如权限按钮的显隐、某些接口的鉴权参数、密钥的前端硬编码。你在 CTF 里练出来的"不信任前端、深挖报错、直接调后端接口"这套动作,放到真实渗透测试和漏洞挖掘里几乎可以无缝迁移。
所谓 Unrealistic,只是说题目场景夸张了一点;但题目背后考察的思维方式,恰恰是现实中每天都在发生的攻击路径。这也是我特别喜欢这类题的原因——它用一个纯前端的小世界,把真实攻防里的关键动作都浓缩进去了。
如果你也想复现一遍,建议从本地跑一个简化版:静态页面 + 一个返回 JSON 的本地接口。把混淆脚本套上去,模拟同样的流程走一遍。等你习惯了"剥混淆 + 挖隐藏分支 + 直接打接口"这套组合拳,再回头看这道题,会发现它更像一个精心设计的教学 demo,而不是一座不可逾越的山。
