ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

抓包改价包拆解:服务端金额校验与防篡改回归

抓包改价包拆解:服务端金额校验与防篡改回归

简介:面向希望了解移动端抓包与请求调试的初学者及测试人员,这份Fiddler专题资料包围绕商品价格请求的抓包分析与修改演示展开,配套工具、视频教程和文档说明,可用于本地实验环境中的抓包流程学习与问题排查。压缩包共52个文件,整体约10.62MB,包含exe与dll工具程序、txt配置与说明、wav音频素材、dat数据文件以及少量js、config、ico和png等辅助文件,覆盖工具运行、示例素材与教程文档等用途。目前已有9964人学习下载,热度较高。读者可据此了解Fiddler会话捕获、请求参数观察和模拟器配合调试等环节,并通过教程录屏与文字材料对照理解工具配置、数据定位和结果验证思路,适合作为抓包入门练习与调试排错的参考。

1. 抓包改价包拆解:先看服务端金额校验,再谈工具怎么用

预发环境里把订单请求里的金额从 199 元改成 1 元,接口返回 200,订单详情却还是 199 元——这种“客户端改了、服务端没认”的场面,是抓包调试里最典型的一课。抓包改价包不是一把万能钥匙,它本质上是抓包工具配置、证书安装说明、请求重放脚本和一组金额校验用例的合集。它解决的是:在自有系统、授权测试环境里,把请求拦下来、改字段、看服务端是否重算金额、是否校验签名、是否留下审计日志。适合后端开发、测试和安全审计人员做支付链路回归;不适合拿去碰第三方真实交易。先记住一句话:能不能改成功,取决于服务端把不把客户端金额当可信输入,而不是抓包工具多强。

2. 压缩包落地:监听端口、证书信任链与订单请求定位

2.1 环境隔离与监听端口

拿到压缩包后先别急着开抓包。第一件事是把测试边界划出来:只连本地或预发测试域名,只使用测试账号和沙箱商品数据,抓包工具只监听本机回环地址。常见做法是给测试域名配一条 hosts 记录,例如把order.test.local指到本地 mock 服务或测试网关,再把抓包工具的监听地址设为127.0.0.1、端口设为8080。这样做的原因是:订单请求里往往带登录态和业务标识,直接对真实交易链路抓包,既容易误伤线上数据,也会把敏感头写进 HAR 文件。

压缩包里的工具配置一般会包含监听地址、端口、过滤规则和脱敏头列表。下面这份配置可以直接照着改,重点是只抓目标域名和订单路径,不把整机流量都收进来。

listen: host: 127.0.0.1 port: 8080 capture: include_hosts: - order.test.local include_paths: - /api/order/* redact_headers: - authorization - cookie export: format: har output: ./capture/order.har

这段配置里,listen.port是抓包工具在本机开的监听端口,工具界面里填同一个值即可;include_hosts和include_paths是白名单,避免把埋点、静态资源、第三方脚本一起抓进来;redact_headers决定导出 HAR 时哪些头要打码,authorization和cookie必须列进去。我一般还会把x-request-id保留下来,后面查日志时能靠它把一次请求串起来。导出格式选 HAR,是因为它既能被浏览器开发者工具打开,也能被 Python 脚本解析,比截图和手抄参数可靠得多。

环境隔离还要注意两件事。第一,测试账号不要复用个人账号,商品 SKU 用沙箱数据,订单金额用分做单位,避免出现199和19900混用。第二,抓包结束后把工具监听关掉,确认系统转发设置已经恢复,否则第二天本地服务连不上,排查半天才发现是端口还占着。这个动作很小,但血泪经验是:抓包环境不干净,后面所有结论都不可信。

2.2 证书安装与 HTTPS 解密

订单接口基本都是 HTTPS,抓包工具要解密,就得让客户端信任它生成的根证书。压缩包里的教程通常会写“安装证书”,但真正容易翻车的是信任链位置:桌面端要装到系统受信任根证书区,移动端测试包要显式允许用户证书,发布包不要带这个配置。先在命令行确认目标测试域名握手正常,再去看抓包工具里的请求是不是明文。

openssl s_client -connect order.test.local:443 -servername order.test.local -showcerts </dev/null 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates

这条命令只做一件事:打印测试域名当前证书的主题、签发者和有效期。-servername不能省,否则多域名共用入口时可能拿到默认证书;-showcerts把证书链带出来,方便判断是自签证书还是测试网关证书。如果这里就报证书过期或域名不匹配,先修测试环境,不要急着怀疑抓包工具。证书链没问题,再导入抓包工具根证书,导入后重启客户端,让新信任链生效。

移动端自有测试包常见做法是通过network_security_config.xml只在 debug 构建里信任用户证书,release 构建保持默认。下面这段只适合测试包,不能带进发布包。

<network-security-config> <debug-overrides> <trust-anchors> <certificates src="user" /> <certificates src="system" /> </trust-anchors> </debug-overrides> </network-security-config>

debug-overrides表示只在 debuggable 构建生效,src="user"是允许用户安装的证书,src="system"保留系统根证书。这样测试机上装完抓包工具根证书就能解密,正式包不会继承这个信任。注意:只对自有测试应用这样做,第三方应用不要动证书信任配置;测试结束把用户证书移除,避免测试机长期处在宽松信任状态。

2.3 过滤与定位订单请求

抓包工具一开,请求列表会刷得很快,真正要看的只有下单、改单、支付前校验这几类请求。我的习惯是先按 Host 和 Path 过滤,再看 Method 和状态码,最后才看请求体字段。订单请求里常见字段包括amount、total、price、quantity、sign、timestamp、nonce,但不同系统命名不一样,所以不要一上来就全局搜amount,先把候选请求缩小到/api/order/*。

压缩包里的 HAR 可以直接用脚本过一遍,输出候选请求和命中的金额字段。下面这段只读 HAR,不修改任何请求,适合先做侦察。

import json import sys from urllib.parse import urlparse def load_har(path): with open(path, encoding="utf-8") as f: return json.load(f) def candidates(har): rows = [] for entry in har["log"]["entries"]: req = entry["request"] url = urlparse(req["url"]) if url.hostname != "order.test.local": continue if not url.path.startswith("/api/order/"): continue if req["method"] not in ("POST", "PUT", "PATCH"): continue body_text = req.get("postData", {}).get("text", "") fields = [] try: body = json.loads(body_text) if body_text else {} fields = [k for k in body if k in ( "amount", "total", "price", "quantity", "sign", "timestamp", "nonce" )] except json.JSONDecodeError: pass rows.append({ "method": req["method"], "url": req["url"], "status": entry["response"]["status"], "fields": fields }) return rows for row in candidates(load_har(sys.argv[1])): print(row)

脚本里的sys.argv[1]是 HAR 路径,运行方式就是python3 find_order.py capture/order.har。url.hostname限定测试域名,避免把其他环境请求混进来;url.path.startswith("/api/order/")是路径白名单;fields只打印命中的敏感字段名,不打印字段值,防止金额和签名进日志。输出里如果看到status是 200 但fields里没有金额,说明金额可能在表单、加密体或 header 里,下一步再查postData.mimeType和请求头。

定位到候选请求后,先复制一份原始请求做基线,再在测试环境改一个字段做对照。不要同时改金额、数量、签名和时间戳,否则返回 409 你都不知道是哪个校验拦的。常见做法是:第一轮只改amount,第二轮只改quantity,第三轮改amount并重算签名。每轮只动一个变量,结果才可解释。

注意:抓包和解密只用于自有系统或明确授权的测试环境,测试数据不要包含真实用户信息和真实交易凭证。

3. 改金额为什么翻车:四条金额信任边界与本地拦截实验

3.1 订单金额的四条信任边界

很多教程把“改金额”讲成抓包工具里点一下“打断点”、把amount改成 1、放行,然后看接口返回。这个演示只能说明请求发出去了,说明不了业务真的按 1 元成交。订单金额至少经过四条边界:客户端展示金额、下单请求里的金额、服务端重算金额、支付渠道回执金额。客户端展示金额只影响页面,下单请求金额是客户端可篡改的输入,服务端重算金额才是业务应收,支付渠道回执金额是最终对账依据。四条边界里只要有一条把客户端金额当真,就可能出现资损;反过来,服务端重算加签名覆盖加回执对账,改请求体基本都会在某一层被拦下来。

信任边界常见实现被篡改后的表现正确做法
客户端展示金额页面渲染、缓存页面显示 1 元,订单仍按原价只做展示,不作为下单依据
下单请求金额请求体amount接口 200,订单金额不变或变 1 元服务端按 SKU 和数量重算
服务端重算金额商品表、价格中心返回 409 或按重算价下单金额单位统一,重算结果落快照
支付渠道回执支付单、对账单回执金额与订单不符,挂起回执校验加差异告警

这张表里最容易被忽略的是“接口 200 但订单金额不变”。有些系统下单接口只做参数格式校验,真正落库是异步任务,异步任务又按商品表重算,所以抓包改金额看起来“成功”,实际订单没变。测试时不能只看接口状态码,要查订单快照、支付单、库存流水三处是否一致。还有一种情况是服务端把金额当字符串比较,"199"和199不相等,直接返回参数错误,这种属于实现粗糙,不是安全校验。

3.2 用本地 mock 服务验证篡改是否被拦截

要判断服务端有没有把客户端金额当可信输入,最直接的办法是搭一个本地订单服务,故意接收篡改金额,看它返回什么。下面这段用 Python 标准库起一个最小服务,商品价格写死在服务端,客户端传来的amount只用于比对,不用于计算应收。

from http.server import BaseHTTPRequestHandler, HTTPServer import json PRODUCTS = {"sku_1001": 19900} # 单位:分 class Handler(BaseHTTPRequestHandler): def _reply(self, status, payload): body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"{}" try: data = json.loads(raw) except json.JSONDecodeError: return self._reply(400, {"code": "BAD_JSON"}) sku = data.get("sku") client_amount = data.get("amount") server_amount = PRODUCTS.get(sku) if server_amount is None: return self._reply(404, {"code": "SKU_NOT_FOUND"}) if not isinstance(client_amount, int): return self._reply(400, {"code": "AMOUNT_TYPE_INVALID"}) if client_amount != server_amount: return self._reply(409, { "code": "AMOUNT_MISMATCH", "client_amount": client_amount, "server_amount": server_amount }) return self._reply(200, { "order_id": "ord_local_001", "sku": sku, "amount": server_amount }) if __name__ == "__main__": HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

运行python3 order_guard.py后,服务监听127.0.0.1:8000。PRODUCTS是服务端价格表,单位用分,避免浮点误差;client_amount只做整数类型和相等性校验;金额不一致返回 409,并在响应里带回服务端金额,方便测试断言。这里故意不用客户端金额计算订单,是因为真实系统里应收金额应该由服务端价格中心、优惠计算、运费规则共同决定,客户端传上来的值最多只能作为“用户预期金额”做提示。

用 curl 发一个篡改请求,观察返回:

curl -i -X POST http://127.0.0.1:8000/api/order \ -H 'Content-Type: application/json' \ -d '{"sku":"sku_1001","quantity":1,"amount":1}'

预期是HTTP/1.1 409,响应体里code为AMOUNT_MISMATCH,server_amount为19900。如果返回 200,说明这个 mock 服务缺少金额重算;如果返回 500,说明类型校验或异常处理没兜住。把amount改成19900再发一次,应该返回 200,这一步是基线,证明服务本身能正常下单。测试用例要同时覆盖 1、0、-1、超大值、字符串金额和缺字段,别只测一个 1。

3.3 重放、幂等与日志追踪

改金额测试经常伴随重放:同一个请求连续发两次,看会不会生成两笔订单。如果服务端没有幂等键,第二次请求可能又下一单,测试同学以为“改价成功”,实际是重复下单。正确做法是客户端生成Idempotency-Key,服务端用唯一索引或 Redis 键做去重,重复请求返回同一笔订单。

import os import uuid import requests base = os.environ["TEST_BASE_URL"] key = str(uuid.uuid4()) headers = { "Authorization": f"Bearer {os.environ['TEST_TOKEN']}", "Idempotency-Key": key, "Content-Type": "application/json" } payload = {"sku": "sku_1001", "quantity": 1, "amount": 19900} first = requests.post(f"{base}/api/order", headers=headers, json=payload, timeout=5) second = requests.post(f"{base}/api/order", headers=headers, json=payload, timeout=5) assert first.json()["order_id"] == second.json()["order_id"], "重复请求生成了不同订单"

TEST_BASE_URL只能指向本地或测试域名,TEST_TOKEN用测试账号令牌;Idempotency-Key每次测试用例生成一个,同一个用例的两次请求复用同一个键。断言比较order_id,相同才算幂等生效。如果服务端返回 409 表示“请求处理中”,也算合理,但要有明确状态码约定。日志里至少要记录request_id、client_amount、server_amount、decision、idempotency_key,这样出现金额差异时能直接定位是客户端篡改、服务端重算还是异步任务覆盖。

4. 授权测试复现:从 HAR 导出到断言脚本的闭环

4.1 测试用例设计与参数表

抓到订单请求后,不要随手改一个数字就结束。我一般先列用例矩阵,把“改什么、预期什么、证据放哪”写清楚,再跑脚本。这样即使换一个人复现,也知道每一步在验证哪条边界。

用例编号前置条件篡改字段预期结果证据
AMT-001测试 SKU 价格 19900 分amount=1409 或 400,订单不落库响应体、订单查询
AMT-002同上amount=-1400,参数非法响应体、审计日志
AMT-003同上amount=19900200,订单金额 19900订单快照
AMT-004同上amount为字符串400,类型错误响应体
AMT-005同上删除amount400,缺参响应体
AMT-006同一幂等键重放两次不改金额两次返回同一订单订单号对比

表格里的“预期结果”要写成可断言的状态码或字段,不要写“应该失败”这种模糊描述。AMT-001 到 AMT-005 都在验证服务端是否重算金额和校验类型,AMT-006 验证幂等。每跑完一轮,把响应体、订单号、日志片段放进同一个目录,文件名带用例编号,后面回归时直接对比。

4.2 抓包导出为可重放请求

HAR 里带 Cookie 和 Authorization,不能直接贴进脚本。常见做法是只保留content-type、accept、x-request-id这类必要头,令牌从环境变量注入,请求体原样保留但只用于测试域名。下面脚本从 HAR 里挑第一条订单 POST 请求,生成一个可读的重放模板。

import json import os import sys with open(sys.argv[1], encoding="utf-8") as f: har = json.load(f) target = None for entry in har["log"]["entries"]: req = entry["request"] if req["method"] == "POST" and "order.test.local" in req["url"]: target = req break if not target: raise SystemExit("没有找到订单请求") keep = {"content-type", "accept", "x-request-id"} headers = { h["name"]: h["value"] for h in target["headers"] if h["name"].lower() in keep } headers["Authorization"] = "Bearer " + os.environ["TEST_TOKEN"] body = target.get("postData", {}).get("text", "{}") print("import os, requests") print("headers = %r" % headers) print("payload = %s" % body) print("resp = requests.post(" "os.environ['TEST_BASE_URL'] + '/api/order', " "headers=headers, json=payload, timeout=5)") print("print(resp.status_code, resp.text)")

sys.argv[1]是 HAR 路径;keep是允许保留的头白名单,authorization和cookie不在里面,避免令牌进代码库;TEST_TOKEN和TEST_BASE_URL从环境变量读,测试脚本里不写死。生成的模板只指向测试域名,跑之前先确认TEST_BASE_URL不是生产地址。请求体里的金额字段可以保留,因为这是测试用例的一部分,但不要把真实用户订单导出到公共目录。

4.3 断言脚本与结果记录

重放模板只是把请求发出去,真正有价值的是断言。下面脚本把篡改金额请求打到本地或测试服务,断言服务端必须拦截,并把结果写进report.json。

import json import os import requests base = os.environ["TEST_BASE_URL"] token = os.environ["TEST_TOKEN"] headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", "X-Request-Id": "case-amt-001" } payload = {"sku": "sku_1001", "quantity": 1, "amount": 1} resp = requests.post(f"{base}/api/order", headers=headers, json=payload, timeout=5) assert resp.status_code in (400, 409), ( f"服务端没有拦截篡改金额: {resp.status_code} {resp.text}" ) report = { "case": "AMT-001", "status": resp.status_code, "body": resp.json(), "decision": "blocked" } with open("report.json", "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) print("拦截通过", resp.status_code)

base必须是本地或测试域名,token用测试账号;payload.amount是最小单位,1 表示 1 分,不是 1 元;断言只接受 400 或 409,200 一律视为漏洞。report.json里保留状态码和响应体,后面做回归时可以直接比对decision字段。脚本跑通后,把 AMT-001 到 AMT-006 串成一个循环,每个用例单独写报告,失败就退出非零状态码,方便接到 CI 里。

5. 避坑记录:证书、重放、幂等与日志的五次翻车

5.1 证书装了仍失败:信任位置不对

现象:抓包工具里目标域名一直握手失败,浏览器却能打开测试站;换一台机器又正常。原因通常不是证书没装,而是装错了位置:移动端应用只信任系统根证书,用户证书对发布包不生效;有的测试包还开了证书固定,只认指定签发者。解决:先确认是自有测试包还是第三方应用。自有测试包用 debug 构建的network-security-config只对测试环境放开用户证书,release 包不要带;证书固定场景换测试域名或测试签发者,别在生产包上动。第三方应用不要改信任配置,测试边界到此为止。

5.2 返回 200 但订单金额没变:异步重算覆盖了

现象:抓包改amount=1,下单接口返回 200,订单详情还是 199 元;再过几秒看支付单,金额也没变。原因可能是服务端同步接口只做参数接收,真正落库是异步任务,异步任务按商品表重算金额;也可能是订单缓存没刷新,或者查询走了只读库。解决:别只看下单接口,查订单快照、支付单、库存流水三处;在日志里搜request_id,看client_amount和server_amount是否都记录了;如果异步任务重算,测试用例应该断言最终订单金额等于服务端价格,而不是接口返回体。这个坑很容易让人误判“改价成功”,实际业务没受影响。

5.3 重放触发重复下单:缺幂等键

现象:同一个请求连发两次,生成两笔订单,库存扣了两次;测试同学以为第二次是改价成功。原因是没有Idempotency-Key,或者服务端收了键但没做唯一约束。解决:客户端每次下单生成一个键,同一个业务动作复用;服务端用数据库唯一索引或缓存占位,重复请求返回第一次的订单号;测试用例 AMT-006 专门验证两次请求返回同一order_id。注意:幂等键要跟用户和业务场景绑定,不能全局一个固定值,否则不同订单会被误判为重复。

5.4 移动端抓包不生效:网络栈和证书固定

现象:测试机 Wi-Fi 里填了监听地址,桌面端能抓到请求,移动端应用就是不走抓包工具;换一个老版本应用又能抓。原因可能是应用走了独立网络库、QUIC 或证书固定,也可能是测试包没有开启调试日志。解决:只在自有测试构建里处理,关闭 QUIC、统一网络库、打开请求日志;确认测试包信任用户证书;如果是第三方应用,不要继续尝试绕过,直接放弃。这个边界要写进测试说明,不然新人会在这上面耗一整天。

5.5 日志里找不到请求:过滤规则和直连

现象:抓包工具里能看到请求,服务端日志里搜不到request_id;或者反过来,日志有记录但 HAR 里没有。原因可能是抓包白名单把路径过滤掉了,也可能是客户端对本地地址走了直连,或者请求被重试到了另一个实例。解决:先把过滤规则临时放宽到只按 Host 过滤,确认请求能进来;再检查NO_PROXY类直连配置和测试域名解析;最后用x-request-id在网关日志、应用日志、异步任务日志里三段串联。不要只看一个日志文件,订单链路的请求经常跨服务。

6. 把抓包结果变成防御清单:三个校验点和一套回归习惯

6.1 三个校验点

抓包改价测试的终点不是“改成功了”,而是把结果变成防御清单。我现在会盯三个校验点:服务端是否按 SKU 和数量重算金额,签名是否覆盖金额和数量,幂等键和支付回执是否对得上。第一点决定客户端金额是不是可信输入,第二点决定请求体被改后签名会不会失效,第三点决定重放和异步回调会不会造成重复扣款。三个点都过,才敢说这条支付链路能扛住抓包篡改。

校验点实现位置失败响应回归断言
服务端重算下单服务、价格中心409AMOUNT_MISMATCH篡改金额必须非 200
签名覆盖网关、签名服务401 或 400SIGN_INVALID改金额后签名校验失败
幂等与回执订单库、支付回调重复请求返回同一单两次请求order_id相同

6.2 回归脚本落点

回归脚本不要写得太聪明,固定用例矩阵就够了。下面这段只做一件事:把金额边界值跑一遍,失败就退出。

import os import requests base = os.environ["TEST_BASE_URL"] token = os.environ["TEST_TOKEN"] cases = [ {"amount": 1, "expect": (400, 409)}, {"amount": -1, "expect": (400,)}, {"amount": 19900, "expect": (200,)}, ] for case in cases: resp = requests.post( f"{base}/api/order", headers={"Authorization": f"Bearer {token}"}, json={"sku": "sku_1001", "quantity": 1, "amount": case["amount"]}, timeout=5 ) assert resp.status_code in case["expect"], (case, resp.status_code, resp.text)

TEST_BASE_URL只指向测试环境,cases里每个金额都对应一个明确预期;断言失败时把用例、状态码和响应体一起打出来,排查不用猜。这个脚本我一般放在订单服务的tests/目录下,改支付相关代码就先跑一遍。

从那以后我每次动支付相关逻辑,都强制先跑一轮本地篡改回归:把金额改成 1、负数、超大值,看服务端是不是一律拒绝,再看日志里有没有留下client_amount和server_amount。抓包工具只是放大镜,真正要修的是服务端的信任边界。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进