优惠码兑换接口在IAP订阅体系里属于典型的高危资产接口。它不像普通商品列表或详情页,读多写少,可以容忍少量异常流量。优惠码一旦被重复兑换,要么直接造成订阅库存损失,要么被转卖成低价订阅权益。更麻烦的是,这类接口往往同时承载了用户身份绑定、优惠码核销、苹果服务端校验等多个动作,任何一步缺少完整性保护,都可能被攻击者利用。本文围绕时间戳、随机数、签名、加密四类基础手段,说明如何设计一个可以落地的防重放与防篡改方案。

攻击面:为什么HTTPS和登录态还不够
很多人认为加上HTTPS和OAuth登录就够了。HTTPS解决的是传输过程中的加密与证书校验,它能让中间人无法直接看到明文,但并不能证明请求只被发送一次。越狱手机上可以安装自签名证书完成抓包,攻击者拿到完整请求后,原封不动地再次提交,就是一次重放攻击。更隐蔽的是,如果攻击者同时修改请求体中的 user_id,而接口没有签名校验,就能把优惠码权益核销到指定账号。也就是说,HTTPS只是传输管道,不能替代业务层的请求合法性校验。
IAP订阅优惠码通常来自苹果后台或活动系统。服务端收到兑换请求后,一般会先去校验优惠码是否有效、是否已经使用,再给当前用户开通订阅。这个流程里,code 和 user_id 是核心参数。如果攻击者抓到一个 user_id=10001 的合法兑换请求,把 user_id 改成 10002 再重放,服务端如果只按 code 判断,就会把权益发给错误账号。即使接口要求登录态,攻击者也可以登录自己的账号,把抓到的 code 绑定到自己名下。这样一来,真实用户的优惠码被冒用,库存被消耗。
要同时解决重放和篡改,必须引入四类机制:时间戳限制请求有效期,随机数保证一次一密,签名保证参数不可修改,加密保护 code 等敏感字段。它们不是各自独立的,而是需要按顺序组合。下面的实现会围绕一个核心原则:先验证请求完整性和时效性,再做业务核销,避免任何非法请求触达苹果服务或权益系统。
时间戳与随机数:一次有效的请求窗口
时间戳的作用是给请求划定一个非常短的生命周期。客户端在发起请求时附加 timestamp 参数,服务端收到后与当前时间比较,如果差的绝对值超过允许偏差,例如300秒,就拒绝。这个时间窗口既要兼容客户端和服务端的时钟偏差,也要足够短,避免攻击者把抓到的包保存几天后再用。时间戳本身不防重放,只缩小了重放可用的时间范围。
真正防止同一条请求被重复提交的是随机数 nonce。客户端每次生成一个密码学安全的随机字符串,服务端在时间戳窗口内记录并使用一次,第二次出现直接拒绝。实现上可以用 Redis 的 SET key value NX EX 命令,key 中带上 nonce 或 nonce 加业务标识,NX 保证只有第一次写入成功,EX 设置过期时间与时间戳窗口一致。这样即使攻击者在窗口期内重放,第二次写入会失败,服务端返回重复请求错误。nonce 不能用自增数字或用户ID代替,必须使用足够长的随机值,例如 UUID v4 或 32 字节随机数。
import time
import redis
ALLOWED_SKEW_SECONDS = 300
def validate_timestamp_and_nonce(ts, nonce, r: redis.Redis):
try:
request_time = int(ts)
except (TypeError, ValueError):
return False, "invalid timestamp"
now = int(time.time())
if abs(now - request_time) > ALLOWED_SKEW_SECONDS:
return False, "timestamp expired"
key = f"iap:nonce:{nonce}"
if not r.set(key, "1", nx=True, ex=600):
return False, "duplicate request"
return True, "ok"
Go、Java、Node 等服务端的实现思路相同,核心就是原子写入和过期时间。需要注意的是,如果业务存在重试机制,客户端每次重试都应该生成新的 nonce,服务端不要把 nonce 当作业务幂等键;真正的订单幂等建议用独立的 order_id 或兑换单号。另一个容易踩坑的点是 Redis 主从切换导致写入丢失,如果对一致性要求极高,可以使用 Redis Lua 或数据库唯一约束来兜底。
签名与加密:让参数改不动、敏感字段看不懂
签名解决参数被篡改的问题。客户端和服务端共享一个密钥,客户端把请求中需要参与签名的字段按规则排序并拼接,使用 HMAC-SHA256 计算摘要,服务端收到后按同样规则重新计算,比对是否一致。只要攻击者不知道密钥,修改 user_id、code 或时间戳中的任何一个字符,都会导致签名不匹配。排序可以按 key 的字典序,避免两端拼接顺序不一致。这里给一个 Python 的签名函数示例。
import hmac
import hashlib
def sign_params(params: dict, secret: str) -> str:
items = sorted(
(k, v) for k, v in params.items() if k != "sign"
)
canonical = "&".join(f"{k}={v}" for k, v in items)
return hmac.new(
secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
签名可以防篡改,但 code 等字段在 HTTPS 下虽然不可见,可如果攻击者通过抓包工具或逆向拿到明文,仍然可能结合其他漏洞发起攻击。因此对优惠码本身再做一层加密是有必要的。推荐使用带认证的对称加密 AES-GCM,它在加密的同时提供完整性校验,能防止密文被篡改。密钥长度使用 256 位,随机生成的 12 字节 nonce 每次不同,写入密文头部一起传输。服务端收到后先验签,再解密;如果先解密后验签,可能暴露解密失败信息,也增加被选择密文攻击的风险。
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
def encrypt_payload(plaintext: bytes, key: bytes) -> bytes:
nonce = os.urandom(12)
cipher = AESGCM(key)
ct = cipher.encrypt(nonce, plaintext, None)
return nonce + ct
def decrypt_payload(payload: bytes, key: bytes) -> bytes:
nonce = payload[:12]
ct = payload[12:]
cipher = AESGCM(key)
return cipher.decrypt(nonce, ct, None)
客户端侧如果需要加密,可以在原生代码中完成,不建议把密钥硬编码在字符串常量里,否则逆向应用后很容易被提取。可以采用白盒加密、密钥协商或把加密能力下沉到服务端代理。服务端侧密钥应放在 KMS、环境变量或配置中心,定期轮换。签名密钥和加密密钥最好分开管理,避免一个泄露后同时失去防篡改和机密性能力。
完整校验顺序与生产细节
一个稳健的兑换接口,收到请求后建议按以下顺序处理:基础身份校验、签名校验、时间戳校验、nonce 去重、解密敏感字段、优惠码业务校验、调用苹果服务端确认、原子发放权益。签名和时间戳的校验放在解密前,可以先把大部分伪造请求挡在外面。nonce 去重放在签名后,避免无效请求占用 Redis 空间。优惠码业务校验必须与发货动作放在同一个数据库事务中,或者用状态机保证不会重复发货。
下面用一个表格汇总四种机制的作用和实现要点。
| 机制 | 主要作用 | 实现方式 | 注意事项 |
|---|---|---|---|
| 时间戳 | 限制请求有效期 | 比较服务端当前时间与请求时间 | 设置合理偏差,如300秒 |
| 随机数 | 防止同一请求重放 | Redis SET NX EX 记录 nonce | 使用密码学随机值,过期时间覆盖窗口 |
| 签名 | 防止参数篡改 | HMAC-SHA256 对排序后的参数签名 | 密钥分离,客户端混淆 |
| 加密 | 保护敏感字段 | AES-GCM 加密 code 或整个 payload | 先验签后解密,密钥定期轮换 |
错误码设计上要避免向客户端泄露过多信息。比如时间戳过期、nonce 重复、签名错误,都可以统一返回参数无效或请求已过期,不要精确到具体哪个字段出了问题,否则攻击者可以通过调整参数探测校验逻辑。对于真实用户的重试,建议客户端重新生成请求参数并提示稍后再试。服务端同时要记录每次拒绝的原因到日志,方便后续分析攻击特征。
最后提醒两个容易被忽略的点。第一,IAP 订阅优惠码的最终核销可能依赖苹果 App Store Server API,苹果侧也有自己的限流和鉴权,服务端在调用前需要先完成本地请求校验,不要把攻击流量转嫁给苹果。第二,优惠码库存通常有限,活动期间并发可能较高,nonce 去重和订单状态更新需要使用原子操作。只要时间戳、nonce、签名、加密这四层按顺序落地,这个接口就能从只能依赖 HTTPS 的普通接口,升级为具备明确防重放和防篡改能力的资产接口。