黑产算的是收益,不是胜负
《泡泡工坊》上线三个月后,交易行里出现了一批价格稳定低于市场三成的材料。运营封了十几个账号,第二天又冒出新的。技术团队追踪后发现,黑产没有修改客户端,也没有攻击服务器,而是利用了一个合法接口:批量创建小号,完成新手任务领取材料,再通过交易行转移给主号。
这个案例说明,黑产的目标不是“赢过系统”,而是让单位时间的收益高于成本。封号只是成本的一项,账号注册、设备、时间、被追缴的风险都要算进去。只要收益仍然为正,封禁就只是生产损耗。因此,架构对抗的目标不是“彻底消灭黑产”,而是把三类边界划清楚:哪些数据必须由服务端权威计算,哪些接口必须限频,哪些奖励必须延迟发放。边界越清楚,黑产的可利用面越小,误伤也越少。
黑产目标与攻击面
不同资产对应不同的攻击手法,不能用一套规则包打天下:
| 目标 | 典型手法 | 当前边界 | 残余风险 |
|---|---|---|---|
| 金币 | 小号刷任务、脚本搬砖、低价转金 | 产出服务端结算,交易行抽税 | 多账号协同、跨服转移 |
| 账号 | 批量注册、盗号、租号 | 实名与设备风控 | 接码平台、模拟器农场 |
| 道具 | 复制、异常掉落、活动套利 | 掉落服务端权威,道具带唯一 ID | 交易链洗白、延迟发放被绕过 |
| 排名 | 代打、演员、外挂 | 对局结果服务端校验 | 高段位协同、跨区匹配 |
| 活动奖励 | 多小号领奖、脚本刷榜 | 领取资格与发放分离 | 资格快照时点、奖励延迟期 |
这张表的作用是给每条防线找位置。金币问题的核心是产出与转移,账号问题的核心是注册与身份,道具问题的核心是唯一性和交易链,排名问题的核心是对局校验,活动奖励的核心是资格判定时点。把资源投在错误的位置,会出现“封了很多号,经济还是通胀”的结果。还要区分单账号作弊和团伙套利。单账号作弊靠行为检测和客户端完整性;团伙套利靠社交图谱、设备关联和交易链分析。前者的处置单位是账号,后者往往要处置整个关联簇,否则封一个补一个。
三条硬边界
服务端权威:结果不能由客户端说了算
任何会影响资产、排名、进度结算的数据,必须由服务端计算或校验。客户端可以上报输入,不能上报结果。常见的错误包括:客户端计算伤害后上报、客户端决定掉落、客户端提交任务完成状态。服务端权威不是“所有逻辑放服务端”。表现层、输入预测、部分非关键动画可以放在客户端,以降低延迟;但结算入口必须在服务端。判断标准很简单:这个值能不能直接换成金币、道具、排名或活动奖励?能,就必须权威。
接口限频:让批量操作付出时间成本
限频的目的不是防住所有请求,而是让脚本的吞吐低于人工。频率阈值要按“玩家 + 接口 + 场景”维度设置,而不是全局限流。全球限流会让正常玩家在活动高峰被误伤,黑产却能换接口继续刷。限频还要有成本梯度:普通查询便宜,写操作贵,涉及资产的接口最贵。令牌桶适合这种场景,容量决定突发容忍,补充速率决定持续吞吐。被限流时返回明确的等待时间,客户端才能做退避,而不是盲目重试放大压力。
奖励延迟:把发放和资格判定分开
活动奖励、排名奖励、交易所得不应该在事件发生时立即到账。延迟发放给风控留出观察窗口,也给最终校验留出时间。延迟期內发现异常,可以冻结或作废;已经发放的奖励再追回,成本和舆情都高得多。延迟不是越长越好。延迟越长,玩家体验越差,客服解释成本越高。按资产价值分级:小额即时或短延迟,大额和可交易资产延迟更久。延迟期要对外可见,例如“奖励将在 24 小时内发放”,而不是让玩家猜测。
权威等级与资产分级
把“服务端权威”做成一个开关不够用。实际系统里存在三档:
| 权威等级 | 含义 | 适用资产 |
|---|---|---|
| 客户端声明 | 客户端上报,服务端只做格式校验 | 外观预览、非结算性展示 |
| 服务端校验 | 服务端检查合法性,但值来自客户端输入 | 移动、技能释放、部分进度 |
| 服务端权威 | 服务端计算并落库,客户端只收结果 | 货币、道具、排名、活动奖励 |
资产分级决定权威门槛。外观类可以接受客户端声明;进度类至少服务端校验;货币、可交易道具、排名和活动奖励必须服务端权威。这个矩阵要写进接口规范,而不是靠开发自觉。另一个容易漏的点是幂等。涉及资产的接口必须带幂等键,重试不能重复发放。限频只能降低请求频率,不能防止重放;幂等键和唯一约束才是最后一道闸。命令层的幂等模型见3.2 GM 命令的幂等与补偿 。
限频不是越低越好
限频过低会把正常玩家挡在门外。活动开放瞬间,玩家会集中领取、集中匹配、集中交易;如果阈值按平峰设计,峰值就会大面积误伤。阈值应该按场景分开:
| 场景 | 示例容量 | 示例补充速率 | 说明 |
|---|---|---|---|
| 登录 | 5 次 | 1 次/10 秒 | 防撞库,允许重试 |
| 匹配 | 10 次 | 1 次/5 秒 | 防脚本排队 |
| 交易行查询 | 30 次 | 5 次/秒 | 读多写少 |
| 交易行成交 | 5 次 | 1 次/10 秒 | 写操作,成本高 |
| 奖励领取 | 3 次 | 1 次/30 秒 | 防批量小号 |
这些是示例值,必须用真实峰值压测校准。校准方法是看正常玩家的 P99 请求量,把容量设在 P99 之上,再用补充速率限制持续吞吐。不要用平均值,平均值会盖住活动峰值。限频的另一个误区是只按 IP。NAT 和校园网会让大量正常玩家共享 IP,按 IP 限频会集体误伤;黑产也能用代理池绕过。按账号、设备、接口组合限频,IP 只作为辅助信号。被限频的请求要记录原因和重试时间,否则客服无法解释“为什么我点不了”。
奖励延迟与最终校验
奖励延迟的实现要点是资格快照和发放时复核分开:
- 事件发生时记录资格快照:谁、因为什么、应得多少、依据哪条规则。
- 奖励进入待发放队列,状态为 pending。
- 延迟期结束后,发放前再查一次账号状态:是否被封禁、是否被标记、资格是否仍然成立。
- 通过则发放并记录幂等键;不通过则作废并记录原因。
这里有个重要边界:已经发放的奖励不能静默作废。如果发放后才发现异常,要走追缴流程,通知玩家并保留申诉入口;静默扣回会引发更大的信任问题。延迟期的价值就是把大部分异常拦在发放之前。延迟期还要防止“卡点”攻击:黑产知道 24 小时后发放,就在第 23 小时转移资产。对策是发放时校验资产流向和账号关联,而不是只看时间。对高价值奖励,可以在延迟期内分段检查,而不是等到最后一刻。
让收益低于成本
对抗的终点是经济账。下面是一张示例估算表,用来说明决策逻辑,不是真实账单:
| 成本项 | 黑产单账号成本(示例值) | 提高成本的手段 |
|---|---|---|
| 账号获取 | 2 元 | 实名、设备绑定、注册冷却 |
| 设备与网络 | 3 元/月 | 设备指纹、代理检测 |
| 脚本维护 | 5 元/月 | 客户端完整性、行为检测 |
| 被封损失 | 8 元/次 | 提高检测率、追缴非法所得 |
| 时间成本 | 2 小时/批 | 限频、延迟发放、任务链加长 |
如果单账号预期收益是 30 元,总成本是 20 元,黑产仍然会做;把检测率提高、追缴比例提高、账号成本提高之后,总成本超过 30 元,供给才会下降。这里的数字必须用自己的数据重新测算,不能照抄。经济账还有一层:不要让防守成本高于黑产成本。如果为了防住一个小众玩法投入了大量人工复核,而黑产的实际损失很小,这笔投入就不划算。边界设计要优先保护高价值、可交易、可规模化的资产。
数据模型与可运行片段
internal/anticheatboundary 实现三件事:Check 按资产规则校验权威等级、幂等键与限频,Bucket 用令牌桶控制持续吞吐,Deliver 在延迟期结束后做最终校验并保证发放幂等。
// file: internal/anticheatboundary/anticheatboundary.go
// tier: L1
// verify: go test ./internal/anticheatboundary/
package anticheatboundary
import (
"errors"
"fmt"
"math"
"time"
)
// Authority says who produced the value the server is about to act on: a
// client claim, a server-side validation, or a fully authoritative server
// computation. Cheaper assets may accept validation; transferable assets
// require authority.
type Authority string
const (
AuthorityClient Authority = "client_claimed"
AuthorityValidated Authority = "server_validated"
AuthorityAuthoritative Authority = "server_authoritative"
)
func authorityRank(a Authority) (int, bool) {
switch a {
case AuthorityClient:
return 0, true
case AuthorityValidated:
return 1, true
case AuthorityAuthoritative:
return 2, true
}
return 0, false
}
// Asset classifies what an action can change. The more valuable and
// transferable the asset, the higher the authority bar.
type Asset string
const (
AssetCosmetic Asset = "cosmetic"
AssetProgress Asset = "progress"
AssetItem Asset = "item"
AssetCurrency Asset = "currency"
AssetRanking Asset = "ranking"
)
func assetKnown(a Asset) bool {
switch a {
case AssetCosmetic, AssetProgress, AssetItem, AssetCurrency, AssetRanking:
return true
}
return false
}
// Rule is the boundary for one asset: the minimum authority, how long a
// reward stays pending, and how many rate-limit tokens one action costs.
type Rule struct {
MinAuthority Authority
RewardDelay time.Duration
RateCost int
}
// Policy maps assets to rules. An asset with no rule is denied, not defaulted.
type Policy struct {
Rules map[Asset]Rule
}
// DefaultPolicy is the starting boundary for a live game: cosmetic and
// progress can be validated, while items, currency and ranking must be
// server-authoritative. Currency rewards wait before delivery.
func DefaultPolicy() Policy {
return Policy{Rules: map[Asset]Rule{
AssetCosmetic: {MinAuthority: AuthorityValidated, RateCost: 1},
AssetProgress: {MinAuthority: AuthorityValidated, RateCost: 2},
AssetItem: {MinAuthority: AuthorityAuthoritative, RateCost: 3},
AssetCurrency: {MinAuthority: AuthorityAuthoritative, RewardDelay: 10 * time.Minute, RateCost: 5},
AssetRanking: {MinAuthority: AuthorityAuthoritative, RateCost: 5},
}}
}
// RuleFor fails closed: an unknown asset, a non-positive rate cost, or an
// unknown authority makes the whole rule unusable.
func (p Policy) RuleFor(a Asset) (Rule, error) {
r, ok := p.Rules[a]
if !ok {
return Rule{}, fmt.Errorf("anticheatboundary: no rule for asset %q", a)
}
if _, ok := authorityRank(r.MinAuthority); !ok {
return Rule{}, fmt.Errorf("anticheatboundary: unknown authority %q", r.MinAuthority)
}
if r.RateCost <= 0 {
return Rule{}, errors.New("anticheatboundary: rate cost must be positive")
}
if r.RewardDelay < 0 {
return Rule{}, errors.New("anticheatboundary: reward delay must not be negative")
}
return r, nil
}
// Action is a request that would change an asset.
type Action struct {
PlayerID string
Asset Asset
Authority Authority
IdempotencyKey string
At time.Time
}
func needsIdempotency(a Asset) bool {
return a == AssetItem || a == AssetCurrency || a == AssetRanking
}
// Decision is the boundary result. RetryAfter is set only for rate limiting;
// RewardDelay is advisory and tells the caller how long to hold the payout.
type Decision struct {
Allowed bool
Reason string
RetryAfter time.Duration
RewardDelay time.Duration
}
// Check applies the boundary in a fixed order: rule, identity, authority,
// idempotency, then rate limit. The order matters because a denied request
// must not consume a token.
func Check(p Policy, a Action, bucket *Bucket, now time.Time) Decision {
r, err := p.RuleFor(a.Asset)
if err != nil {
return Decision{Reason: err.Error()}
}
if a.PlayerID == "" {
return Decision{Reason: "anticheatboundary: player id is required"}
}
got, ok := authorityRank(a.Authority)
if !ok {
return Decision{Reason: "anticheatboundary: unknown authority"}
}
want, _ := authorityRank(r.MinAuthority)
if got < want {
return Decision{Reason: fmt.Sprintf("anticheatboundary: %s requires %s", a.Asset, r.MinAuthority)}
}
if needsIdempotency(a.Asset) && a.IdempotencyKey == "" {
return Decision{Reason: "anticheatboundary: idempotency key is required for a transferable asset"}
}
if bucket != nil {
ok, retry := bucket.Allow(now, float64(r.RateCost))
if !ok {
return Decision{Reason: "anticheatboundary: rate limited", RetryAfter: retry}
}
}
return Decision{Allowed: true, Reason: "within boundary", RewardDelay: r.RewardDelay}
}
// Bucket is a token bucket for one key, such as a player and endpoint pair.
// Tokens refill continuously; a cost above capacity can never succeed.
type Bucket struct {
capacity float64
refillPerSecond float64
tokens float64
last time.Time
}
// NewBucket creates a full bucket. Zero refill is allowed and means the
// bucket only recovers when the policy or process restarts.
func NewBucket(capacity, refillPerSecond float64, now time.Time) (*Bucket, error) {
if capacity <= 0 {
return nil, errors.New("anticheatboundary: bucket capacity must be positive")
}
if refillPerSecond < 0 {
return nil, errors.New("anticheatboundary: refill rate must not be negative")
}
return &Bucket{capacity: capacity, refillPerSecond: refillPerSecond, tokens: capacity, last: now}, nil
}
// Allow consumes cost tokens. When it fails, RetryAfter is the whole-second
// wait until enough tokens accumulate; zero means the request can never pass
// with the current bucket.
func (b *Bucket) Allow(now time.Time, cost float64) (bool, time.Duration) {
if b == nil || cost <= 0 || cost > b.capacity {
return false, 0
}
if now.Before(b.last) {
now = b.last
}
if b.refillPerSecond > 0 {
b.tokens += now.Sub(b.last).Seconds() * b.refillPerSecond
if b.tokens > b.capacity {
b.tokens = b.capacity
}
}
b.last = now
if b.tokens >= cost {
b.tokens -= cost
return true, 0
}
if b.refillPerSecond <= 0 {
return false, 0
}
missing := cost - b.tokens
seconds := math.Ceil(missing / b.refillPerSecond)
return false, time.Duration(seconds) * time.Second
}
// Reward is a payout that is earned immediately but delivered later, after a
// delay and a final account check. Voided rewards are never paid.
type Reward struct {
ID string
PlayerID string
Asset Asset
Amount int64
EarnedAt time.Time
Delay time.Duration
Voided bool
DeliveredAt time.Time
}
// NewReward validates the payout before it enters the delayed queue.
func NewReward(id, playerID string, asset Asset, amount int64, now time.Time, delay time.Duration) (Reward, error) {
switch {
case id == "":
return Reward{}, errors.New("anticheatboundary: reward id is required")
case playerID == "":
return Reward{}, errors.New("anticheatboundary: reward player is required")
case !assetKnown(asset):
return Reward{}, fmt.Errorf("anticheatboundary: unknown reward asset %q", asset)
case amount <= 0:
return Reward{}, errors.New("anticheatboundary: reward amount must be positive")
case delay < 0:
return Reward{}, errors.New("anticheatboundary: reward delay must not be negative")
}
return Reward{ID: id, PlayerID: playerID, Asset: asset, Amount: amount, EarnedAt: now, Delay: delay}, nil
}
// DeliverOutcome is the result of one delivery attempt.
type DeliverOutcome string
const (
DeliverPending DeliverOutcome = "pending"
DeliverPaid DeliverOutcome = "paid"
DeliverVoided DeliverOutcome = "voided"
)
// Deliver pays a reward only after its delay and only if the account is not
// flagged. Delivery is idempotent: a second successful call returns Paid
// without paying again.
func Deliver(r Reward, now time.Time, flagged bool) (Reward, DeliverOutcome) {
if !r.DeliveredAt.IsZero() {
return r, DeliverPaid
}
if r.Voided {
return r, DeliverVoided
}
if flagged {
r.Voided = true
return r, DeliverVoided
}
if now.Before(r.EarnedAt.Add(r.Delay)) {
return r, DeliverPending
}
r.DeliveredAt = now
return r, DeliverPaid
}
本机验证命令与结果:
cd .local/checks/gsarch-lab
go test -count=1 ./internal/anticheatboundary/
ok gsarchlab/internal/anticheatboundary
该包共有 6 个表驱动测试,覆盖规则查找、权威分级、幂等要求、令牌桶补充与拒绝、奖励参数校验、延迟发放与作废;gofmt -l 无输出。
边界评审检查表
- 影响货币、道具、排名、活动奖励的结算入口,是否全部服务端权威?
- 客户端上报的字段里,有没有被直接当成结果使用的?
- 涉及资产的接口是否要求幂等键?重放会不会重复发放?
- 限频是否按账号、设备、接口、场景分维度?阈值是否用峰值校准?
- 被限流时是否返回明确的重试时间?客服能否解释?
- 高价值奖励是否延迟发放?延迟期是否做了资格与账号状态复核?
- 已发放奖励的追缴是否有通知和申诉入口?
- 每条边界的成本是否低于它保护的资产价值?
小结
黑产看的是收益,不是胜负。对抗的架构边界有三条:结算必须服务端权威,批量接口必须限频,高价值奖励必须延迟发放并做最终校验。权威要分级,限频要分场景,延迟要配合资格快照和发放复核。
边界不是越严越好。过严会误伤正常玩家,也会让防守成本超过损失。正确的目标是让黑产的单位时间收益低于成本,同时把误伤控制在可申诉、可回滚的范围内。信号怎么采、误伤怎么控见4.1 信号采集与误伤控制 ,处置与申诉见4.2 处置、申诉与复核 。
阅读导航:上一节:4.2 处置、申诉与复核 · 下一节:5.1 日志、指标与链路追踪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。