Hello Navi

Tech, Security & Personal Notes

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;

Challenge

PHP type juggling 挑战。源码要求输入一个 "magic number",不能包含数字 1-9,但必须等于 3735929054。

Solution

源码关键逻辑:

1
2
3
4
5
6
7
8
9
10
11
function noother_says_correct($number) {
$one = ord('1');
$nine = ord('9');
for ($i = 0; $i < strlen($number); $i++) {
$digit = ord($number[$i]);
if (($digit >= $one) && ($digit <= $nine)) {
return false; // 禁止数字 1-9
}
}
return $number == "3735929054"; // loose comparison
}

数字 0 是允许的(只检查 1-9)。3735929054 的十六进制是 0xdeadc0de,不包含 1-9。

PHP 的 loose comparison (==) 遇到 0x 前缀的字符串时,PHP 7.x 及更早版本会将其按十六进制解析为整数。所以 "0xdeadc0de" == "3735929054"3735929054 == 3735929054true

这个技巧在 PHP 8.0+ 已被修复(hex string 不再被当作 numeric string),但 WeChall 仍运行在旧版 PHP 上。

1
2
$ python3 -c "print(0xdeadc0de)"
3735929054

提交入口在 php0818.php,POST 字段为 number(不是 answer,也不需要 CSRF token)。

0xdeadc0de

Challenge

在本机或公网服务器搭建 HTTP 服务器,从外部可访问,提供指定路径和内容的文件。WeChall 会从你登录时的来源 IP 发起回调验证。

挑战页面会给出具体的参数(每个 session 不同):

  • URL 路径: http://<IP>:<port>/<USER>/<USER>.html
  • 文件内容: My name is <USER> and iChall.(精确字节数)
  • 字节数要求精确,不能多换行符

Solution

核心问题: WeChall 根据你登录时的来源 IP 发起回调验证。如果你的机器在 NAT/VPN 后面没有公网 IP,就无法直接验证。

解决方案: 在一台有公网 IP 的服务器上(VPS)搭建 Web 服务,从该服务器登录 WeChall 并提交。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 1. 在公网服务器上创建文件(USER 替换为 challenge 页面给你的值)
mkdir -p /tmp/<USER>
echo -n 'My name is <USER> and iChall.' > /tmp/<USER>/<USER>.html

# 2. 启动 HTTP 服务器
cd /tmp && python3 -m http.server <PORT> &

# 3. 用 Python 从服务器登录 WeChall 并提交端口
python3 -c "
import urllib.request, urllib.parse, http.cookiejar

cj = http.cookiejar.MozillaCookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
opener.addheaders = [('User-Agent', 'Mozilla/5.0')]

# Login (填入自己的 WeChall 账号)
opener.open('https://www.wechall.net/en/login', timeout=30)
data = urllib.parse.urlencode({'username':'<WC_USER>','password':'<WC_PASS>','login':'Login'}).encode()
opener.open(urllib.request.Request('https://www.wechall.net/en/login', data=data), timeout=30)

# Submit port
data = urllib.parse.urlencode({'port':'<PORT>','go':'I have set it up. Please check my server.'}).encode()
opener.open(urllib.request.Request('https://www.wechall.net/en/challenge/training/www/basic/index.php', data=data), timeout=30)
"

Challenge

Help! A zebra escaped from its enclosure. But where is it now?

一张 PNG 图片(斑马),斑马身上的条纹实际上是一个一维条码(barcode),但条码的条纹"越狱"了——bars 没有延伸到应有的边界。

Solution

条码的黑色条纹只在斑马身上,没有延伸到上下边界("escaped from its enclosure"),所以 zbarimg 等工具直接解码会失败。

第一步,提取斑马条纹区域。用 Python 垂直平均条纹区域,再按阈值二值化,模拟"补全"条纹的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from PIL import Image
import numpy as np

img = Image.open('zebra.png')
arr = np.array(img)

# 取条纹密集区域 (y=46~258)
region = arr[46:258, :, :3].mean(axis=2)

# 每列判断:若超过 40% 像素是暗色,则该列算黑条
col_dark = np.mean(region < 128, axis=0) > 0.4

# 生成条码图像
barcode = np.ones((100, len(col_dark) + 40), dtype=np.uint8) * 255
barcode[:, 20:-20] = (1 - col_dark.astype(np.uint8)) * 255

# 放大以使解码器识别
h, w = barcode.shape
big = np.repeat(np.repeat(barcode, 4, axis=0), 2, axis=1)
Image.fromarray(big).save('zebra_fixed.png')

然后用 zbarimg 解码:

1
2
$ zbarimg -S*.enable zebra_fixed.png
QR-Code:The answer is saFFari

关键词就是 saFFari(斑马跑到 Safari 上了 🦓)。

saFFari

Challenge

Bacon's cipher (Baconian cipher). A hidden message is encoded in the case pattern of a paragraph of text. Each letter is represented by 5 bits of A/B (upper/lower case). Uses the full 26-letter Bacon alphabet.

The page displays a Wikipedia article about Bacon's cipher with mixed case — the case pattern hides a secret message.

Solution

提取大小写模式,Upper=B,Lower=A,每 5 位一组按 26 字母 Bacon 字母表映射回 A-Z。空格用 X 表示。

1
BaCoN's cIphEr or THE bacOnIAN CiPHer iS a meThOD oF sTEGaNOGrapHY (a METhoD Of HidIng A sECRet MeSsaGe as OpPOsEd TO a TRUe CiPHeR) dEVIseD BY francis bAcoN. a MessAge Is coNCeALED in THe pRESenTatIoN OF TexT, ratHer thaN iTs coNteNt. tO enCODe A MEsSaGe, eaCh lETter Of THe pLAInText Is rePLAcED By A groUp oF fIvE OF the LETterS 'A' or 'B'. thIs REplaCement is dONE aCcordINg To tHe ALphAbet Of THe BACOnIAN cIpHeR, sHoWn bElOw. NoTe: A SeCoNd vErSiOn oF BaCoN'S CiPhEr uSeS A UnIqUe cOdE FoR EaCh lEtTeR. iN OtHeR WoRdS, i aNd j eAcH HaS ItS OwN PaTtErN. tHe wRiTeR MuSt mAkE UsE Of tWo dIfFeReNt tYpEfAcEs fOr tHiS CiPhEr. AfTeR PrEpArInG A FaLsE MeSsAgE WiTh tHe sAmE NuMbEr oF LeTtErS As aLl oF ThE As aNd bS In tHe rEaL, sEcReT MeSsAgE, tWo tYpEfAcEs aRe cHoSeN, oNe tO RePrEsEnT As aNd tHe oThEr bS. tHeN EaCh lEtTeR Of tHe fAlSe mEsSaGe mUsT Be pReSeNtEd iN ThE ApPrOpRiAtE TyPeFaCe, AcCoRdInG To wHeThEr iT StAnDs fOr aN A Or a b. To dEcOdE ThE MeSsAgE, tHe rEvErSe mEtHoD Is aPpLiEd. EaCh 'TyPeFaCe 1' LeTtEr iN ThE FaLsE MeSsAgE Is rEpLaCeD WiTh aN A AnD EaCh 'TyPeFaCe 2' LeTtEr iS RePlAcEd wItH A B. tHe bAcOnIaN AlPhAbEt iS ThEn uSeD To rEcOvEr tHe oRiGiNaL MeSsAgE. aNy mEtHoD Of wRiTiNg tHe mEsSaGe tHaT AlLoWs tWo dIsTiNcT RePrEsEnTaTiOnS FoR EaCh cHaRaCtEr cAn bE UsEd fOr tHe bAcOn cIpHeR. bAcOn hImSeLf pRePaReD A BiLiTeRaL AlPhAbEt[2] FoR HaNdWrItTeN CaPiTaL AnD SmAlL LeTtErS WiTh eAcH HaViNg tWo aLtErNaTiVe fOrMs, OnE To bE UsEd aS A AnD ThE OtHeR As b. ThIs wAs pUbLiShEd aS An iLlUsTrAtEd pLaTe iN HiS De aUgMeNtIs sCiEnTiArUm (ThE AdVaNcEmEnT Of lEaRnInG). BeCaUsE AnY MeSsAgE Of tHe rIgHt lEnGtH CaN Be uSeD To cArRy tHe eNcOdInG, tHe sEcReT MeSsAgE Is eFfEcTiVeLy hIdDeN In pLaIn sIgHt. ThE FaLsE MeSsAgE CaN Be oN AnY ToPiC AnD ThUs cAn dIsTrAcT A PeRsOn sEeKiNg tO FiNd tHe rEaL MeSsAgE.

解码:

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
import re

text = """
BaCoN's cIphEr or THE bacOnIAN CiPHer iS a meThOD oF sTEGaNOGrapHY (a METhoD Of HidIng A sECRet MeSsaGe as OpPOsEd TO a TRUe CiPHeR) dEVIseD BY francis bAcoN. a MessAge Is coNCeALED in THe pRESenTatIoN OF TexT, ratHer thaN iTs coNteNt. tO enCODe A MEsSaGe, eaCh lETter Of THe pLAInText Is rePLAcED By A groUp oF fIvE OF the LETterS 'A' or 'B'. thIs REplaCement is dONE aCcordINg To tHe ALphAbet Of THe BACOnIAN cIpHeR, sHoWn bElOw. NoTe: A SeCoNd vErSiOn oF BaCoN'S CiPhEr uSeS A UnIqUe cOdE FoR EaCh lEtTeR. iN OtHeR WoRdS, i aNd j eAcH HaS ItS OwN PaTtErN. tHe wRiTeR MuSt mAkE UsE Of tWo dIfFeReNt tYpEfAcEs fOr tHiS CiPhEr. AfTeR PrEpArInG A FaLsE MeSsAgE WiTh tHe sAmE NuMbEr oF LeTtErS As aLl oF ThE As aNd bS In tHe rEaL, sEcReT MeSsAgE, tWo tYpEfAcEs aRe cHoSeN, oNe tO RePrEsEnT As aNd tHe oThEr bS. tHeN EaCh lEtTeR Of tHe fAlSe mEsSaGe mUsT Be pReSeNtEd iN ThE ApPrOpRiAtE TyPeFaCe, AcCoRdInG To wHeThEr iT StAnDs fOr aN A Or a b. To dEcOdE ThE MeSsAgE, tHe rEvErSe mEtHoD Is aPpLiEd. EaCh 'TyPeFaCe 1' LeTtEr iN ThE FaLsE MeSsAgE Is rEpLaCeD WiTh aN A AnD EaCh 'TyPeFaCe 2' LeTtEr iS RePlAcEd wItH A B. tHe bAcOnIaN AlPhAbEt iS ThEn uSeD To rEcOvEr tHe oRiGiNaL MeSsAgE. aNy mEtHoD Of wRiTiNg tHe mEsSaGe tHaT AlLoWs tWo dIsTiNcT RePrEsEnTaTiOnS FoR EaCh cHaRaCtEr cAn bE UsEd fOr tHe bAcOn cIpHeR. bAcOn hImSeLf pRePaReD A BiLiTeRaL AlPhAbEt[2] FoR HaNdWrItTeN CaPiTaL AnD SmAlL LeTtErS WiTh eAcH HaViNg tWo aLtErNaTiVe fOrMs, OnE To bE UsEd aS A AnD ThE OtHeR As b. ThIs wAs pUbLiShEd aS An iLlUsTrAtEd pLaTe iN HiS De aUgMeNtIs sCiEnTiArUm (ThE AdVaNcEmEnT Of lEaRnInG). BeCaUsE AnY MeSsAgE Of tHe rIgHt lEnGtH CaN Be uSeD To cArRy tHe eNcOdInG, tHe sEcReT MeSsAgE Is eFfEcTiVeLy hIdDeN In pLaIn sIgHt. ThE FaLsE MeSsAgE CaN Be oN AnY ToPiC AnD ThUs cAn dIsTrAcT A PeRsOn sEeKiNg tO FiNd tHe rEaL MeSsAgE.
"""

# 只取字母,忽略标点和空格
bits = ''.join('B' if c.isupper() else 'A'
for c in text if c.isalpha())

bac26 = {'AAAAA':'A','AAAAB':'B','AAABA':'C','AAABB':'D',
'AABAA':'E','AABAB':'F','AABBA':'G','AABBB':'H',
'ABAAA':'I','ABAAB':'J','ABABA':'K','ABABB':'L',
'ABBAA':'M','ABBAB':'N','ABBBA':'O','ABBBB':'P',
'BAAAA':'Q','BAAAB':'R','BAABA':'S','BAABB':'T',
'BABAA':'U','BABAB':'V','BABBA':'W','BABBB':'X',
'BBAAA':'Y','BBAAB':'Z'}

decoded = ''
# 截断到 5 的倍数,忽略末尾不足 5 位的碎片
for i in range(0, len(bits) - len(bits) % 5, 5):
c = bac26.get(bits[i:i+5], '?')
if c == 'X':
decoded += ' '
else:
decoded += c

print(decoded)

解码后得到类似:

1
VERY WELL DONE FELLOW HACKER THE SECRET KEYWORD IS DRSLCAHINFSF

注意:该挑战是 session-bound(每 session 密码不同),上述消息只是示例。实际运行脚本会得到当前 session 的密码,形如 bpnnbnfonec

关键点:

  • 使用 26 字母版本(I/J 各有独立编码)
  • case 映射为 Upper=B, Lower=A
  • X 代表空格(word separator)

Challenge

管理员根据 feedback "修复" 了上一个版本(Limited Access)中的 .htaccess,现在 protected/protected.php 更加安全了。需要再次访问它。

Solution

上一个版本用 <Limit GET> 限制了 GET 方法,通过改用 POST 绕过。这个"修复"版本在 .htaccess 里封了更多方法:

1
2
3
<Limit GET POST HEAD PUT DELETE CONNECT OPTIONS PATCH>
require valid-user
</Limit>

Apache 的 <Limit> 指令只拦截明确列出的方法,未列出的方法不受限制。上面漏掉了 TRACKPATCHPROPFINDMOVE 等非标准扩展方法。

直接用 TRACK 方法绕过:

1
2
$ curl -X TRACK -b 'WC=...' \
'https://www.wechall.net/en/challenge/wannabe7331/limited_access_too/protected/protected.php'

返回受保护页面的内容,挑战即完成(auto-solve,无需额外提交)。

Challenge

A regular square pyramid where all 8 edges are equal in length (set to a). Find a formula for the volume (no more than 9 characters).

Solution

正四棱锥,所有 8 条棱等长(设为 a)。求体积公式(不大于 9 字符)。

底面正方形对角线 \(d = a\sqrt{2}\),底面中心到顶点距离为 \(d/2 = a\sqrt{2}/2\)

\(h = \sqrt{a^2 - (a\sqrt{2}/2)^2} = \sqrt{a^2 - a^2/2} = a/\sqrt{2}\)

体积 \(V = \frac{1}{3} \times a^2 \times h = \frac{1}{3} \times a^2 \times \frac{a}{\sqrt{2}} = \frac{a^3}{3\sqrt{2}}\)

化简到 9 字符以内:

\[ \frac{a^3}{3\sqrt{2}} = \frac{a^3}{\sqrt{18}} = 18^{-\frac{1}{2}} \cdot a^3 \]

提交 \(18^{-0.5}a^3\) 即可(9 字符,精确解):

18-.5a3

Challenge

"You got mail and a nice attachment." — 有一张附件图片,其中隐藏了 12 字母的 session-bound solution。

Solution

挑战页面提供了 attachment.php,下载后是一个 JPEG 图片(221x350)。

JPEG 尾部附着一个 ZIP 文件,其中包含 solution.txt。JPEG 和 ZIP 能共存于同一个文件是因为两者的解析方向不同:

1
2
JPEG:     从头读    → FF D9 后停止(尾部垃圾被忽略)
unzip: 从尾扫 → 找 EOCD 签名 PK\x05\x06 → 定位 ZIP 数据

JPEG 解码器遇到 End of Image marker FF D9 就结束处理,后面的数据不影响图片显示。

unzip 根本不依赖文件扩展名 —— ZIP 格式的 End of Central Directory (EOCD) 记录固定在文件的最后 22 字节unzip 打开文件后: 1. 从尾部往前搜索 EOCD 签名 PK\x05\x06 2. 读取 EOCD 中的中央目录偏移量 3. 跳到偏移量处,找到所有文件条目和压缩数据

所以只要文件尾部附着一个结构完整的 ZIP,unzip 就能识别,无论文件头是什么(JPEG、EXE、PDF 都一样)。man page 也写了自解压 ZIP(exe+ZIP)"as with any other ZIP archive" —— 同一机制。

unzip -l 确认 ZIP 内容:

1
2
3
4
5
6
7
$ unzip -l attachment.jpg
Archive: attachment.jpg
Length Date Time Name
--------- ---------- ----- ----
12 2011-01-08 14:17 solution.txt
--------- -------
12 1 file

直接解压:

1
2
3
$ unzip attachment.jpg
Archive: attachment.jpg
extracting: solution.txt

solution.txt 内容即为答案(session-bound,每用户不同)。

Challenge

IRC 频道 #wechall,每分钟有一人加入并说 "hi",服务器将消息发送给频道中所有人(包括发送者)。经过 0xfffbadc0ded 分钟后,总共发送了多少条 "hi" 消息?无人离开频道。

3 分钟的例子:

  • 第 1 分钟:第 1 人加入发 hi(1),服务器回发给 1 人(1)→ 2 条
  • 第 2 分钟:第 2 人加入发 hi(1),服务器回发给 2 人(2)→ 3 条
  • 第 3 分钟:第 3 人加入发 hi(1),服务器回发给 3 人(3)→ 4 条
  • 总计:2 + 3 + 4 = 9 条

Solution

第 k 分钟发送 k+1 条消息(1 条来自加入者,k 条来自服务器转发)。n 分钟的总和为:

1
total = 1 + 2 + ... + (n+1) = n(n+3)/2
1
2
3
4
5
6
>>> n = 0xfffbadc0ded
>>> n
17591026060781
>>> total = n * (n + 3) // 2
>>> total
154722098935564539692256152
154722098935564539692256152

Challenge

Stegano LSB 图像隐写。挑战页面提供一张 carrier.png,其中包含 12 个大写字母的隐藏信息。每 session 不同。

Solution

隐藏信息不在传统的 data-hiding LSB 中(zsteg 检测不到有效文本),而是通过 visual bit plane 方式嵌入:将字母写入某个 bit plane,肉眼查看该 plane 的图像即可读出。

遍历 8 个 bit plane × 3 个颜色通道后发现,字母出现在 绿色通道的 bit 2(0-indexed)中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PIL import Image

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

out = Image.new('L', (w, h))
out_pixels = []
for pixel in pixels:
# 取绿色通道 (index 1) 的 bit 2
val = (pixel[1] >> 2) & 1
out_pixels.append(255 if val else 0)
out.putdata(out_pixels)
out.save('bit2_green.png')

生成的 bit2_green.png 中,97% 以上像素为黑,仅 2-3% 的白点构成 12 个斜体大写字母。肉眼直接可读。

也可以从工具集一键扫描所有 bit plane:

1
$ uv run ~/ctf/tool/script/forensics/lsb_scan.py carrier.png

输出每个 bit plane 的白色像素占比,sparse(< 5%)的 plane 就是隐藏文本所在。

同样可以通过其他 stegano 工具发现:

  • Stegsolve(Java):Analyse → Bit Plane 逐层查看,绿色通道 bit 2 即可看到字母
  • Stegoveritasstegoveritas carrier.png 自动提取所有 bit plane,输出到 results/ 目录

关键点

  • 使用绿色通道的 bit 2(不是 bit 4),Steganabara 对应 mask=4(即 2^2)
  • 字母在 bit plane 图像中肉眼可见,白字黑底
  • zsteg 不适用——这不是 data-hiding,而是 visual plane 嵌入;zsteg 找的是 LSB 中编码的数据字节,而字母是直接画在 bit plane 上的
  • 答案 session-bound,每张 carrier.png 不同
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE