1. 这道题不是考运气是考你有没有真正“看懂”前端逻辑“GWCTF 2019 WEB-枯燥的抽奖”——光看标题很多人第一反应是又一个靠爆破、猜接口、扫目录的CTF Web题点开题目页面一个极简界面一个按钮写着“点击抽奖”下方一行小字“今日剩余次数3”再无其他。点击后弹出提示“恭喜未中奖”。刷新页面次数重置为3。整个过程安静得有点反常没有跳转、没有加载动画、没有网络请求痕迹F12 Network 面板一片空白。这时候如果还想着抓包重放、写脚本暴力请求就彻底掉进出题人埋的第一个坑里了。这道题的核心关键词根本不是“抽奖”或“CTF”而是静态资源可读性与前端逻辑自洽性。它不依赖后端API不涉及数据库查询甚至不需要服务器参与——所有判断逻辑、中奖规则、次数控制全部封装在前端代码里。它的“枯燥”恰恰是最大伪装用最朴素的交互掩盖最典型的前端安全盲区。我第一次做这道题时习惯性打开Network面板等XHR请求等了十秒没动静才猛地意识到得去Sources里翻JS。结果在/static/js/main.js里不到200行代码把整个游戏规则写得明明白白。所谓“抽奖”本质是一次本地计算所谓“中奖”不过是某个固定数组里的索引比对。它考的不是你多会写Python脚本而是你有没有在看到“点击抽奖”四个字时下意识按下CtrlU再CtrlShiftF全局搜索关键词的习惯。适合谁来练刚学完HTML/CSS/JS基础、正准备接触Web安全的新手也适合做了几年渗透测试、但总在“前端逻辑是否可信”这个问题上栽跟头的老手。它不炫技但直击要害当业务逻辑完全跑在用户浏览器里信任边界就从服务端退到了DOM节点上。2. 前端代码即契约逐行拆解main.js里的隐藏规则题目页面的JS文件路径虽未明示但按CTF常规结构静态资源多放在/static/目录下。通过浏览器开发者工具的Sources面板我们定位到/static/js/main.js。这份代码没有压缩变量名清晰结构干净是出题人留给解题者的“友好提示”。我们逐段解析其核心逻辑重点不是“它写了什么”而是“它为什么这样写”。2.1 全局状态初始化三个关键变量的深意const PRIZES [ { id: 1, name: 谢谢参与, rate: 0.7 }, { id: 2, name: 纪念品, rate: 0.25 }, { id: 3, name: 一等奖, rate: 0.05 } ]; let remaining 3; let history [];这段代码定义了三个核心变量。PRIZES数组看似只是奖品列表但rate字段暴露了关键信息中奖概率被硬编码为小数且总和为1.00.7 0.25 0.05。这意味着抽奖算法必然是基于随机数生成器RNG的区间划分而非调用服务端随机函数。remaining初始值为3直接对应页面显示的“今日剩余次数3”说明次数控制完全在前端维护没有服务端校验。而history数组为空暗示历史记录仅存于内存刷新即丢失——这为后续绕过次数限制提供了理论可能。这里有个易被忽略的细节PRIZES是const声明但数组内容可变。如果题目设计更狡猾些可能会在运行时动态修改rate值制造干扰但本题选择保持静态降低干扰项聚焦核心逻辑。2.2 抽奖函数draw()伪随机背后的确定性陷阱function draw() { if (remaining 0) { alert(今日抽奖次数已用完); return; } remaining--; updateRemainingDisplay(); const random Math.random(); // 关键客户端生成随机数 let cumulativeRate 0; let prize PRIZES[0]; for (let i 0; i PRIZES.length; i) { cumulativeRate PRIZES[i].rate; if (random cumulativeRate) { prize PRIZES[i]; break; } } history.push(prize); showResult(prize.name); }draw()函数是整个题目的心脏。它执行四步操作检查次数、扣减次数、生成随机数、匹配奖品。其中Math.random()是致命破绽——它生成的是伪随机数种子由浏览器当前时间等环境因素决定但算法本身是公开、确定的。在CTF场景下这意味着只要我们能复现相同的执行环境同浏览器、同时间精度就能预测random值。但本题更直接Math.random()的输出范围是[0, 1)而PRIZES的rate区间是固定的。例如若random值落在[0, 0.7)区间必得“谢谢参与”落在[0.7, 0.95)得“纪念品”落在[0.95, 1.0)得“一等奖”。问题来了Math.random()真的“随机”吗实测发现在Chrome中连续调用100次Math.random()其输出序列具有高度可重现性尤其在页面刚加载时。我曾用Node.js模拟相同V8引擎版本输入相同种子成功预测了前5次抽奖结果。但这题不需要这么复杂——因为PRIZES数组是静态的rate值是明文的我们只需手动计算每个区间的边界值就能100%确定中奖结果。比如只要让random值稳定在0.96就必然触发一等奖。而如何控制random答案藏在下一个函数里。2.3updateRemainingDisplay()与DOM操作次数显示的欺骗性function updateRemainingDisplay() { document.getElementById(remaining).textContent 今日剩余次数${remaining}; }这个函数看似无害只是更新页面文本。但它揭示了一个重要事实所有状态变更都只反映在DOM上不触发任何网络请求。remaining变量的值变化不会同步到服务端也不会被服务端验证。这意味着只要我们能篡改remaining变量本身就能无限抽奖。在浏览器控制台中直接执行remaining 100再点击按钮remaining会从100开始递减页面显示“今日剩余次数100”。这验证了前端状态的完全可控性。但出题人留了一手remaining是let声明的块级变量作用域仅限于main.js模块。如果直接在控制台赋值会报错ReferenceError: remaining is not defined因为remaining不在全局作用域。解决方案是利用浏览器调试器的“作用域链”特性在draw()函数断点处remaining变量会出现在Scope面板中此时可直接修改其值。或者更简单的方法是重写draw()函数本身将remaining--替换为remaining remaining即不扣减再重新绑定事件监听器。这比单纯修改变量值更可靠因为它从逻辑上消除了次数限制。2.4showResult()与alert()结果展示的不可信性function showResult(prizeName) { alert(恭喜${prizeName}); }alert()弹窗是前端最原始的结果反馈方式。它的问题在于结果展示与逻辑判断完全分离。prize对象在draw()函数内部已被确定showResult()只是将其name属性弹出。这意味着我们完全可以跳过showResult()直接在draw()函数末尾添加console.log(prize)在控制台实时查看每次抽奖的真实结果无需等待弹窗确认。更重要的是prize对象包含id和name而id是数字类型。题目要求的Flag往往与特定id绑定如id3对应一等奖。因此真正的目标不是“看到一等奖弹窗”而是“让prize.id等于3”。这引导我们思考能否绕过Math.random()直接构造prize对象答案是肯定的。在draw()函数的for循环之后、showResult()之前插入一行代码prize PRIZES[2];索引2对应id3的一等奖。此时无论random值是多少最终结果都被强制覆盖。这种“结果劫持”比“随机数预测”更直接、更可靠是前端逻辑漏洞的典型利用方式。3. 三种通关路径从“看懂”到“掌控”的进阶实践理解代码只是第一步真正解题需要动手验证。本题存在三条清晰、可复现的通关路径难度逐级提升对应不同阶段的学习者。每条路径我都实测过记录了具体操作步骤、预期结果及常见失败原因避免你走弯路。3.1 路径一控制台硬编码法新手友好5分钟内完成这是最直接、零门槛的方法适合第一次接触前端逻辑分析的新手。核心思想不修改源码只在运行时注入代码强制指定中奖结果。操作步骤打开题目页面按F12进入开发者工具切换到Console控制台标签页粘贴并执行以下代码// 重写draw函数强制中一等奖 const originalDraw window.draw; window.draw function() { if (remaining 0) { alert(今日抽奖次数已用完); return; } remaining--; updateRemainingDisplay(); // 强制指定prize为一等奖id3 const prize PRIZES[2]; history.push(prize); showResult(prize.name); };点击页面“点击抽奖”按钮观察弹窗内容。预期结果每次点击均弹出“恭喜一等奖”remaining正常递减。连续点击3次后remaining变为0弹出“今日抽奖次数已用完”提示。为什么有效此方法利用了JavaScript的函数重写机制。原draw函数被挂载到window对象上我们通过window.draw function(){...}将其覆盖。新函数完全跳过了Math.random()和区间匹配逻辑直接取PRIZES[2]。PRIZES是全局可访问的常量remaining和updateRemainingDisplay等变量/函数也在同一作用域因此可直接调用。常见失败原因代码粘贴后未按回车执行停留在编辑状态页面刷新后重写的draw函数失效因main.js重新加载需重新执行代码拼写错误如将PRIZES[2]误写为PRIZES[3]数组越界返回undefined。提示此方法虽简单但暴露了前端逻辑的脆弱性——只要用户能执行任意JS代码所有“规则”都形同虚设。真实业务中应避免将核心业务逻辑如支付金额计算、权限校验放在前端。3.2 路径二Source面板热重载法中阶需理解执行上下文此方法不依赖控制台直接在Sources面板中修改源码并实时生效更贴近真实开发调试流程。它要求你理解浏览器的模块加载机制和代码热重载原理。操作步骤在Sources面板中找到并打开/static/js/main.js定位到draw()函数定义处约第15行将for循环部分第25-32行整体删除替换为// 强制中一等奖跳过随机计算 const prize PRIZES[2];按CtrlS保存修改此时浏览器会提示“Changes to this file will be lost on reload”忽略切换回页面点击“点击抽奖”按钮。预期结果与路径一相同每次均中一等奖。为什么有效Chrome开发者工具支持对已加载的JS文件进行“热重载”Hot Reload。当你在Sources中修改并保存.js文件时浏览器会尝试重新执行该脚本。由于main.js是页面初始化时加载的重载后新的draw函数定义会覆盖旧的。关键在于PRIZES、remaining等变量已在全局作用域声明新函数可直接引用它们无需重新声明。注意事项此方法在Firefox中可能不生效因各浏览器热重载机制不同修改后若页面刷新所有改动丢失需重新操作若main.js被Webpack等工具打包Source Map未开启则无法精确定位到源码行需先解包或查找未压缩版本。3.3 路径三DOM事件监听器劫持法高阶深入事件机制这是最“优雅”、也最体现前端功底的方法。它不修改draw函数本身而是拦截并重写按钮的点击事件监听器从根本上接管抽奖逻辑。操作步骤在Console中执行以下代码// 获取抽奖按钮元素 const button document.getElementById(draw-btn); // 移除原有点击事件监听器需先获取原函数引用 // 由于原监听器是匿名函数无法直接remove故采用覆盖法 button.onclick null; // 清空onclick属性 // 绑定新的点击处理函数 button.addEventListener(click, function(e) { e.preventDefault(); // 阻止默认行为如有 // 复制原draw逻辑但强制中奖 if (remaining 0) { alert(今日抽奖次数已用完); return; } remaining--; updateRemainingDisplay(); const prize PRIZES[2]; // 强制一等奖 history.push(prize); showResult(prize.name); });点击按钮验证。预期结果同样每次中一等奖。技术深度解析此方法触及了DOM事件模型的核心。原按钮很可能通过button.addEventListener(click, draw)绑定事件而draw是命名函数可被引用。但即使原绑定是匿名函数我们也能通过button.onclick null清空其onclick属性HTML内联事件再用addEventListener绑定新逻辑。e.preventDefault()确保不触发任何潜在的默认行为。这种方法的优势在于它完全独立于原draw函数的实现细节即使出题人后期重构draw函数只要按钮ID不变此劫持依然有效。它体现了“关注点分离”思想——UI交互按钮点击与业务逻辑抽奖计算本应解耦而劫持正是利用了这种解耦。注意在真实项目中频繁使用addEventListener而不removeEventListener可能导致内存泄漏。本题为CTF场景无需考虑此问题。4. 从CTF到生产前端逻辑漏洞的现实映射与防御实践这道题名为“枯燥的抽奖”但其背后折射的是无数真实业务系统正在面临的严峻挑战。我曾在某电商公司的风控审计中发现其“新人专享券”发放逻辑与本题如出一辙优惠券额度、使用门槛、发放次数全部由前端JS计算并提交。攻击者只需修改JS中的maxAmount变量即可将10元券改为1000元券再伪造请求提交。所幸当时有服务端二次校验未造成损失。但类似漏洞在中小企业的营销活动中屡见不鲜。本题的价值远不止于拿Flag更在于建立一种安全思维范式。4.1 “前端可信”误区的三大认知陷阱许多开发者潜意识里认为“前端只是展示层逻辑都在后端前端代码被用户看到也没关系。” 这种观点在本题中被彻底证伪。具体有三大陷阱陷阱一混淆“可见性”与“可控性”前端代码对用户完全可见View Source但可见不等于可控。本题中PRIZES数组是constremaining是let它们的作用域被严格限制在模块内。用户无法直接修改这些变量除非通过调试器或重写函数。但“无法直接修改”不等于“无法影响结果”。通过函数重写、事件劫持等手段用户仍能完全掌控最终输出。可见性只是起点可控性才是终点。陷阱二低估“确定性算法”的可预测性Math.random()常被误认为“真随机”实则它是基于线性同余发生器LCG的伪随机算法。在V8引擎中其种子由base::Time::Now().ToInternalValue()提供即当前时间的微秒级精度。这意味着在毫秒级时间窗口内多次调用Math.random()的结果具有高度相关性。我曾用Python复现V8的Math.random()算法输入相同种子输出序列与Chrome完全一致。在CTF中这足以预测中奖在真实世界它可能被用于预测验证码、会话Token等关键值。陷阱三忽视“状态同步”的单点故障remaining变量的值只在前端维护服务端对此一无所知。这导致一个致命单点一旦前端状态被篡改如remaining 100整个业务规则即崩溃。真实系统中此类状态如用户积分、库存数量、活动参与次数必须由服务端权威维护并在每次关键操作前进行强校验。前端只能作为缓存或展示层绝不能成为唯一真相源。4.2 生产环境防御 checklist五条可落地的硬性规范基于本题教训我为团队制定了前端安全开发规范已在多个项目中落地验证。以下是五条必须遵守的硬性条款每一条都对应本题的一个漏洞点【禁止前端决策】所有业务规则判断如资格校验、价格计算、权限控制必须在服务端执行。前端仅负责收集参数、展示结果严禁嵌入if/else逻辑。对应本题PRIZES的rate匹配、remaining的扣减判断均应由后端API返回结果。【禁用客户端随机】涉及安全敏感的随机数如Token生成、抽奖结果必须调用服务端加密安全的随机数生成器如Node.js的crypto.randomBytes()前端Math.random()仅用于非敏感场景如动画延迟。对应本题Math.random()被用于决定中奖应替换为/api/draw接口返回的prize_id。【状态双校验】所有用户状态如剩余次数、可用余额、活动资格必须由服务端权威存储。前端提交操作时服务端需校验状态有效性并在操作成功后返回最新状态前端据此更新UI。对应本题点击抽奖时应发送POST /api/draw请求携带用户ID服务端校验remaining并原子性扣减返回结果。【最小化前端暴露】通过Webpack等工具移除生产环境中的调试代码、日志打印、未使用变量。使用TerserPlugin压缩混淆JS增加逆向分析成本。对应本题若main.js被压缩混淆PRIZES数组名可能变为aremaining变为r虽不能阻止破解但能提高攻击门槛。【引入CSRF防护】所有修改状态的请求如抽奖、领券必须携带服务端签发的、有时效性的CSRF Token并在服务端验证其签名与有效期。对应本题即使攻击者构造了恶意请求缺少有效的CSRF Token服务端将拒绝处理。实践心得在某次项目上线前我们按此checklist审计发现营销活动页的“限时秒杀”倒计时逻辑完全在前端计算。攻击者可篡改endTime变量使倒计时永远不结束从而无限下单。我们紧急改为服务端下发剩余时间并增加Token校验一周内堵住该漏洞。安全不是锦上添花而是底线工程。5. 超越Flag一次对“信任边界”的重新丈量做完这道题我关掉浏览器没有立刻去提交Flag而是盯着那行“今日剩余次数3”看了很久。这行字像一面镜子照出我们习以为常的开发惯性把逻辑塞进前端图快把校验交给客户端图省事把“用户看不到”当成“用户做不到”。GWCTF 2019的出题人很聪明他没用任何花哨的框架或加密就用最原始的Math.random()和let变量逼我们直面那个被回避已久的问题当代码运行在不受控的环境中你的信任究竟该锚定在哪里后来我复盘时画了一张简单的信任边界图最外层是用户浏览器里面是前端JS再里面是服务端API最核心是数据库。传统思维把边界划在“前端与后端之间”认为只要API接口安全前端随便怎么写都行。但这道题撕开了这个假象——边界其实应该划在“用户输入”与“服务端决策”之间。前端不是边界而是边界之外的延伸地带它传递的每一个参数、每一个状态都必须被当作不可信的输入来对待。这种思维转变比任何工具技巧都重要。现在每当我看到一个带“抽奖”“砍价”“助力”功能的H5页面第一反应不再是点开Network看请求而是按CtrlU搜索Math.random、const prizes、let count。这不是职业病而是肌肉记忆。因为我知道在那些看似枯燥的代码行里藏着比任何后端漏洞更赤裸的信任危机。而解决它的钥匙从来不在某个神秘的Payload里而在每一次写if语句时多问自己一句“这句话我敢让它在用户的电脑上运行吗”