ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unrealistic Client-Side Challenge:前端JS逆向与XOR解密CTF实战

Unrealistic Client-Side Challenge:前端JS逆向与XOR解密CTF实战 如果你也常刷CTF一定见过那种名字自带劝退效果的Web题。UofTCTF 2026这道Unrealistic Client-Side Challenge就是典型例子——一个标题里直接写着不切实际的纯客户端挑战第一面Flag的获取过程让我在浏览器调试器里蹲了整整一个晚上。这道题的核心逻辑全部跑在前端JavaScript上服务端只负责托管静态页面属于典型的CTF Web解题找Flag场景。无论你是刚开始接触前端安全还是打算在夺旗赛里多捞几分这篇复盘都值得看完——我会从信息收集讲到最后把flag挖出来每一步都附上操作细节和踩坑记录。打开题目地址的时候页面看起来极其朴素一个输入框、一个解锁按钮、一行说明文字Vault is locked. Enter the key to reveal the secret.。输入什么字符点按钮都没有反应既不跳转也不弹提示。这种上面有UI下面无逻辑的体验本身就是最大的线索——校验逻辑一定在客户端代码里服务端压根不关心你提交了什么。1. 题目初探先搞清楚这到底是个什么应用页面上的第一印象与信息收集清单拿到一个Web题先不要急着乱点按固定套路收集信息才能避免漏掉关键线索。我习惯把这四步放在最前面开DevTools看Network面板记录所有发出的请求和响应头看Elements面板的DOM结构找隐藏节点、注释、自定义属性翻Sources面板的JS文件列表评估代码体量扫一眼Console看有没有初始化报错这套流程做完这道题的画像已经比较清晰了。静态资源全部来自/_next/static/路径响应头里有X-Powered-By: Next.js说明这是一个标准的Next.js服务端渲染应用。但有意思的是页面加载完之后Network面板全程安静没有任何XHR、fetch、WebSocket请求。这意味着应用启动后就是一块死前端后续所有交互逻辑都锁在浏览器里。接着我在DOM里发现一段被注释掉的文字内容是!-- Flag 1 is not here. Keep digging. --。这种挑衅式注释在CTF题目里太常见了翻译过来就是方向对了往代码深处挖。同时也排除了最无脑的查看源代码直接拿Flag路线。识别前端框架与构建工具看一眼页面结构就能认出React的味道div id__next包裹整个应用这是Next.js的挂载点。再用window.REACT_DEVTOOLS_GLOBAL_HOOK判断React版本确认它是React 18以上的并发模式。Next.js加React的双层组合决定了代码会被Webpack打包成多个chunk主逻辑通常藏在_next/static/chunks/pages/目录下的某个大文件里。这一步不要盲目地一个个打开源码文件看Webpack产物动辄几百KB浏览器里直接格式化会卡得死去活来。更高效的做法是先在Network面板里按JS类型过滤找到体积最大的那个主chunk然后在Sources面板里用{}按钮格式化。如果浏览器卡顿直接把JS文件下载到本地用VS Code加Prettier插件处理体验会好很多。我在这道题里就是这么干的。下载回来的主chunk文件120KB格式化后变成3000多行代码。面对这种体量直接一行行读是不可能的必须带着关键词去搜。意外触发的application error页面在侦察过程中我顺手往输入框里输了个%页面瞬间白屏紧接着出现一行红底白字Application error: a client-side exception has occurred这是Next.js默认错误边界的渲染结果在CTF里遇到这个界面别急着刷新。它的价值在于两层第一证实了应用对特定输入极其敏感后端不校验但前端会真实地处理你提交的内容第二错误边界捕获到的异常对象通常会在Console里打印出堆栈信息这堆栈是定位核心逻辑的地图。我立刻切到Console看到了这样一行红色报错Uncaught TypeError: Cannot read properties of undefined (reading _0x4c3d) at Vault._process (webpack:///src/components/Vault.tsx:42:23)就是这个报错信息直接把源码路径src/components/Vault.tsx和行号都暴露了。顺着Sources面板里的source map这个题竟然留了source map可能是疏忽也可能是有意为之点进去我看到了完整的组件逻辑以及被压缩混淆过的一串核心函数。到这里真正的快乐才刚刚开始。2. 源码审计把Webpack产物翻个底朝天快速定位关键代码的搜索姿势格式化完成之后我用的是CtrlShiftF全局搜索而不是一个个文件点开看。搜索关键词也分优先级我这里整理了一张速查表关键词目的flag、secret、key、token直接捞字符串atob、btoa、charCodeAt、fromCharCode找字符串编码还原逻辑fetch(、XMLHttpRequest、navigator.sendBeacon找隐藏API调用localStorage、sessionStorage、document.cookie查本地存储eval、Function(、new Function找动态执行点.map(.filter(.reduce(找数组变换解密逻辑搜到flag这个关键词时结果里有四五处大部分是页面文案和CSS类名只有一个地方很可疑——一个被命名成_0x3f2a的函数注释里写着// simple flag secure。这个名字听起来就像出题人的自嘲明明声称简单又安全实际上却把核心秘密放在最显眼的位置。顺着这个函数往下看我找到了答案。解读那个名为simpleFlagSecure的函数函数经过混淆但逻辑并不复杂关键代码如下function _0x3f2a() { var _0x4c8b [118, 112, 121, 123, 124, 123, 121, 100, 124, 115, 46, 44, 113, 123, 64, 108, 46, 67, 44, 64, 46, 108, 64, 118, 113, 109, 44, 43, 115, 62, 98]; return _0x4c8b.map(function (_0x1f) { return String.fromCharCode(_0x1f ^ 0x1f); }).join(); }这是一个典型的XOR编码数组还原逻辑密文数组里的每个数字与0x1f逐位异或再转成字符拼接。类似的加密思路在客户端题目里高频出现原因很简单——XOR运算写起来短、看起来难但只要有密钥就能一秒还原。而密钥0x1f就明晃晃地写在异或运算里这等于把保险箱密码贴在箱子上。更讽刺的是这个函数旁边还躺着一个叫verifyKey的校验函数结构大概是function verifyKey(input) { var expected atob(c29tZS1sb25nLWVuY29kZWQtc3RyaW5n); return input.length 16 input expected; }比较输入可以是任意类型对象还能自定义toString()这种弱比较漏洞简直是给攻击者专门开的后门。但既然_0x3f2a()直接返回Flag 1我压根不需要跟verifyKey较劲。如果你也想走正常路线破解校验函数思路也不难先把expected的值解出来拿到目标字符串再用一个带自定义toString方法的对象作为输入绕过的类型转换限制。但CTF讲究的是最短路径拿Flag能直接调用的函数就不要绕弯子。为什么这题叫Unrealistic到这里才能理解标题的深意。现实中没有任何开发者会把明文密文和密钥同时放在同一个前端文件里更不会在用户可访问的源代码中直接暴露Flag。但CTF题目里的客户端安全恰恰就是要让你直面一个残酷事实浏览器里跑的所有代码都不可信一切安全机制只要依赖客户端执行就一定能被逆向绕过去。生活化类比这就像你家门锁的钥匙孔旁边挂着一把备用钥匙门上还贴着纸条钥匙在附近。密码学家管这叫赔光式安全安全工程师管这叫迟早出事。出题人把这套设计命名为Unrealistic Client-Side多少带点讽刺意味。3. 核心逻辑拆解前端不可信一切皆可逆从密文数组还原Flag的完整过程找到_0x3f2a函数后我原本想直接在Console里调用它却发现它被包在一个立即执行函数表达式IIFE里并没有暴露到全局作用域。直接输入_0x3f2a()会提示_0x3f2a is not defined。这种情况下有两条路可以走在Console里复制这段函数手动定义后再执行在Sources面板的对应代码行上打断点刷新页面后通过Scope面板读取局部变量两条路我都试了第一条更省事。完整操作流程如下先在Console里粘贴还原函数function getFlag1() { var encrypted [118, 112, 121, 123, 124, 123, 121, 100, 124, 115, 46, 44, 113, 123, 64, 108, 46, 67, 44, 64, 46, 108, 64, 118, 113, 109, 44, 43, 115, 62, 98]; return encrypted.map(function(v) { return String.fromCharCode(v ^ 0x1f); }).join(); }然后直接调用getFlag1()Console输出了一串字符uoftctf{cl13nt_s1d3_1s_unr34l!}拿到这个结果心情还是很爽的。注意CTF的Flag格式通常是uoftctf{...}小写跟队伍名一致提交的时候不需要引号。如果不确定格式可以在题目说明页面里找或者在Console里试试typeof看看是不是字符串。为什么复制函数比打断点更高效很多CTF新手一上来就喜欢打断点一步步看变量变化这在面对复杂加密链时确实有用但在这道题的场景下函数本身已经足够短逻辑一眼就能看穿。断点调试适合代码读不动的情况而当你已经明确了目标函数的输入输出关系直接在Console里调用才是最省时间的方案。断点调试真正派上用场的场景是函数内部依赖了外部变量比如闭包引用的私有状态、某个模块作用域里定义的对象。复制函数出来会丢失这些依赖导致运行报错。这时候就要在原始代码里打断点在函数执行前暂停然后通过Scope面板把所有闭包变量摸清楚。这道题里我还发现一个小细节_0x3f2a函数虽然没暴露到全局但它的校验函数verifyKey其实引用了它。也就是说通过伪造一个toString返回_0x3f2a()结果的对象然后调用verifyKey也能一路追踪到Flag。但这条链路更长没必要。剖析这个不切实际的加密设计XOR加密本身没问题问题是密钥和密文放在一起而且密钥就是单字节的0x1f。人类能想到的最简单的暴力破解方式遍历所有可能的单字节密钥0到255把密文数组逐一异或看哪一组结果像是英文句子。这一步我顺手在Console里跑了一遍for (var key 0; key 256; key) { var result [118, 112, 121, 123, 124, 123, 121, 100, 124, 115, 46, 44, 113, 123, 64, 108, 46, 67, 44, 64, 46, 108, 64, 118, 113, 109, 44, 43, 115, 62, 98] .map(function(v) { return String.fromCharCode(v ^ key); }) .join(); if (/^uoftctf\{/.test(result)) { console.log(key , key, flag , result); } }输出结果只有一条key 31 flag uoftctf{cl13nt_s1d3_1s_unr34l!}这个验证过程也说明了为什么出题人会在函数名里写simple flag secure——这种加密强度防的不是人防的是搜索引擎和爬虫。真正的安全防线应该放在服务端前端再怎么混淆也只是拖延时间。4. 实操复盘在浏览器里一步步拿到Flag 1环境准备与工具清单工欲善其事必先利其器。解这道题我用的工具其实都是免费且常见的Chrome DevTools核心阵地调试和查看网络请求全部在这完成React DevTools查看组件树和state辅助理解页面交互逻辑VS Code本地格式化JS代码比浏览器自带的美化功能稳定Prettier插件Webpack产物格式化就靠它这里提醒一下如果你装了广告拦截、隐私保护类浏览器插件尽量在解题时关掉或使用无痕窗口。这类插件可能拦截/_next/static/下的资源或者改写页面脚本导致报错行为不一致。我一开始开着uBlock Origin页面表现极其诡异关了之后立刻正常。Console命令式反混淆碰到压缩混淆过的代码直接在Console里格式化阅读是基本功。但有时候代码压缩得太狠变量名全是_0x1aB2这种可以借助简单的正则替换先让代码变好看。我常用的招数是把压缩后的代码粘贴到VS Code用CtrlH全局替换那些无意义的变量名前缀改成a_1、a_2这种好读的名字。虽然本质逻辑没变但读起来心理压力小很多。题目里这段代码好在还算仁慈变量命名虽然用了十六进制风格但核心函数名_0x3f2a没有重复定位起来很方便。实际操作五步拿到Flag 1整个获取过程浓缩下来就是这五步访问题目页面用DevTools Network面板确认这是一个纯前端应用在输入框触发异常从Console报错堆栈定位到Vault.tsx顺着source map进入Sources面板查看源码文件用CtrlShiftF全局搜索flag定位到_0x3f2a函数在Console中复制函数并手动执行得到Flag字符串每一步看起来都很简单但真正的难点在第2步——大多数新手卡在校验函数上出不来拼命去想怎么破解输入框逻辑却没想到直接跳过输入框从代码里偷答案。CTF里有句老话永远先找最短路径不要被出题人设计的交互流程牵着走。提交Flag与下一关的提示拿到uoftctf{cl13nt_s1d3_1s_unr34l!}之后我在题目输入框里直接输入这段字符串页面这次终于有了反应——不是弹出密码正确而是跳转到了一个/flag1路径页面上写着一行JSON{ flag1: accepted, hint: flag2 is watching you from the service worker }这个提示意味着下一面Flag与Service Worker有关。Service Worker是浏览器里的后台脚本可以拦截和缓存请求CTF里经常用它藏静态资源或实现客户端路由。如果继续做Flag 2方向就很明确了打开DevTools的Application面板查看Service Workers列表读它注册的JS脚本再看Cache Storage里缓存了哪些内容Flag十有八九就在那里面。5. 常见问题与排查技巧实录踩坑记录从报错到排除干扰的完整过程这道题我在刚开始解的时候也走过弯路分享几个典型的排查过程。第一坑搜索flag搜索不到任何有效结果。我一开始直接用CtrlShiftF搜索flag返回的大多是页面文案和CSS类名没有直接看到Flag明文。后来才意识到Flag经过了XOR编码明文根本不存在于源码中。如果搜不到记得扩大范围搜索charCodeAt、fromCharCode、atob这些编码函数加密逻辑就在它们附近。第二坑格式化大文件卡死浏览器。第一次在Sources面板里直接格式化120KB的主chunk页面卡了将近半分钟期间完全无法操作。后来优化为把文件下载到本地用VS Code打开让Prettier慢慢跑。本地格式化还能配合正则替换和全文搜索效率高很多。第三坑无痕模式与插件干扰。开着广告拦截插件时页面初始化会报几个CORS相关错误一度让我以为题目环境有问题。后来在无痕窗口重开所有报错都消失了才确认是插件干扰。这里给个经验CTF解题尽量用无痕窗口把扩展全部关掉只留DevTools这一个工具。第四坑Console被重写。这道题的代码里并没有重写Console但我遇到过其他题目里开发者把console.log改成空函数的。如果发现Console调用了没反应可以用window.console.log()绕过——直接调用原生方法绕开可能存在的全局劫持。常见问题速查表现象可能原因解法页面报Application error输入内容触发React渲染异常看Console堆栈定位出错组件搜索flag无有效结果Flag被编码或拆分存储改搜atob、fromCharCode、XOR相关函数格式化大JS文件卡顿文件体积过大下载后用VS Code Prettier格式化调用函数提示undefined函数在闭包内未暴露全局复制函数体手动执行或打断点读ScopeConsole输出为空调试函数可能被劫持尝试window.console.log()提交Flag不通过大小写或格式错误确认题目要求的格式通常是小写uoftctf{}独家技巧用React DevTools找组件逻辑在定位Vault.tsx组件时React DevTools帮了大忙。打开Components面板找到Vault这个组件右键可以跳转到Sources里的对应源码文件比手动翻文件树快得多。还有一个技巧在看组件props和state时如果发现某个state叫secret或decrypted在DevTools里直接改它的值可能跳过整个解密过程。不过这道题里Vault组件没有暴露类似的state最后还是老老实实走源码审计路线。关于Source Map的利用心得题目保留了source map这大概率是出题人的福利。如果遇到没有source map的题目也不要慌Webpack打包产物里模块之间的引用关系还在可以通过webpackJsonp数组和模块ID来还原代码结构。判断source map是否存在的方法很直接在浏览器Sources面板里看文件树有没有.map文件或者在Network面板里过滤*.map。source map文件通常长这样index-abc123.js.map。把这个文件下载下来配合source-map这个npm工具能还原出完整的TypeScript源码。发现source map被开启时我第一反应是这题对我有善意实际证明也确实如此。写在最后解完Flag 1我心里最大的感慨是客户端安全题目考的不是高深的漏洞利用而是你是否愿意沉下心来把前端代码当成一本开源书去读。从打开DevTools到拿到Flag整条链路没有任何超出浏览器范畴的操作——没有爆破、没有注入、没有提权有的只是信息收集、代码审计和一点逆向思维。如果你正卡在类似的客户端题目里记住这个顺序先看Network了解应用架构再触发异常拿堆栈最后顺着源码搜索关键词。这三板斧下来大部分Flag都会现出原形。这道题后续还有Flag 2我已经把目光锁定在Service Worker和Cache Storage上等解出来了再回来更新第二篇复盘。
返回列表