基于 PoW 的防作弊 GPU 排行榜
从伪造一个跑分请求开始,用可验证的 PoW 解替代客户端自报成绩,并说明算力估计、重复提交与设备身份的边界。
本文目录
假设一个 GPU 跑分页面结束测试后,向服务端提交:
{"gpu": "某型号显卡", "score": 1000000, "elapsed_ms": 1000}
如果服务端直接信任这些字段,攻击者根本不用运行测试。他可以把分数改大,把耗时改小,甚至换一个显卡型号。
服务端收到了一个数字,却没有拿到足以支撑这个数字的证据。这是我做 nierf.com GPU 排行榜时想解决的问题:能不能让客户端提交计算结果,由服务端自己验算和计分?
PoW 提供了一种办法。不过先说清楚它的范围:PoW 可以验证符合条件的解,借此估计计算吞吐;它不能单独证明这些解来自某张显卡,也不能精确记录每一次尝试。
一、把“报分”换成“交解”
服务端发出一个不可提前预测的 challenge,客户端寻找 nonce,使得:
SHA256(challenge || nonce) < target
这里约定 nonce 是 8 字节无符号大端整数,哈希结果也按大端无符号整数比较。实际协议还应明确版本和字段边界,避免同样的值被拼成不同字节。
在把哈希输出近似看作均匀分布、各次尝试近似独立的模型下,成功概率是:
p = target / 2^256
例如要求哈希前 12 位为 0,可以设 target = 2^244,单次成功概率为 1/4096,平均约尝试 4096 次得到一个解。
注意是平均。有人第一次就成功,也有人尝试一万次仍未成功。找到一个解,不等于证明恰好执行了 4096 次计算。
验证者则只需对给定 nonce 算一次哈希,检查是否小于 target。昂贵的搜索和便宜的验算,就这样分开了。
二、先用 CPU 走通一次流程
下面是可直接运行的教学实验。为方便复现,challenge 是固定字节;为了观察流程,求解端和验证端放在同一进程中。它不测 GPU 性能,也不是可直接上线的会话服务。
import hashlib
import time
challenge = bytes.fromhex('9a' * 32)
target = 1 << 244
def is_solution(nonce):
if type(nonce) is not int or not 0 <= nonce < 2**64:
return False
payload = challenge + nonce.to_bytes(8, 'big')
digest = hashlib.sha256(payload).digest()
return int.from_bytes(digest, 'big') < target
def solve(count):
solutions = []
nonce = 0
while len(solutions) < count:
if is_solution(nonce):
solutions.append(nonce)
nonce += 1
return solutions
def verify(nonces):
if not 1 <= len(nonces) <= 64:
raise ValueError('提交数量超出范围')
if any(type(nonce) is not int for nonce in nonces):
raise ValueError('nonce 必须是整数')
if len(nonces) != len(set(nonces)):
raise ValueError('存在重复 nonce')
if not all(is_solution(nonce) for nonce in nonces):
raise ValueError('存在无效解')
return len(nonces)
started = time.perf_counter()
nonces = solve(8)
received = time.perf_counter()
accepted = verify(nonces)
print('有效解数量:', accepted)
print('解对应的期望尝试数:', accepted * (2**256 / target))
print('本地演示耗时:', received - started)
try:
verify([nonces[0], nonces[0]])
except ValueError as error:
print(error)
程序会接受 8 个不同的有效解,并拒绝把同一个解重复算分。这个难度下,8 个解对应的期望尝试数是 32768,但实际求解循环执行的次数可以不同。
把求解循环搬到 GPU,可以并行尝试大量 nonce。服务端的验算原则不变:拿到具体字节,重算哈希,不读取客户端自报的分数作为依据。
三、从有效解数量估计算力
设固定难度下收到 k 个不同的有效解,成功概率为 p,服务端观测的时间为 T,可以构造一个估计值:
estimated_hashrate = k / (p × T)
分子 k/p 是由解数量推算的工作量,不是已被逐次证明的尝试次数。因此界面和文章更适合称它为“估计算力”或“有效吞吐估计”。
在固定观察窗口、独立稀有成功事件的近似模型下,解数量有随机波动;当样本足够多时,相对波动量级约为 1/√k。例如累计约 100 个解时仍有约 10% 量级的随机波动,不能把少量样本的末尾小数当成精确排名。
还有一个常被忽略的区别:固定时间数解,与找到固定数量的解就停止,不是同一种采样规则。 后者直接用 k/T 评分可能引入停止规则相关的偏差。上面的八解程序用于演示验算,正式榜单需要先固定测试协议,再分析估计方法。
计时也必须来自服务端。若从签发 challenge 到收到最终结果计时,网络延迟、调度和提交开销都会进入分母,测得的是端到端吞吐,而非纯 GPU 内核运行速度。
四、真正上线时,会话要约束什么?
教学代码里的固定 challenge 不能照搬到公开榜单,否则别人可以把昨天算好的解拿来提交。
一个会话可以在服务端保存这些信息:
| 信息 | 用途 |
|---|---|
| 不可预测的 challenge | 阻止提前针对本轮计算 |
| 会话 ID、账号或参与者关联 | 防止结果随意跨会话转用 |
| 算法版本、target、nonce 编码 | 固定验算和比较口径 |
| 签发时间、截止时间 | 使用可信时间界定测试窗口 |
| 已接受的 nonce 或最终提交状态 | 防止重复计分与重放 |
| 数量和大小上限 | 限制验算及存储成本 |
这些数据要由服务端保存,或使用服务端认证的令牌保护;不能相信客户端回传的 target 和时间。签名令牌可以防篡改,但单独一个令牌并不能记住“已经用过”,重放仍需状态或等效机制处理。
验算顺序也有意义:先检查会话、格式、数量、时限和重复,再执行哈希验证,最后原子地记录成绩与消费状态。并发提交时,如果检查与记账分开,两个请求仍可能把同一份结果算两次。
一次会话只允许一次最终提交,并不阻止用户反复开新会话挑最好结果。限流、参与规则与统计口径需要共同处理这个问题。
五、哪些作弊被挡住,哪些还在?
| 行为 | PoW 验算能否单独处理 |
|---|---|
| 把客户端分数字段改大 | 可以避免直接采信,由服务端重新计分 |
| 提交随机 nonce | 能验证真假,成功概率取决于难度 |
| 重复提交同一个解 | 需要额外去重和会话消费状态 |
| 重放旧会话的解 | 需要新的 challenge 和会话绑定 |
| 找远程服务器或多张卡代算 | 不能仅凭解判断计算来源 |
| 把设备型号改成另一张卡 | 不能仅凭解证明硬件身份 |
| 反复测试只保留幸运的一轮 | 需要采样与排名规则约束 |
这张表比“PoW 防作弊”五个字更接近实际能力:它能让最廉价的伪造分数失效,但还没有完成设备身份认证。
即便来源可信,SHA-256 求解吞吐也只是某一种工作负载的表现,不能直接代表游戏帧率、光线追踪或所有通用计算性能。不同内核优化、浏览器、驱动、功耗与温度条件都可能影响结果。
六、排行榜怎样展示,才不掩盖这些差异?
按 GPU 型号聚合,比堆满重复提交更容易阅读。但如果每个型号只展示最高分,提交次数多的型号就更容易碰到一次幸运结果。
因此榜单除了成绩,还值得展示样本数、测试时长、算法版本、运行环境和波动范围。中位数、分位数或经过一致规则筛选的结果,通常比一个脱离样本背景的最高分更容易解释;不同协议版本的成绩也不应直接混排。
异常成绩可以单独标记,保留原始提交供复核。PoW 让这一步有了可检查的材料,但如何处理异常,仍然需要明确的榜单规则。
七、回到 nierf.com 的出发点
我做这个排行榜的出发点,是把“客户端说自己算了多少”改成“客户端交出结果,服务端验证并估计吞吐”。上面的代码和会话字段是用于解释这一思路的简化模型,不代表线上接口或已经完成的全部反作弊能力。
PoW 在区块链和防滥用机制里也有应用,但移到 GPU 排行榜之后,问题就变成了三个不同层次:解是否有效,估计值是否稳定,设备归属是否可信。
先把能验证的部分交给验证,把还不能证明的部分如实留在成绩的解释里。 这样读者才知道这个分数值得参考到什么程度。
交流与讨论