独立开发 · 文章
卡测试攻击事件复盘:一条静态支付链接,让我所有产品支付账户被暂停。
先说结论:如果你的网站上放着支付平台给的"永久付款链接",它就是一个任何人都能无限次调用的刷卡接口。我的所有网站因此被卡测试攻击,支付账户被暂停了一段时间。这篇文章记录发生了什么、为什么会这样,以及我后来怎么改的。
一、发生了什么
我做的几个小产品,收款方式都很"省事":在支付平台后台建好商品,拿到平台生成的静态付款链接,把它贴到网站的"购买"按钮上。没有后端,没有验证,没有限流,网站前面只有 CDN。
这种做法在没有人盯上你的时候没有任何问题。直到某一天:
支付后台出现大量失败的小额交易;
失败原因集中在"卡被拒""CVC 错误""卡号无效";
随后支付账户被平台暂停,需要申诉、补材料,收款停了一段时间。
下图是当时被攻击期间的部分邮件截图。

下图是支付平台导出的支付数据的报表情况

更多详细的情况可以查看我当时在X发布的帖子
二、什么是卡测试(Card Testing)
卡测试是一种欺诈手法。攻击者手里有一批从别处窃取的银行卡信息,但不知道哪些卡还能用。他们需要找一个"验证器":
找到一个能提交卡信息的付款页面;
用脚本批量提交,通常金额很小(1 美元甚至更低);
看返回结果,成功的卡标记为"活卡",拿去别处大额盗刷。
对攻击者来说,你的网站只是个免费的验卡工具。他们不买你的东西,也不在乎你是谁。
对你来说,后果是:
大量失败交易会拉高你的拒付率和欺诈率,支付平台和卡组织对此非常敏感;
部分成功的测试交易会在几周后变成拒付(chargeback),每笔都有罚金;
平台风控触发,账户被限制或暂停;
你什么都没卖出去。
三、为什么静态付款链接特别危险
很多支付平台的商品都有一个永久地址,形如:
https://checkout.某平台.com/buy/商品编号它有几个致命特点:
不经过你的服务器。 请求直接打到支付平台,你前面的 Cloudflare、WAF、限流全都看不到。你想防也无从下手。
可以无限次打开。 每次打开都是一个新的付款页面,可以反复提交卡信息。
商品编号是公开的。 它写在网页源码里、按钮链接里、甚至分享出去的聊天记录里。任何人拿到就能脚本化攻击。
没有人机验证。 没有人在中间问一句"你是人吗"。
也就是说,我当时的防护是零。这不是"防护不够",是"没有防护"。
四、修复建议,不要公开任何付款链接
核心思路只有一句话:网站上不放付款链接,每次购买由服务端临时生成一次性的收银台。
我的网站托管在 Cloudflare Pages,后端用 Pages Functions,支付平台是 Dodo Payments。这里分享一下我改造后的流程:
用户点击"购买"按钮;
页面加载 Cloudflare Turnstile(人机验证,动作名
checkout),拿到一次性令牌;浏览器向
/api/checkout发一个 POST,里面只有令牌和档位(1/2/3 台设备);服务端依次检查:配置是否齐全、请求来源、请求体大小、IP 频率、全站配额、Turnstile 令牌;
全部通过后,服务端用保存在服务端的密钥,向支付平台申请一个一次性的收银台会话;
服务端确认返回的地址确实属于支付平台的域名,再交给浏览器跳转。
记得在dodo后台开启3D Secure,这个很重要。

商品编号、API 密钥、Turnstile 密钥,全部只存在于 Pages 的服务端环境变量里。前端构建产物里只有 Turnstile 的站点密钥,它本来就是公开的。
1. 校验来源
只接受来自自己域名的请求:
const ORIGINS = new Set(["https://example.com", "https://www.example.com"])
const origin = request.headers.get("origin")
if (!origin || !ORIGINS.has(origin)) return json({ code: "origin" }, 403)这挡不住有心人(Origin 头可以伪造),但能过滤掉一大批随手写的脚本和别人站点的盗用。
2. 校验 Turnstile 令牌
令牌必须在服务端核对,光在前端渲染控件没有任何意义:
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
})
const data = await response.json()
// success 为真,且 hostname 是自己的域名,且 action 是 checkout几个细节:
令牌是一次性的,所以每次购买都要重新渲染一个控件;
要核对返回的
hostname和action,防止别的站点拿自己的令牌来用;令牌长度要限制,别让它成为一个攻击面。
3. 分层限流
我用 Cloudflare KV 做计数,IP 先做 SHA-256 再存,日志里不留原始 IP:
维度 | 限度 | 何时计数 |
|---|---|---|
单 IP 尝试次数 | 每小时 30 次 | 验证令牌之前,失败的试探也算 |
全站签发数量 | 每小时 15 个、每天 50 个 | 成功签发之后 |
单 IP 成功签发 | 每小时 8 个、每天 20 个 | 验证通过之后 |
有一个小设计:尝试次数在验证令牌之前就计数。这样攻击者拿一堆假令牌来试探,也会消耗自己的额度,而不是白白消耗我的验证请求。
4. 限制请求可以改什么
接口不接受任意商品、任意数量、任意折扣码。请求里唯一能选的是档位 1/2/3,而且必须在服务端白名单里,数量固定为 1。折扣码在支付平台的收银台里输入,不经过我的接口。
5. 对支付平台返回的地址再做校验
服务端只在返回地址是 https 且主机名等于支付平台收银台域名时才放行,浏览器端跳转前再检查一次。避免任何中间环节被篡改后变成开放重定向。
6. 打开强认证
创建收银台时开启 force_3ds,强制 3D Secure。这会让部分用户多一步验证,但对卡测试来说是很实在的障碍:攻击者拿到的往往只是卡号、有效期和 CVC,过不了 3DS。
五、一个容易忽略的坑:更换新的支付之后,记得归档旧的产品
不要以为做了上述的防护措施就一了百了,高枕无忧,如果你新的支付方式用的产品ID还是之前被攻击的哪些产品的ID,那么你上述的这些防护措施的作用等于0,因为只要商品编号泄露过,用 API 密钥照样能为它开收银台。对方依旧可以拼接出来完整的支付链接,所以很重要的一点,先把旧产品进行复制,然后将旧产品直接归档。
千万别为了走捷径,贪图快捷而复用旧产品的产品ID。
连桌面 App 也要注意:安装包里的 Info.plist 是公开的。我的做法是 App 里只放商品编号的 SHA-256 摘要,激活时对平台返回的编号取同样的摘要再比较,"购买"按钮统一指向官网价格页。
六、这套方案的局限
我不想把它写成银弹:
计数不是原子的。 KV 读后写,而且跨节点同步大约要一分钟,所以它限制的是"试探成本",不是精确配额。
全站配额是双刃剑。 每小时 15 个的全局上限,能把攻击造成的损失压到很小,但攻击者也可以故意把额度打满,让真实用户暂时买不了。对小产品来说我接受这个取舍,流量大了需要换成更精细的方案(Durable Objects 做原子计数,或按风险分级)。
Origin 校验能被伪造。 它只是第一道筛子。
Turnstile 不是万能的。 有真人打码或高质量自动化时,仍然可能被绕过,所以后面还需要限流和 3DS 兜底。
安全是叠加的,每一层都不完美,但叠在一起能把攻击的成本抬到不划算。卡测试攻击者追求的是"便宜、批量",只要你不再是最便宜的那个目标,他们就会去找下一个。
七、给独立开发者的检查清单
上线收款之前,过一遍:
[ ] 网站上有没有永久付款链接?有就换成服务端按需创建的一次性收银台;
[ ] 创建收银台的接口有没有人机验证,并且在服务端校验了令牌?
[ ] 有没有按 IP 和全站两个维度限流?
[ ] 接口能不能被用来修改价格、数量、商品?应该全部是服务端决定;
[ ] 是否开启了 3DS 或平台提供的风控规则?
[ ] 商品编号、API 密钥是否只存在于服务端环境变量?
[ ] 是否给支付平台设了告警:失败交易数量突增时第一时间通知你?
[ ] 日志里是否记录了拦截原因(来源、国家、ASN),方便事后分析?
[ ] 公开的东西里(源码、安装包、文档、聊天记录)有没有泄露商品编号?
八、写在最后
这次事故里最让我难受的不是技术难度,而是它太容易避免了:几十行服务端代码、一个免费的 Turnstile、一点限流,就能把"零防护"变成"有防护"。我之所以没做,只是因为"支付平台给了链接,贴上去就能收钱",而且一直没出事。
没出事不代表安全,只代表还没被扫到。互联网上有大量脚本在持续扫描任何能提交卡信息的页面,小站也不例外。
如果你现在的网站上也贴着一条静态付款链接,建议今天就改。
同时也非常感谢dodopayments这个支付平台的帮助和支持,在攻击的高峰期,为了我的账户安全暂时停用,事后经过几次邮件沟通以及dodo中国区的工作人员的协助,这件事得到了圆满的解决。
版权声明
本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。