Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

逆向某站PoW签到逻辑

某站用了 PoW(Proof of Work)机制来防止自动签到。本来想着简单的 HTTP 请求就能搞定,结果发现还要算哈希。研究了下它的实现,用 Python 复现了一遍,记录一下过程。

背景

PoW 的核心思想很简单:服务器给你一道难题,你必须做一定的计算工作才能解决。计算成本提高了,自动化脚本就不划算了。

常见的 PoW 实现(比如比特币)要求哈希结果有特定数量的前导零。这个站也是这么干的。

抓包分析

打开浏览器开发者工具,观察签到流程:

1
GET /api/pow/challenge?tier=4&hps=3

响应:

1
2
3
4
5
6
7
{
"challengeId": "d10938eb-0c02-49c4-a5ac-c67c5faf860a",
"challenge": "8414fee4-8f21-40dc-b503-73a9f9205c96",
"difficulty": 9,
"targetBits": 9,
"targetSeconds": 200
}

提交的时候:

1
2
3
4
5
6
POST /api/pow/submit
{
"challengeId": "d10938eb-0c02-49c4-a5ac-c67c5faf860a",
"nonce": 223,
"tier": 4
}

问题来了:这个 nonce 是怎么算出来的?

WASM 文件

在 Network 面板里看到一个 pow.wasm 文件,下载下来看看。WASM 是 WebAssembly,一种可以在浏览器里运行的高效字节码。

wabt 工具反编译(或者直接看 JS 里的调用),发现导出了几个函数:

1
2
3
4
memory: 线性内存
alloc(size): 分配内存
dealloc(ptr, size): 释放内存
hash_with_nonce(challenge_ptr, challenge_len, nonce, out_ptr): 计算哈希

核心就是 hash_with_nonce,输入 challenge 和 nonce,输出哈希结果的前导零数量。

PoW 原理

先说清楚几个概念:

Challenge(挑战)

服务器每次生成的随机字符串:

1
"challenge": "8414fee4-8f21-40dc-b503-73a9f9205c96"

每次签到都不一样,这是关键。如果 challenge 不变,算一次就能复用,那就没意义了。

Difficulty(难度)

要求哈希结果有多少个前导零:

1
"difficulty": 9  // 要求前9个字符都是0

SHA-256 输出 64 个十六进制字符。十六进制每个字符是 0-f(16种可能),是 0 的概率是 1/16。

要得到 n 个前导零,概率是 (1/16)^n:

难度 预期尝试次数
1 ~16 次
2 ~256 次
9 ~68.7 亿次
16 ~2^64 次

所以 difficulty 每增加 1,计算量翻倍 16 倍。

Nonce(随机数)

客户端要找到一个数值,使得 SHA256(challenge + nonce) 的结果至少有 difficulty 个前导零。

只能暴力尝试:

1
2
3
4
nonce=0:  SHA256("8414fee4...0") = "a1b2c3d4..."           → leading=0  ❌
nonce=1: SHA256("8414fee4...1") = "f7e8d9c0..." → leading=0 ❌
...
nonce=223: SHA256("8414fee4...223") = "000000001abc123..." → leading=9 ✅

找到 nonce=223 后提交给服务器验证。

为什么每次都要重新算?

这是很多人容易误解的点。

虽然 difficulty 是固定的(比如 9),但 challenge 每次都不同,所以需要的 nonce 也不同:

签到 Challenge Nonce
第1次 8414fee4-... 223
第2次 a7f3d2e1-... 512
第3次 f9e8d7c6-... 89

同样的 nonce=223,在不同 challenge 下哈希结果完全不同:

1
2
SHA256("8414fee4..." + "223") = "005866b7..."     → leading=2
SHA256("a7f3d2e1..." + "223") = "d4c3b2a1..." → leading=0

这就是 PoW 的核心设计:每次都要付出计算工作,无法缓存或复用

Python 实现

加载 WASM

wasmtime 库加载 WASM 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from wasmtime import Engine, Store, Module, Instance, Memory, Func

wasm_bytes = pathlib.Path("pow.wasm").read_bytes()
engine = Engine()
store = Store(engine)
module = Module(engine, wasm_bytes)
instance = Instance(store, module, [])

# 获取导出
exports = instance.exports(store)
memory = exports.get("memory")
alloc_func = exports.get("alloc")
dealloc_func = exports.get("dealloc")
hash_func = exports.get("hash_with_nonce")

内存管理

WASM 用线性内存,需要手动管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
def write_to_memory(data: bytes, ptr: int):
"""写入数据到WASM内存"""
mem = memory.data_ptr(store)
for i in range(len(data)):
mem[ptr + i] = data[i]

def read_from_memory(ptr: int, size: int) -> bytes:
"""从WASM内存读取数据"""
mem = memory.data_ptr(store)
result = bytearray(size)
for i in range(size):
result[i] = mem[ptr + i]
return bytes(result)

计算哈希

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def hash_with_nonce(challenge: str, nonce: int) -> Tuple[int, str]:
"""计算 SHA-256 哈希,返回 (前导零数量, 哈希值)"""
challenge_bytes = challenge.encode('utf-8')
challenge_ptr = alloc_func(store, len(challenge_bytes))
out_ptr = alloc_func(store, 32) # SHA-256 输出 32 字节

try:
write_to_memory(challenge_bytes, challenge_ptr)

# 调用 WASM 函数
leading = hash_func(store, challenge_ptr, len(challenge_bytes), nonce, out_ptr)

hash_bytes = read_from_memory(out_ptr, 32)
hash_hex = hash_bytes.hex()

return leading, hash_hex
finally:
dealloc_func(store, challenge_ptr, len(challenge_bytes))
dealloc_func(store, out_ptr, 32)

暴力搜索 Nonce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def find_nonce(challenge: str, difficulty: int) -> Optional[int]:
"""查找满足条件的 nonce"""
challenge_bytes = challenge.encode('utf-8')
challenge_ptr = alloc_func(store, len(challenge_bytes))
out_ptr = alloc_func(store, 32)

try:
write_to_memory(challenge_bytes, challenge_ptr)

nonce = 0
while True:
leading = hash_func(store, challenge_ptr, len(challenge_bytes), nonce, out_ptr)

if leading >= difficulty:
return nonce

nonce += 1

# 定期让出控制权,避免占满 CPU
if nonce % 10000 == 0:
time.sleep(0.001)
finally:
dealloc_func(store, challenge_ptr, len(challenge_bytes))
dealloc_func(store, out_ptr, 32)

算力校准

服务器要求提供设备算力(H/s,Hashes per second),用来调整难度:

1
2
3
4
5
6
7
8
9
10
11
12
def benchmark(duration_ms: int = 1200) -> int:
"""测速,返回 H/s"""
start_time = time.perf_counter()
nonce = 0

while (time.perf_counter() - start_time) * 1000 < duration_ms:
hash_func(store, challenge_ptr, len(challenge_bytes), nonce, out_ptr)
nonce += 1

elapsed_ms = (time.perf_counter() - start_time) * 1000
hps = int((nonce / elapsed_ms) * 1000)
return hps

完整签到流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import requests

class PowSignInClient:
def __init__(self, base_url: str, session: requests.Session):
self.base_url = base_url.rstrip('/')
self.session = session

def get_challenge(self, tier_id: int, hps: int):
"""获取 PoW 挑战"""
params = f"tier={tier_id}&hps={hps}"
url = f"{self.base_url}/api/pow/challenge?{params}"
return self.session.get(url).json()

def submit_pow(self, challenge_id: str, nonce: int, tier: int):
"""提交签到"""
payload = {
"challengeId": challenge_id,
"nonce": nonce,
"tier": tier
}
url = f"{self.base_url}/api/pow/submit"
return self.session.post(url, json=payload).json()

# 执行签到
session = requests.Session()
session.cookies.set("sid", "your-cookie-here")

client = PowSignInClient("https://target.com", session)

# 1. 测速
hps = benchmark(duration_ms=600)
print(f"设备算力: {hps} H/s")

# 2. 获取挑战
challenge_data = client.get_challenge(tier_id=4, hps=hps)
print(f"Challenge: {challenge_data['challenge']}")
print(f"Difficulty: {challenge_data['difficulty']}")

# 3. 计算 Nonce
nonce = find_nonce(challenge_data['challenge'], challenge_data['difficulty'])
print(f"找到 Nonce: {nonce}")

# 4. 提交签到
result = client.submit_pow(challenge_data['challengeId'], nonce, 4)
print(f"签到结果: {result}")

运行效果

1
2
3
4
5
6
7
8
$ python pow_sign.py auto --cookie "sid=xxx"
[Auto] 设备算力: 150,000 H/s
[Auto] Challenge: 8414fee4-8f21-40dc-b503-73a9f9205c96
[Auto] Difficulty: 9
[Auto] 预计耗时: ~4.5s
[Auto] 找到 Nonce: 223
[Auto] Hash: 00000000123456789abcdef012345678...
[Auto] 签到成功! 奖励: 17.56

总结

PoW 机制确实是防止自动化的有效手段:

  1. 计算成本高 - 每次签到需要几秒甚至几十秒的哈希计算
  2. 无法复用 - challenge 每次随机,nonce 必须重新计算
  3. 容易验证 - 服务器收到 nonce 后算一次哈希就能验证

但也给了逆向的空间:

  1. WASM 可以分析 - 导出函数清晰,逻辑透明
  2. 可以用 Python 复现 - wasmtime 完美支持 WASM
  3. 算力可以优化 - 多线程、GPU 加速都能提速

安全性和可逆性之间,永远是博弈。

Reference