1. 项目概述一次完整的H5前端安全攻防实战最近在复盘一个内部安全众测项目时遇到一个非常典型的H5前端安全案例。它从一个看似不起眼的“负数金额”漏洞开始最终串联起前端逻辑、接口交互、签名校验等多个环节形成了一条完整的攻击链。这个案例完美地展示了现代Web应用中前端尤其是移动端H5页面安全问题的复杂性和隐蔽性。很多开发团队认为前端代码是公开的安全重心放在后端殊不知前端逻辑的缺陷往往是突破防线的第一道口子。这次实战不仅涉及漏洞的发现与利用更深入到如何绕过后续的安全加固比如签名机制对于从事前端开发、安全测试甚至是后端接口设计的同学都有很强的参考价值。我们将从攻击者视角出发完整还原攻击路径再从防御者角度给出切实可行的加固方案。2. 核心漏洞剖析负数金额的“魔力”整个攻击的起点是一个在电商、金融类H5应用中屡见不鲜但又极易被忽视的问题前端提交的数据在服务端是否被充分、正确地校验2.1 漏洞场景还原假设我们有一个H5充值页面。用户选择充值金额例如50元、100元点击充值前端会构造一个请求发给后端。一个缺乏经验的开发团队可能会这样设计前端逻辑简化// 用户点击充值按钮 function recharge() { const amount document.getElementById(amountInput).value; // 假设用户输入-100 const orderData { userId: 12345, amount: amount, // 金额直接使用前端传入值 timestamp: Date.now() }; // 生成签名假设存在签名逻辑但此时可能有问题 orderData.sign generateSign(orderData); // 发送请求 fetch(/api/recharge, { method: POST, body: JSON.stringify(orderData) }); }后端逻辑有缺陷的版本PostMapping(/api/recharge) public ApiResult recharge(RequestBody RechargeRequest request) { // 1. 验证签名我们假设这里签名验证通过了 if (!signService.verify(request)) { return ApiResult.error(签名错误); } // 2. 业务逻辑处理 UserAccount account accountService.getById(request.getUserId()); // 问题点没有对金额进行有效性校验 account.setBalance(account.getBalance() request.getAmount()); accountService.updateById(account); // 记录订单订单金额也是request.getAmount() orderService.createOrder(request); return ApiResult.success(充值成功); }2.2 漏洞原理与利用漏洞的核心在于后端业务逻辑完全信任了前端传入的amount字段没有对其值域进行校验。正常流程用户输入100后端执行balance balance 100余额增加。攻击流程攻击者通过抓包工具如Burp Suite、Charles拦截请求将amount字段的值修改为-100。后端依然执行balance balance (-100)即balance balance - 100。结果是用户的余额减少了100元而攻击者可能因此“购买”了商品或兑现了权益相当于零成本套利。注意在实际攻击中攻击者往往不会直接修改为明显的负数而是尝试极值、小数、科学计数法如1e2、超大数等以触发不同的业务逻辑错误例如整数溢出、余额变成负数等。为什么前端校验不可靠很多开发者会说“我们在前端做了校验输入框限制了只能输入正数。” 这正是误区所在。前端校验完全是为了用户体验和初步过滤任何来自客户端的输入都是不可信的。攻击者可以直接使用工具发送自定义请求绕过浏览器页面。禁用或修改前端JavaScript代码。使用浏览器开发者工具修改已通过校验的请求数据。这个漏洞的危害等级通常很高因为它直接涉及核心资产资金、积分、虚拟货币的增减可能造成直接的经济损失。2.3 深入利用不仅仅是充值这个漏洞的模式可以推广到任何涉及“数量”、“值”变更的业务接口积分兑换修改兑换所需的积分为负数增加自身积分。优惠券领取修改领取数量为负数尝试“退还”优惠券可能触发其他逻辑。订单支付在某些拆分支付或混合支付场景修改某个支付渠道的金额为负数抵消其他渠道的支付金额。3. 防御者的第一次反击后端校验与签名机制当安全团队或开发人员发现此类漏洞后最直接的修复方案是在后端添加强校验。3.1 完善的后端校验代码PostMapping(/api/recharge) public ApiResult recharge(RequestBody RechargeRequest request) { // 1. 验证签名 if (!signService.verify(request)) { return ApiResult.error(签名错误); } // 2. 业务参数校验 if (request.getAmount() null) { return ApiResult.error(金额不能为空); } // 核心校验金额必须为正数且符合业务规则如最小充值单位1元 if (request.getAmount().compareTo(BigDecimal.ZERO) 0) { return ApiResult.error(充值金额必须大于0); } // 校验金额精度防止传入0.001元等非法值 if (request.getAmount().scale() 2) { // 假设金额单位是元支持两位小数 return ApiResult.error(金额格式错误); } // 校验金额上限防止过大数值导致业务问题 if (request.getAmount().compareTo(new BigDecimal(10000)) 0) { return ApiResult.error(单笔充值金额不得超过10000元); } // 3. 后续业务逻辑... // ... 使用校验后的 request.getAmount() }关键点使用BigDecimal处理金额必须使用BigDecimal或数据库的Decimal类型避免浮点数精度丢失和计算错误。比较使用compareTo不要用直接比较使用BigDecimal的compareTo方法。校验维度多元化非空、正负、精度、上下限。这些规则应与产品定义严格一致。3.2 引入签名机制为了防止请求在传输过程中被篡改通常会引入签名机制。这是防御“负数金额”等篡改攻击的标准方案。签名基本原理客户端H5和服务端约定一个共同的密钥secret该密钥永不通过网络传输。客户端将请求参数如userId,amount,timestamp按照一定规则如按参数名ASCII码升序排序拼接成字符串。将拼接后的字符串与secret合并通过哈希算法如MD5、SHA256、HMAC-SHA256计算出一个签名值sign。客户端将sign连同其他参数一起发送给服务端。服务端收到请求后使用相同的算法和规则用自己存储的secret重新计算一次签名。比较客户端传来的sign和自己计算出的sign是否一致。不一致则拒绝请求。示例Node.js端生成签名const crypto require(crypto); function generateSign(params, secret) { // 1. 过滤掉sign参数本身并排序 const sortedKeys Object.keys(params) .filter(key key ! sign) .sort(); // 2. 拼接键值对 const stringToSign sortedKeys .map(key ${key}${params[key]}) .join(); // 3. 拼接密钥并计算HMAC-SHA256签名更安全 const hmac crypto.createHmac(sha256, secret); hmac.update(stringToSign); return hmac.digest(hex); } // 使用示例 const requestParams { userId: 123, amount: 100.00, timestamp: 1678886400000, nonce: abcdefg // 随机数防重放 }; const secret your-secret-key-here; requestParams.sign generateSign(requestParams, secret); // 发送 requestParams服务端验证签名public boolean verifySign(MapString, String params, String serverSecret) { String clientSign params.get(sign); if (StringUtils.isEmpty(clientSign)) { return false; } // 移除sign参数并排序、拼接 String serverSign generateServerSign(params, serverSecret); // 安全地比较两个签名防止计时攻击 return MessageDigest.isEqual(clientSign.getBytes(), serverSign.getBytes()); }到这一步防御似乎已经固若金汤前端传入的amount被篡改后由于签名是基于所有参数计算的服务端验签会失败请求被拒绝。攻击链条被斩断。4. 攻击者的迂回签名绕过实战然而在安全攻防中道高一尺魔高一丈。签名机制并非无懈可击它的安全性严重依赖于签名算法的实现细节和参与签名的参数范围。这里就引出了我们案例中的第二个关键点签名绕过。4.1 常见的签名设计缺陷签名绕过通常源于以下几种设计或实现上的疏忽1. 参数解析不一致场景客户端签名时amount的值是数字100。但攻击者传参时将其改为字符串100或100.0。如果服务端签名验证逻辑在拼接字符串时对参数值的处理方式如toString()的格式与客户端不一致就会导致服务端计算出的签名与客户端不同但业务逻辑层在解析参数时可能将字符串100成功转换为数字100。漏洞点签名验证层和业务逻辑层使用了不同的参数解析器或类型转换规则。2. 参数缺失或冗余场景签名规则是“对所有非空参数签名”。攻击者发现如果额外添加一个服务端业务逻辑不识别但签名验证逻辑会处理的参数比如一个无用的extra字段并参与签名计算服务端验签依然能通过。更危险的是如果服务端签名逻辑忽略了某些参数比如sign本身或者一些被认为是“安全”的参数如timestamp攻击者就可以在这些未被签名的参数上做文章。漏洞点签名参数范围白名单/黑名单定义不严格、不清晰。3. 签名密钥Secret泄露或可预测场景密钥硬编码在H5的JavaScript代码中。虽然代码可被压缩混淆但密钥作为字符串常量仍有被提取的风险。或者密钥的生成算法存在缺陷导致可以被攻击者推算出来。漏洞点密钥管理不当客户端存在不应存储的敏感信息。4. 重放攻击Replay Attack场景签名算法本身没问题但请求中没有防重放机制如一次性随机数nonce或严格的时间戳校验。攻击者拦截一个合法的“充值100元”的请求包虽然不能修改amount因为改了就验签失败但他可以将这个完整的请求包原封不动地重复发送多次导致用户被重复扣款或重复充值。漏洞点签名机制保证了请求不被篡改但无法保证请求的唯一性。4.2 实战中的签名绕过案例在我们的H5渗透案例中遇到的是一种结合了参数缺失和业务逻辑上下文的绕过方式。漏洞接口/api/applyCoupon(应用优惠券)正常请求{ userId: 123, couponCode: SAVE10, orderId: ORDER_67890, timestamp: 1678886400, sign: a1b2c3d4e5f6... // 由 userId, couponCode, orderId, timestamp 计算得出 }后端签名验证逻辑有缺陷public boolean verifySign(MapString, String params) { // 只对 userId, couponCode, orderId 这三个字段进行签名验证 // timestamp 和 sign 字段被忽略了 String[] signFields {userId, couponCode, orderId}; // ... 拼接 signFields 对应的值并计算签名 ... }后端业务逻辑public ApiResult applyCoupon(ApplyRequest request) { // 1. 验签基于有缺陷的白名单 // 2. 查询优惠券信息 Coupon coupon couponService.getByCode(request.getCouponCode()); // 3. 检查优惠券是否适用于此订单 // 关键点这里检查优惠券的适用范围时依赖了 request.getOrderId() if (!coupon.isApplicableToOrder(request.getOrderId())) { return ApiResult.error(优惠券不适用于此订单); } // 4. 应用优惠计算折扣... }攻击者的绕过思路观察攻击者发现timestamp字段不参与签名。实验攻击者尝试修改timestamp请求依然成功。这证实了timestamp不在签名范围内。关联攻击者回顾“负数金额”漏洞目标是修改金额。但当前接口是应用优惠券不直接涉及金额。构造攻击链攻击者先下一个正常订单A获得orderId_A。他有一个面值很大的优惠券COUPON_X但该券规则是“仅限订单B使用”。他拦截应用优惠券的请求将couponCode改为COUPON_X同时将orderId改为orderId_B一个他无权操作或已存在的订单ID。由于orderId在签名白名单内直接修改会导致验签失败。关键绕过步骤攻击者不修改请求体中的orderId而是利用服务端业务逻辑的另一个缺陷。他发现服务端在applyCoupon方法中除了从请求体 (RequestBody) 解析orderId还会尝试从HTTP请求的URL路径参数或Header中读取orderId并且业务逻辑优先使用了后者攻击请求POST /api/applyCoupon?orderIdORDER_B HTTP/1.1 Host: target.com Content-Type: application/json { userId: 123, couponCode: COUPON_X, orderId: ORDER_A, // 请求体中的orderId参与签名保持不变 timestamp: 1678886400, sign: 合法的签名基于ORDER_A计算 }服务端处理签名验证使用请求体中的orderId: “ORDER_A”计算签名验证通过。业务逻辑couponService.getByCode(“COUPON_X”)获取到优惠券。检查适用范围coupon.isApplicableToOrder(...)。这里的方法参数如果是从RequestParam(“orderId”)获取那么值就是ORDER_B。优惠券规则检查通过因为COUPON_X确实适用于ORDER_B。最终优惠券被成功应用到了攻击者的订单ORDER_A上而他本无权使用这张券。这个案例的狡猾之处在于它利用了签名验证和业务逻辑对参数来源的解析不一致。签名验的是A业务用的是B从而在签名有效的情况下实现了业务逻辑的欺骗。5. 全链路加固方案从开发到运维面对如此迂回的攻击单一的防御措施是远远不够的。我们需要建立一套从前端到后端、从代码到运维的全链路安全体系。5.1 前端H5安全编码规范输入校验仅为体验明确前端校验的目的——提升用户体验和减少无效请求绝不能作为安全依据。所有关键业务逻辑的校验必须在后端进行。敏感信息零存储绝对不要将加密密钥secret、数据库连接信息等硬编码或存储在H5的代码、本地存储LocalStorage、Cookie中。签名所需的secret应仅存在于服务端。代码混淆与加固对JavaScript代码进行压缩、混淆增加静态分析的难度。但要知道这只能提高攻击门槛不能从根本上防止逆向。使用安全的通信强制使用HTTPS防止中间人攻击MITM窃听或篡改请求。5.2 后端接口安全设计黄金法则完整的参数签名签名所有非空参数最安全的做法是除sign字段本身外所有传递给接口的参数包括URL Query参数、Header中自定义的业务参数、RequestBody中的参数都应参与签名计算。规范化参数在签名前必须对参数进行规范化处理。例如统一将数字转为字符串格式统一日期格式过滤掉参数名和值两端的空格。确保客户端和服务端的处理逻辑完全一致。示例规范流程1. 获取所有参数GET/POST/Header中约定的业务参数。 2. 过滤掉 sign 字段。 3. 将所有参数名按ASCII码升序排序。 4. 遍历排序后的参数名按“keyvalue”格式拼接用“”连接。value需进行URL编码。 5. 将拼接的字符串与 secret 组合使用HMAC-SHA256等强哈希算法计算签名。严格的参数校验类型与范围对每个输入参数进行严格的类型、范围、格式、枚举值校验。使用如Java的Bean ValidationNotNull,Min,Max,Pattern等框架。业务逻辑校验金额必须大于0用户状态必须有效订单必须属于当前用户等。这些校验应放在签名验证之后业务逻辑之前。防重放攻击机制时间戳请求中必须包含当前时间戳如timestamp。服务端收到请求后校验该时间戳与服务器时间的差值是否在允许范围内例如±5分钟。超出范围的请求视为重放拒绝处理。随机数Nonce请求中必须包含一个唯一随机字符串如nonce。服务端可将近期如时间戳允许范围内的nonce缓存起来如存入Redis设置过期时间。如果收到重复的nonce则判定为重放攻击拒绝请求。nonce必须参与签名。组合使用timestampnonce是防重放的经典组合。时间戳防止旧请求被长期重放nonce防止在时间窗口内的短期重放。密钥安全管理服务端存储签名密钥应存储在服务端的配置中心或环境变量中严禁写入代码。定期轮换制定密钥轮换策略定期更新密钥。即使某个密钥意外泄露影响范围也可控。分级密钥不同重要级别的接口、不同环境生产/测试使用不同的密钥。5.3 安全测试与监控渗透测试与代码审计定期对H5前端代码和后端接口进行安全审计和渗透测试重点关注业务逻辑漏洞、签名实现、输入校验等。请求日志与审计记录所有关键接口的请求和响应日志包括完整的参数、签名、用户IP、时间等。这些日志是事后追溯和分析攻击的宝贵资料。异常行为监控建立风控规则监控异常行为。例如同一用户短时间内高频发起相同请求。请求参数出现异常值如金额为负数、超大整数。签名验证失败的频率突然升高。某个接口的请求模式与正常用户行为差异巨大。WAFWeb应用防火墙在网关层部署WAF可以拦截一些通用的攻击模式如SQL注入、XSS、恶意扫描等为应用层安全提供一道额外的防线。6. 总结与反思回顾这次从“负数金额”到“签名绕过”的攻防实战其本质是安全链条上多个环节的连续失守。最初业务逻辑缺乏基本的输入校验让攻击者有了可乘之机。在引入签名机制后又因为实现上的不严谨参数签名范围不完整、业务逻辑与验签逻辑解析不一致导致了防御被绕过。对于开发者而言最重要的启示是安全是一个整体任何一环的薄弱都会导致全局的崩溃。不能只依赖某一种技术如签名而需要构建纵深防御体系。对于安全人员这个案例展示了攻击者总是会寻找系统中最薄弱的环节他们的思维是发散的、联动的。我们的防御思维也必须如此不仅要看单点更要看链路看交互。在实际开发中我个人的体会是与其在出事后再打补丁不如在项目初期就将这些安全规范作为必须遵守的“纪律”定下来。比如所有对数据库状态进行“写”操作的接口必须经过“验签-防重放-参数基础校验-业务逻辑校验”四道关卡。通过代码模板、统一拦截器AOP、公司中间件等方式将这些安全逻辑固化下来才能最大程度地避免因人而异的实现疏漏。最后安全攻防是一场持续的斗争。今天有效的方案明天可能就会出现新的绕过方法。保持学习保持警惕对代码怀有敬畏之心是我们每一个从业者的必修课。
H5前端安全攻防实战:从负数金额漏洞到签名绕过防御
发布时间:2026/6/29 1:09:54
1. 项目概述一次完整的H5前端安全攻防实战最近在复盘一个内部安全众测项目时遇到一个非常典型的H5前端安全案例。它从一个看似不起眼的“负数金额”漏洞开始最终串联起前端逻辑、接口交互、签名校验等多个环节形成了一条完整的攻击链。这个案例完美地展示了现代Web应用中前端尤其是移动端H5页面安全问题的复杂性和隐蔽性。很多开发团队认为前端代码是公开的安全重心放在后端殊不知前端逻辑的缺陷往往是突破防线的第一道口子。这次实战不仅涉及漏洞的发现与利用更深入到如何绕过后续的安全加固比如签名机制对于从事前端开发、安全测试甚至是后端接口设计的同学都有很强的参考价值。我们将从攻击者视角出发完整还原攻击路径再从防御者角度给出切实可行的加固方案。2. 核心漏洞剖析负数金额的“魔力”整个攻击的起点是一个在电商、金融类H5应用中屡见不鲜但又极易被忽视的问题前端提交的数据在服务端是否被充分、正确地校验2.1 漏洞场景还原假设我们有一个H5充值页面。用户选择充值金额例如50元、100元点击充值前端会构造一个请求发给后端。一个缺乏经验的开发团队可能会这样设计前端逻辑简化// 用户点击充值按钮 function recharge() { const amount document.getElementById(amountInput).value; // 假设用户输入-100 const orderData { userId: 12345, amount: amount, // 金额直接使用前端传入值 timestamp: Date.now() }; // 生成签名假设存在签名逻辑但此时可能有问题 orderData.sign generateSign(orderData); // 发送请求 fetch(/api/recharge, { method: POST, body: JSON.stringify(orderData) }); }后端逻辑有缺陷的版本PostMapping(/api/recharge) public ApiResult recharge(RequestBody RechargeRequest request) { // 1. 验证签名我们假设这里签名验证通过了 if (!signService.verify(request)) { return ApiResult.error(签名错误); } // 2. 业务逻辑处理 UserAccount account accountService.getById(request.getUserId()); // 问题点没有对金额进行有效性校验 account.setBalance(account.getBalance() request.getAmount()); accountService.updateById(account); // 记录订单订单金额也是request.getAmount() orderService.createOrder(request); return ApiResult.success(充值成功); }2.2 漏洞原理与利用漏洞的核心在于后端业务逻辑完全信任了前端传入的amount字段没有对其值域进行校验。正常流程用户输入100后端执行balance balance 100余额增加。攻击流程攻击者通过抓包工具如Burp Suite、Charles拦截请求将amount字段的值修改为-100。后端依然执行balance balance (-100)即balance balance - 100。结果是用户的余额减少了100元而攻击者可能因此“购买”了商品或兑现了权益相当于零成本套利。注意在实际攻击中攻击者往往不会直接修改为明显的负数而是尝试极值、小数、科学计数法如1e2、超大数等以触发不同的业务逻辑错误例如整数溢出、余额变成负数等。为什么前端校验不可靠很多开发者会说“我们在前端做了校验输入框限制了只能输入正数。” 这正是误区所在。前端校验完全是为了用户体验和初步过滤任何来自客户端的输入都是不可信的。攻击者可以直接使用工具发送自定义请求绕过浏览器页面。禁用或修改前端JavaScript代码。使用浏览器开发者工具修改已通过校验的请求数据。这个漏洞的危害等级通常很高因为它直接涉及核心资产资金、积分、虚拟货币的增减可能造成直接的经济损失。2.3 深入利用不仅仅是充值这个漏洞的模式可以推广到任何涉及“数量”、“值”变更的业务接口积分兑换修改兑换所需的积分为负数增加自身积分。优惠券领取修改领取数量为负数尝试“退还”优惠券可能触发其他逻辑。订单支付在某些拆分支付或混合支付场景修改某个支付渠道的金额为负数抵消其他渠道的支付金额。3. 防御者的第一次反击后端校验与签名机制当安全团队或开发人员发现此类漏洞后最直接的修复方案是在后端添加强校验。3.1 完善的后端校验代码PostMapping(/api/recharge) public ApiResult recharge(RequestBody RechargeRequest request) { // 1. 验证签名 if (!signService.verify(request)) { return ApiResult.error(签名错误); } // 2. 业务参数校验 if (request.getAmount() null) { return ApiResult.error(金额不能为空); } // 核心校验金额必须为正数且符合业务规则如最小充值单位1元 if (request.getAmount().compareTo(BigDecimal.ZERO) 0) { return ApiResult.error(充值金额必须大于0); } // 校验金额精度防止传入0.001元等非法值 if (request.getAmount().scale() 2) { // 假设金额单位是元支持两位小数 return ApiResult.error(金额格式错误); } // 校验金额上限防止过大数值导致业务问题 if (request.getAmount().compareTo(new BigDecimal(10000)) 0) { return ApiResult.error(单笔充值金额不得超过10000元); } // 3. 后续业务逻辑... // ... 使用校验后的 request.getAmount() }关键点使用BigDecimal处理金额必须使用BigDecimal或数据库的Decimal类型避免浮点数精度丢失和计算错误。比较使用compareTo不要用直接比较使用BigDecimal的compareTo方法。校验维度多元化非空、正负、精度、上下限。这些规则应与产品定义严格一致。3.2 引入签名机制为了防止请求在传输过程中被篡改通常会引入签名机制。这是防御“负数金额”等篡改攻击的标准方案。签名基本原理客户端H5和服务端约定一个共同的密钥secret该密钥永不通过网络传输。客户端将请求参数如userId,amount,timestamp按照一定规则如按参数名ASCII码升序排序拼接成字符串。将拼接后的字符串与secret合并通过哈希算法如MD5、SHA256、HMAC-SHA256计算出一个签名值sign。客户端将sign连同其他参数一起发送给服务端。服务端收到请求后使用相同的算法和规则用自己存储的secret重新计算一次签名。比较客户端传来的sign和自己计算出的sign是否一致。不一致则拒绝请求。示例Node.js端生成签名const crypto require(crypto); function generateSign(params, secret) { // 1. 过滤掉sign参数本身并排序 const sortedKeys Object.keys(params) .filter(key key ! sign) .sort(); // 2. 拼接键值对 const stringToSign sortedKeys .map(key ${key}${params[key]}) .join(); // 3. 拼接密钥并计算HMAC-SHA256签名更安全 const hmac crypto.createHmac(sha256, secret); hmac.update(stringToSign); return hmac.digest(hex); } // 使用示例 const requestParams { userId: 123, amount: 100.00, timestamp: 1678886400000, nonce: abcdefg // 随机数防重放 }; const secret your-secret-key-here; requestParams.sign generateSign(requestParams, secret); // 发送 requestParams服务端验证签名public boolean verifySign(MapString, String params, String serverSecret) { String clientSign params.get(sign); if (StringUtils.isEmpty(clientSign)) { return false; } // 移除sign参数并排序、拼接 String serverSign generateServerSign(params, serverSecret); // 安全地比较两个签名防止计时攻击 return MessageDigest.isEqual(clientSign.getBytes(), serverSign.getBytes()); }到这一步防御似乎已经固若金汤前端传入的amount被篡改后由于签名是基于所有参数计算的服务端验签会失败请求被拒绝。攻击链条被斩断。4. 攻击者的迂回签名绕过实战然而在安全攻防中道高一尺魔高一丈。签名机制并非无懈可击它的安全性严重依赖于签名算法的实现细节和参与签名的参数范围。这里就引出了我们案例中的第二个关键点签名绕过。4.1 常见的签名设计缺陷签名绕过通常源于以下几种设计或实现上的疏忽1. 参数解析不一致场景客户端签名时amount的值是数字100。但攻击者传参时将其改为字符串100或100.0。如果服务端签名验证逻辑在拼接字符串时对参数值的处理方式如toString()的格式与客户端不一致就会导致服务端计算出的签名与客户端不同但业务逻辑层在解析参数时可能将字符串100成功转换为数字100。漏洞点签名验证层和业务逻辑层使用了不同的参数解析器或类型转换规则。2. 参数缺失或冗余场景签名规则是“对所有非空参数签名”。攻击者发现如果额外添加一个服务端业务逻辑不识别但签名验证逻辑会处理的参数比如一个无用的extra字段并参与签名计算服务端验签依然能通过。更危险的是如果服务端签名逻辑忽略了某些参数比如sign本身或者一些被认为是“安全”的参数如timestamp攻击者就可以在这些未被签名的参数上做文章。漏洞点签名参数范围白名单/黑名单定义不严格、不清晰。3. 签名密钥Secret泄露或可预测场景密钥硬编码在H5的JavaScript代码中。虽然代码可被压缩混淆但密钥作为字符串常量仍有被提取的风险。或者密钥的生成算法存在缺陷导致可以被攻击者推算出来。漏洞点密钥管理不当客户端存在不应存储的敏感信息。4. 重放攻击Replay Attack场景签名算法本身没问题但请求中没有防重放机制如一次性随机数nonce或严格的时间戳校验。攻击者拦截一个合法的“充值100元”的请求包虽然不能修改amount因为改了就验签失败但他可以将这个完整的请求包原封不动地重复发送多次导致用户被重复扣款或重复充值。漏洞点签名机制保证了请求不被篡改但无法保证请求的唯一性。4.2 实战中的签名绕过案例在我们的H5渗透案例中遇到的是一种结合了参数缺失和业务逻辑上下文的绕过方式。漏洞接口/api/applyCoupon(应用优惠券)正常请求{ userId: 123, couponCode: SAVE10, orderId: ORDER_67890, timestamp: 1678886400, sign: a1b2c3d4e5f6... // 由 userId, couponCode, orderId, timestamp 计算得出 }后端签名验证逻辑有缺陷public boolean verifySign(MapString, String params) { // 只对 userId, couponCode, orderId 这三个字段进行签名验证 // timestamp 和 sign 字段被忽略了 String[] signFields {userId, couponCode, orderId}; // ... 拼接 signFields 对应的值并计算签名 ... }后端业务逻辑public ApiResult applyCoupon(ApplyRequest request) { // 1. 验签基于有缺陷的白名单 // 2. 查询优惠券信息 Coupon coupon couponService.getByCode(request.getCouponCode()); // 3. 检查优惠券是否适用于此订单 // 关键点这里检查优惠券的适用范围时依赖了 request.getOrderId() if (!coupon.isApplicableToOrder(request.getOrderId())) { return ApiResult.error(优惠券不适用于此订单); } // 4. 应用优惠计算折扣... }攻击者的绕过思路观察攻击者发现timestamp字段不参与签名。实验攻击者尝试修改timestamp请求依然成功。这证实了timestamp不在签名范围内。关联攻击者回顾“负数金额”漏洞目标是修改金额。但当前接口是应用优惠券不直接涉及金额。构造攻击链攻击者先下一个正常订单A获得orderId_A。他有一个面值很大的优惠券COUPON_X但该券规则是“仅限订单B使用”。他拦截应用优惠券的请求将couponCode改为COUPON_X同时将orderId改为orderId_B一个他无权操作或已存在的订单ID。由于orderId在签名白名单内直接修改会导致验签失败。关键绕过步骤攻击者不修改请求体中的orderId而是利用服务端业务逻辑的另一个缺陷。他发现服务端在applyCoupon方法中除了从请求体 (RequestBody) 解析orderId还会尝试从HTTP请求的URL路径参数或Header中读取orderId并且业务逻辑优先使用了后者攻击请求POST /api/applyCoupon?orderIdORDER_B HTTP/1.1 Host: target.com Content-Type: application/json { userId: 123, couponCode: COUPON_X, orderId: ORDER_A, // 请求体中的orderId参与签名保持不变 timestamp: 1678886400, sign: 合法的签名基于ORDER_A计算 }服务端处理签名验证使用请求体中的orderId: “ORDER_A”计算签名验证通过。业务逻辑couponService.getByCode(“COUPON_X”)获取到优惠券。检查适用范围coupon.isApplicableToOrder(...)。这里的方法参数如果是从RequestParam(“orderId”)获取那么值就是ORDER_B。优惠券规则检查通过因为COUPON_X确实适用于ORDER_B。最终优惠券被成功应用到了攻击者的订单ORDER_A上而他本无权使用这张券。这个案例的狡猾之处在于它利用了签名验证和业务逻辑对参数来源的解析不一致。签名验的是A业务用的是B从而在签名有效的情况下实现了业务逻辑的欺骗。5. 全链路加固方案从开发到运维面对如此迂回的攻击单一的防御措施是远远不够的。我们需要建立一套从前端到后端、从代码到运维的全链路安全体系。5.1 前端H5安全编码规范输入校验仅为体验明确前端校验的目的——提升用户体验和减少无效请求绝不能作为安全依据。所有关键业务逻辑的校验必须在后端进行。敏感信息零存储绝对不要将加密密钥secret、数据库连接信息等硬编码或存储在H5的代码、本地存储LocalStorage、Cookie中。签名所需的secret应仅存在于服务端。代码混淆与加固对JavaScript代码进行压缩、混淆增加静态分析的难度。但要知道这只能提高攻击门槛不能从根本上防止逆向。使用安全的通信强制使用HTTPS防止中间人攻击MITM窃听或篡改请求。5.2 后端接口安全设计黄金法则完整的参数签名签名所有非空参数最安全的做法是除sign字段本身外所有传递给接口的参数包括URL Query参数、Header中自定义的业务参数、RequestBody中的参数都应参与签名计算。规范化参数在签名前必须对参数进行规范化处理。例如统一将数字转为字符串格式统一日期格式过滤掉参数名和值两端的空格。确保客户端和服务端的处理逻辑完全一致。示例规范流程1. 获取所有参数GET/POST/Header中约定的业务参数。 2. 过滤掉 sign 字段。 3. 将所有参数名按ASCII码升序排序。 4. 遍历排序后的参数名按“keyvalue”格式拼接用“”连接。value需进行URL编码。 5. 将拼接的字符串与 secret 组合使用HMAC-SHA256等强哈希算法计算签名。严格的参数校验类型与范围对每个输入参数进行严格的类型、范围、格式、枚举值校验。使用如Java的Bean ValidationNotNull,Min,Max,Pattern等框架。业务逻辑校验金额必须大于0用户状态必须有效订单必须属于当前用户等。这些校验应放在签名验证之后业务逻辑之前。防重放攻击机制时间戳请求中必须包含当前时间戳如timestamp。服务端收到请求后校验该时间戳与服务器时间的差值是否在允许范围内例如±5分钟。超出范围的请求视为重放拒绝处理。随机数Nonce请求中必须包含一个唯一随机字符串如nonce。服务端可将近期如时间戳允许范围内的nonce缓存起来如存入Redis设置过期时间。如果收到重复的nonce则判定为重放攻击拒绝请求。nonce必须参与签名。组合使用timestampnonce是防重放的经典组合。时间戳防止旧请求被长期重放nonce防止在时间窗口内的短期重放。密钥安全管理服务端存储签名密钥应存储在服务端的配置中心或环境变量中严禁写入代码。定期轮换制定密钥轮换策略定期更新密钥。即使某个密钥意外泄露影响范围也可控。分级密钥不同重要级别的接口、不同环境生产/测试使用不同的密钥。5.3 安全测试与监控渗透测试与代码审计定期对H5前端代码和后端接口进行安全审计和渗透测试重点关注业务逻辑漏洞、签名实现、输入校验等。请求日志与审计记录所有关键接口的请求和响应日志包括完整的参数、签名、用户IP、时间等。这些日志是事后追溯和分析攻击的宝贵资料。异常行为监控建立风控规则监控异常行为。例如同一用户短时间内高频发起相同请求。请求参数出现异常值如金额为负数、超大整数。签名验证失败的频率突然升高。某个接口的请求模式与正常用户行为差异巨大。WAFWeb应用防火墙在网关层部署WAF可以拦截一些通用的攻击模式如SQL注入、XSS、恶意扫描等为应用层安全提供一道额外的防线。6. 总结与反思回顾这次从“负数金额”到“签名绕过”的攻防实战其本质是安全链条上多个环节的连续失守。最初业务逻辑缺乏基本的输入校验让攻击者有了可乘之机。在引入签名机制后又因为实现上的不严谨参数签名范围不完整、业务逻辑与验签逻辑解析不一致导致了防御被绕过。对于开发者而言最重要的启示是安全是一个整体任何一环的薄弱都会导致全局的崩溃。不能只依赖某一种技术如签名而需要构建纵深防御体系。对于安全人员这个案例展示了攻击者总是会寻找系统中最薄弱的环节他们的思维是发散的、联动的。我们的防御思维也必须如此不仅要看单点更要看链路看交互。在实际开发中我个人的体会是与其在出事后再打补丁不如在项目初期就将这些安全规范作为必须遵守的“纪律”定下来。比如所有对数据库状态进行“写”操作的接口必须经过“验签-防重放-参数基础校验-业务逻辑校验”四道关卡。通过代码模板、统一拦截器AOP、公司中间件等方式将这些安全逻辑固化下来才能最大程度地避免因人而异的实现疏漏。最后安全攻防是一场持续的斗争。今天有效的方案明天可能就会出现新的绕过方法。保持学习保持警惕对代码怀有敬畏之心是我们每一个从业者的必修课。