某电商平台用户账单被篡改:Ajax异步请求绕过权限校验是如何发生的
一场“静悄悄”的账单魔术
你有没有遇到过这种场景:明明昨晚刚付完款,今天打开订单中心,账单金额却变成了另一串数字;或者客服说“我们系统里没有这笔流水”,但你的短信扣款提醒还热乎着。听起来像幻觉,但在真实的电商架构里,这往往不是系统抽风,而是前端请求被“夹带私货”了。
很多团队在做前后端分离时,会习惯性地把业务参数直接塞进 Ajax 请求里:订单号、商品ID、金额、优惠券码,甚至用户的收货地址。请求发出去以后,页面不刷新、用户体验很丝滑,但问题也藏在这里——浏览器端的每一次网络请求,本质上都是一封任何人都能拆开、涂改、重新寄出的信。如果后端没有把“你是谁”和“你能动什么”核对清楚,攻击者只需要在请求到达服务器之前,把 billId 换成别人的,或者把 amount 改成 0.01,账单就被悄悄替换了。
这不是什么高深的黑客电影桥段,而是安全圈里非常经典的 IDOR(不安全的直接对象引用) 加上 客户端信任越界。下面我们把整个链路拆碎,看看它到底怎么发生的,普通人怎么提前嗅到味道,前端同学又该把哪些锁装上去。
攻击者到底动了哪一行代码?
先还原一个典型的“省事写法”。前端为了快速上线,账单查询和修改接口大概长这样:
// 前端:账单详情获取
async function fetchBill(billId) {
const res = await fetch(`/api/bill/detail?id=${billId}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${userToken}` }
});
return res.json();
}
// 前端:账单金额修正(常见于退款/补差价场景)
async function updateBillAmount(billId, newAmount) {
const res = await fetch('/api/bill/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`
},
body: JSON.stringify({ billId, amount: newAmount })
});
return res.json();
}
看起来挺正常对吧?请求带了 Token,后端应该能认出来是谁在操作。但问题出在后端接口的处理逻辑上:
# 后端伪代码:常见漏洞写法
@app.post("/api/bill/update")
def update_bill(request):
token = request.headers.get("Authorization").split(" ")[1]
user_id = decode_jwt(token)["uid"]
data = request.json
bill_id = data["billId"]
amount = data["amount"]
# ❌ 只校验了登录态,没有校验账单归属权
bill = db.query("SELECT * FROM bills WHERE id = ?", bill_id)
bill.amount = amount
bill.save()
return {"code": 200, "msg": "success"}
注意这行注释:只校验了登录态,没有校验账单归属权。也就是说,只要攻击者知道某个 billId,哪怕他用的是自己的账号 Token,也能把别人的账单金额改掉。更糟糕的是,如果前端把 userId 也放在请求体里,比如 body: { billId, userId: 10086, amount: 0.01 },那后端如果直接用前端传的 userId 做查询,等于把刀递给了攻击者。
用小朋友能听懂的话解释
想象学校小卖部有一本记账本,上面写着“张三借了5元”。老师让张三自己写一张纸条:“请把这本账改成我借了1元”。如果售货员只看纸条上有张三的名字,就照改了,那李四的账也会被随便涂。正确的做法是:售货员得先翻开账本,确认“这笔账本来就是张三的”,才能改。前端传过来的只是“申请”,后端才是“裁判”。
从拦截到篡改,黑盒里发生了什么?
Ajax 请求之所以容易被绕过,核心在于它是异步的、无页面跳转的、可被代理工具截获的。攻击者不需要懂你的数据库,只需要一台电脑、一个抓包工具,就能完成整套动作。
常用工具链
| 工具 | 适用场景 | 特点 |
|---|---|---|
| Chrome DevTools → Network | 本机调试 | 免费,能看到所有 XHR/Fetch |
| Fiddler / Charles | 手机App抓包 | 需要安装CA证书,支持断点修改 |
| mitmproxy | 命令行自动化 | 适合批量重放、脚本化攻击 |
| Burp Suite | 专业渗透 | 功能最全,社区版免费 |
一次典型的篡改流程
- 攻击者用自己的账号登录平台,打开 DevTools → Network → 勾选
Fetch/XHR。 - 触发账单页面加载,找到类似
/api/bill/detail?id=78291的请求。 - 复制请求,用 Burp 的
Repeater或Proxy修改参数: “`http POST /api/bill/update HTTP/1.1 Host: shop.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIs… Content-Type: application/json
{“billId”:“78291”,“amount”:0.01,“reason”:“系统补差”}
4. 发送后,观察响应。如果返回 `{"code":200,"msg":"success"}`,篡改成功。
5. 如果接口有频率限制,攻击者会用 `curl` 或 Python 脚本批量重放,配合代理池绕过快消控。
### HTTPS 是不是就安全了?
很多人以为加密了就不会被抓包。实际上,HTTPS 防的是**网络线路上的窃听**,但不防**终端侧的篡改**。只要用户在设备上安装了自定义根证书(企业内网、校园网、甚至某些“加速助手”都会诱导安装),或者 App 没有做证书锁定(Certificate Pinning),抓包工具照样能解密流量。所以,**信任前端传来的数据,本质上就是信任用户自己的设备**,这在安全设计里是绝对不允许的。
---
## 普通用户怎么提前嗅到危险?
你不需要会写代码,也能发现一些不对劲的信号。账单被篡改往往不是瞬间完成的,它会留下几个“违和感”:
### 1. 金额对得上,但流水号对不上
支付宝/微信扣款短信里会有交易单号,平台订单中心里的账单编号如果和银行流水不一致,大概率是展示层被伪造,或者后端查错了库。
### 2. 同一账号换个设备,账单不一样
如果手机上看是 199 元,电脑网页版却是 99 元,说明服务端没有做跨端数据一致性校验,或者前端缓存了旧数据没同步。
### 3. 页面加载极快,但数据像是“本地算出来的”
有些篡改后的页面会优先读 localStorage 或 Cookie 里的缓存,网络请求根本没走远。你可以按 `F12` → `Network` → 刷新页面,看有没有对应的 `XHR` 请求。如果列表瞬间渲染完成且没有网络活动,就要警惕本地数据被污染。
### 4. 客服查不到,但你自己看得到
最直接的判断方式:截图保存,然后让客服在后台系统里调原始流水。如果后台没有这笔记录,说明前端展示的是被篡改或伪造的数据。
### 5. 收到“内部渠道改价”“代刷账单”的广告
黑产经常拿这类漏洞做包装,宣称可以“低价改订单”“内部补差价”。遇到这种话术,直接拉黑。它们要么是利用 IDOR 批量扫接口,要么是钓鱼骗你先转账。
### 普通人能做的三件小事
- 开启**支付短信/APP推送双重通知**,金额变动第一时间知道。
- 定期检查**授权设备列表**,踢掉不认识的登录态。
- 不要在公共 WiFi 下操作账单、付款、修改收货地址;必须用时,切回手机 4G/5G。
---
## 前端工程师的防御清单(不是背锅,是兜底)
前端的安全职责不是“挡住黑客”,而是**减少攻击面、增加篡改成本、让异常请求能被看见**。下面是实际项目里值得落地的配置和代码模式。
### 1. 请求体不要传“裁判该管的事”
金额、商品原价、优惠后价格、用户ID,这些都应该由后端计算。前端只负责展示和传递“意图”。
```javascript
// ❌ 错误:把金额交给前端决定
body: JSON.stringify({ billId, amount: 0.01 })
// ✅ 正确:只传操作类型和凭证
body: JSON.stringify({
billId: "78291",
action: "request_refund",
reason: "未收到货"
})
2. 给关键请求加签名,防篡改和重放
单纯靠 Token 不够,因为 Token 泄露后攻击者可以无限复用。加一个基于时间戳 + 随机数 + 密钥的签名,能让旧请求失效。
function buildSecurePayload(payload) {
const nonce = crypto.randomUUID();
const timestamp = Date.now();
// 实际项目中 nonce + timestamp + payload 应走 HMAC-SHA256
const signature = btoa(`${nonce}:${timestamp}:${JSON.stringify(payload)}`).slice(0, 32);
return {
...payload,
_meta: { nonce, timestamp, signature }
};
}
async function updateBill(payload) {
const securePayload = buildSecurePayload(payload);
const res = await fetch('/api/bill/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`,
'X-Request-Signature': securePayload._meta.signature
},
body: JSON.stringify(securePayload)
});
return res.json();
}
后端收到后需要校验:
Date.now() - timestamp > 5分钟→ 拒绝(防重放)nonce是否已出现过 → 拒绝(防重放)signature是否与本地计算一致 → 拒绝(防篡改)
3. CORS 必须收紧,别让无关域名“偷看”你的接口
很多团队为了调试方便,会把 Access-Control-Allow-Origin 写成 *。这在生产环境等于告诉全世界:“我的接口谁都能调”。
# Nginx 示例:只允许自家域名跨域
add_header Access-Control-Allow-Origin "https://shop.example.com";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-Signature";
add_header Access-Control-Allow-Credentials "true";
如果用了 Node.js 中间件:
const cors = require('cors');
app.use(cors({
origin: ['https://shop.example.com', 'https://admin.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Signature']
}));
4. 防 CSRF:别让已登录的浏览器“被利用”
如果用户已经登录,恶意网站可以偷偷发起请求。前端需要配合后端做 CSRF Token 校验。
<!-- 页面初始化时注入一次性 Token -->
<meta name="csrf-token" content="{{ csrfToken }}">
async function updateBill(payload) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const res = await fetch('/api/bill/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
return res.json();
}
后端收到后必须校验 Token 是否匹配当前会话,不匹配直接 403。
5. 敏感接口加二次确认和风控埋点
账单修改、退款、余额调整这类操作,前端不要默默发请求。加一个弹窗、短信验证码、或人脸/指纹校验,能拦住绝大多数自动化攻击。
async function confirmBillUpdate(billId) {
const verified = await requestSMSCode(billId); // 触发短信验证
if (!verified) {
showToast('验证失败,请检查手机号');
return;
}
// 验证通过后,把验证码作为二次凭证传给后端
const code = prompt('请输入短信验证码');
const res = await fetch('/api/bill/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`,
'X-Verify-Code': code
},
body: JSON.stringify({ billId, action: 'confirm_update' })
});
if (res.status === 403) {
showToast('操作风险过高,已拦截');
}
}
6. 安全响应头别偷懒
这些头不会阻止 IDOR,但能大幅降低 XSS、点击劫持、中间人攻击的成功率,间接保护 Ajax 请求不被注入篡改脚本。
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.example.com; object-src 'none'; frame-ancestors 'none';
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
7. 前端监控:让异常请求自己“举手”
在请求拦截器里埋一点日志,不用上报完整内容,只记特征:
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 403 || error.response?.status === 401) {
reportSecurityEvent({
endpoint: error.config.url,
method: error.config.method,
timestamp: Date.now(),
userId: getCurrentUserId(),
hint: 'permission_denied'
});
}
return Promise.reject(error);
}
);
后端同样要做:连续 3 次 403 同一个接口,自动冻结该 Token 并通知安全团队。
记住一件事:前端永远不能当裁判
写到这里,我必须说一句可能让部分团队不舒服的话:权限校验的最后一道防线,必须在后端。前端做签名、CORS、CSRF、二次验证,都是“增加攻击成本”和“减少误操作”,但真正决定账单能不能被改的,是后端那句:
-- ✅ 正确写法:强制关联当前登录用户
UPDATE bills SET amount = ?, updated_at = NOW()
WHERE id = ? AND user_id = ?;
如果后端用 WHERE id = ? 而不带 user_id = ?,前面前端做再多花活,都是在给攻击者铺路。安全不是单点突破,是纵深防御。前端把门闩装好,后端把保险柜锁死,监控把警报接上,这三件事缺一不可。
最后聊点实在的
如果你是普通用户,看到账单数字不对,第一反应不是骂平台,而是截图、保留扣款短信、联系官方客服调原始流水。现在正规平台的支付链路都有第三方清算数据,只要钱真的扣了,银行/支付宝/微信那边一定有记录,平台前端展示可以伪造,但清算通道很难造假。
如果你是前端工程师,别把安全配置当成“上线前多此一举的表单”。CORS 收紧、请求签名、CSRF Token、安全响应头、异常监控,这些不是文档里抄来的装饰,而是你每天在跟真实世界的自动化脚本、爬虫、黑产脚本打交道时,能挡掉 80% 低级攻击的护甲。真正让人头疼的高级漏洞,往往就藏在“这里应该没问题”“反正有 Token 校验”“测试环境没测过”这几句话里。
账单是用户对平台信任的具象化。它不该被一行 id=${billId} 轻易改写。把该装的锁装好,把该看的日志看牢,这件事本身就值得认真对待。