WeChall - Crackcha

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()