H5前端安全攻防实战:从负数金额漏洞到签名绕过防御
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(Map<String, 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(Map<String, 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?orderId=ORDER_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,获得
这个案例的狡猾之处在于,它利用了签名验证和业务逻辑对参数来源的解析不一致。签名验的是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. 遍历排序后的参数名,按“key=value”格式拼接,用“&”连接。value需进行URL编码。 5. 将拼接的字符串与 `secret` 组合,使用HMAC-SHA256等强哈希算法计算签名。
- 签名所有非空参数:最安全的做法是,除
严格的参数校验
- 类型与范围:对每个输入参数进行严格的类型、范围、格式、枚举值校验。使用如Java的Bean Validation(
@NotNull,@Min,@Max,@Pattern)等框架。 - 业务逻辑校验:金额必须大于0,用户状态必须有效,订单必须属于当前用户等。这些校验应放在签名验证之后,业务逻辑之前。
- 类型与范围:对每个输入参数进行严格的类型、范围、格式、枚举值校验。使用如Java的Bean Validation(
防重放攻击机制
- 时间戳:请求中必须包含当前时间戳(如
timestamp)。服务端收到请求后,校验该时间戳与服务器时间的差值是否在允许范围内(例如±5分钟)。超出范围的请求视为重放,拒绝处理。 - 随机数(Nonce):请求中必须包含一个唯一随机字符串(如
nonce)。服务端可将近期(如时间戳允许范围内)的nonce缓存起来(如存入Redis,设置过期时间)。如果收到重复的nonce,则判定为重放攻击,拒绝请求。nonce必须参与签名。 - 组合使用:
timestamp+nonce是防重放的经典组合。时间戳防止旧请求被长期重放,nonce防止在时间窗口内的短期重放。
- 时间戳:请求中必须包含当前时间戳(如
密钥安全管理
- 服务端存储:签名密钥应存储在服务端的配置中心或环境变量中,严禁写入代码。
- 定期轮换:制定密钥轮换策略,定期更新密钥。即使某个密钥意外泄露,影响范围也可控。
- 分级密钥:不同重要级别的接口、不同环境(生产/测试)使用不同的密钥。
5.3 安全测试与监控
- 渗透测试与代码审计:定期对H5前端代码和后端接口进行安全审计和渗透测试,重点关注业务逻辑漏洞、签名实现、输入校验等。
- 请求日志与审计:记录所有关键接口的请求和响应日志,包括完整的参数、签名、用户IP、时间等。这些日志是事后追溯和分析攻击的宝贵资料。
- 异常行为监控:建立风控规则,监控异常行为。例如:
- 同一用户短时间内高频发起相同请求。
- 请求参数出现异常值(如金额为负数、超大整数)。
- 签名验证失败的频率突然升高。
- 某个接口的请求模式与正常用户行为差异巨大。
- WAF(Web应用防火墙):在网关层部署WAF,可以拦截一些通用的攻击模式,如SQL注入、XSS、恶意扫描等,为应用层安全提供一道额外的防线。
6. 总结与反思
回顾这次从“负数金额”到“签名绕过”的攻防实战,其本质是安全链条上多个环节的连续失守。最初,业务逻辑缺乏基本的输入校验,让攻击者有了可乘之机。在引入签名机制后,又因为实现上的不严谨(参数签名范围不完整、业务逻辑与验签逻辑解析不一致),导致了防御被绕过。
对于开发者而言,最重要的启示是:安全是一个整体,任何一环的薄弱都会导致全局的崩溃。不能只依赖某一种技术(如签名),而需要构建纵深防御体系。对于安全人员,这个案例展示了攻击者总是会寻找系统中最薄弱的环节,他们的思维是发散的、联动的。我们的防御思维也必须如此,不仅要看单点,更要看链路,看交互。
在实际开发中,我个人的体会是,与其在出事后再打补丁,不如在项目初期就将这些安全规范作为必须遵守的“纪律”定下来。比如,所有对数据库状态进行“写”操作的接口,必须经过“验签->防重放->参数基础校验->业务逻辑校验”四道关卡。通过代码模板、统一拦截器(AOP)、公司中间件等方式,将这些安全逻辑固化下来,才能最大程度地避免因人而异的实现疏漏。
最后,安全攻防是一场持续的斗争。今天有效的方案,明天可能就会出现新的绕过方法。保持学习,保持警惕,对代码怀有敬畏之心,是我们每一个从业者的必修课。
