HackThisSite - Programming Mission 9

Challenge

This challenge is all about algorithm coding, and encryption. The aim of this challenge is to take a comma-delimited string, which represents a 9x9 Sudoku, and parse this string into a form which can be analyzed. The Sudoku should then be analyzed and solved, and returned to its comma-delimited form. This comma-delimited solution would then be hashed using the SHA1 hashing system. The resulting hash should be applied as the key to decrypt the CBC-mode 64bit-block-size Blowfish-encrypted Base64-encoded string provided. The decrypted string is the password for this challenge. Submit it quickly! 这道题的核心是写算法和解密:把一串逗号分隔的字符串当作 9x9 数独解析、求解,再把它还原成同样的逗号分隔形式;对这个结果做 SHA1,把得到的 hash 当作 key,用 CBC 模式、64-bit 分组的 Blowfish 解密题目给的 Base64 密文,明文就是关卡密码。要求快速提交。

题目页面给出:一个 9x9 数独(有向量的题面 + 一份 copy/paste 用的逗号串)、一段 Base64 密文,以及加密算法的 PHP 源码 blowfish.phps。页面还挂了 180 秒倒计时。

Solution

整体链路很直白:解析数独 → 求解 → 回填逗号串 → SHA1 → Blowfish-CBC 解密 → 提交。

真正的难点不在数独,而在 blowfish.phps 是一份魔改过的 Blowfish 实现:它不是标准算法,也没有现成库能直接对上。pycryptodomeBlowfish 解不出正确明文,必须逐行复刻这份 PHP。下文先列出从源码中提取的三个关键约定,再给完整脚本。

第一步:从源码确认算法约定

blowfish.phps 开头的两个成员变量直接决定了模式:

1
2
public $hashcfg      = 1 ;   // Key Hashing MD5: 0  SHA1: 1
public $encryptmode = 1 ; // Encryption mode: EBC: 0 CBC: 1

即 key 用 SHA1、模式用 CBC。继续看 key schedule:

1
2
3
4
5
6
7
8
9
10
11
12
13
function keys($key)
{
$key_hash = sha1($key);

//Convert the $key into a 16Byte key
$key = $this->_str2long(substr(str_pad($key, 16, $key_hash),0,16));

//XOR Pbox1 with the first 32 bits of the key, XOR P2 with the second 32-bits of the key,
for($i=0;$i<count($this->pbox);$i++)
{
$this->pbox[$i] ^= $key[$i%4];
}
}

sha1($key) 在 PHP 里默认返回 40 字符的十六进制字符串。题面流程是把 SHA1 的结果当 key 传进来,也就是这个 40 字符的 hex 串;它长度已经 ≥ 16,str_pad 不起作用,于是 substr(...,0,16) 只取 hex 串的前 16 个字符。这 16 个字符会被 _str2long()unpack('N*'),big-endian 32-bit)拆成 4 个 word 参与 key schedule。

第二处是 round 函数,也是这份实现最不常规之处:

1
2
3
4
5
6
7
8
9
10
11
12
13
function sbox_round($integer)
{
//Split $integer into four 8 Bit blocks
$b0 = $integer<<24 & 0xFF;
$b1 = $integer<<16 & 0xFF;
$b2 = $integer<<8 & 0xFF;
$b3 = $integer & 0xFF;

$return = ($this->sbox0[$b0] + $this->sbox1[$b1] % 4294967295) ;
$return = ($return ^ $this->sbox2[$b2]) + $this->sbox3[$b3] % 4294967295;

return $return;
}

标准 Blowfish 用右移 >> 取四个字节索引,这里全是左移 <<。在 64 位 PHP(以及 Python)整数语义下,(x << 24) & 0xFF(x << 16) & 0xFF(x << 8) & 0xFF 恒为 0,只有 x & 0xFF 这个低字节存活。所以 F 函数退化成一个只用低字节查表的表达式,必须原样照抄,不能用标准 F。

第三处是 CBC 的 IV 和输出格式:

1
2
3
4
if($this->encryptmode == 1) {
$cipher[0][0] = time();
$cipher[0][1] = (double)microtime()*1000000;
}

IV 取的是时间戳 [time(), microtime()*1000000] 两个整数;更关键的是收尾的打包循环 for($i = 0; $i<count($cipher); $i++)$cipher[0](也就是这个 IV)也写进了输出。所以 base64 解码后的密文里,前 8 字节是 IV,真正的密文 block 从第 3 个 32-bit word 开始。解密时要把第一块当 IV 用。

第二步:求解数独

题面的 copy/paste 串按逗号切正好是 81 个字段(行主序),空 cell 就是空字段;求解后按 ",".join(81 个数字) 回填,格式与题面完全一致。这里用最朴素的全解回溯,顺带枚举多个解(题面提示可能有多个解):

1
2
3
def parse_puzzle(cells):
return [[0 if cells[r * 9 + c] == "" else int(cells[r * 9 + c])
for c in range(9)] for r in range(9)]

solve_sudoku() 是全解回溯(行优先试填 + 行/列/宫校验),最多收 limit 个解;完整实现见文末 完整脚本

第三步:复刻魔改 Blowfish

把 PHP 的 block_encrypt / sbox_round / keys 逐行翻译成 Python。注意 block_encrypt 本身是标准 Blowfish 加密轮,所以逆过程就是标准解密轮;另外用随机 block 做 enc/dec 往返自检,以确认逆轮写对。逐行翻译后的 Blowfish 类(sbox_round / block_encrypt / block_decrypt / keys 与 PHP 一一对应)见文末 完整脚本

keys()key 材料只取前 16 字节,self.P[i] ^= kw[i % 4] 之后就是标准的 P-box / S-box 展开循环。

CBC 解密时把第一块取出当 IV(实现见文末 完整脚本blowfish_cbc_decrypt)。

第四步:现算现交

这一关每次加载页面都会重新随机生成一个数独,并用它的解现算一个一次性口令,所以不能分两次跑(先解密再提交),必须 fetch → 解 → 解密 → 提交在同一个进程、同一次请求里完成。脚本对每个解都算一次 key、解密,取第一个全可打印(ASCII 32–126)的结果作为明文并去掉尾部空格填充(实现见文末 完整脚本 main() 里的候选循环)。

被接受的那次运行输出如下(puzzle/cipher/sha1/明文都是那一次的实例数据):

1
2
3
4
5
6
[*] puzzle   : 6,9,3,,,8,4,,7,,8,,4,1,,3,9,6,,7,1,,9,6,,8,,3,,,2,8,5,,,4,2,,8,1,7,,9,,,1,,7,,6,,8,,2,7,1,,6,,9,5,2,8,,2,5,,4,1,,,9,9,3,6,,5,2,7,4,1
[*] cipher : aqTuiQAD268luTLGbz/aLw==
[*] solutions: 1
[*] sol #0 sha1=6a7b41c76c1241cc32da0d1ab2e35321d61b1199 -> b'f6hv(/ ' (printable=True)
[*] password : f6hv(/
[*] verdict : True

服务器提交后返回:

1
2
CORRECT!
Your answer is correct. Congrats!

提交字段名是 password(不是一般关卡的 solution),这一点也要注意。

Script

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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""HackThisSite Programming Mission 9 — One-Time-Pad Encryption.

Flow:
1. fetch /missions/prog/9/ and parse the comma-delimited 9x9 Sudoku + the
Base64 Blowfish ciphertext;
2. solve the Sudoku (backtracking, enumerate every solution);
3. join the solution back into the same comma-delimited form, SHA1 it, and
use the resulting SHA1 hex string as the Blowfish key;
4. decrypt the Base64 ciphertext with the *exact* Blowfish variant shipped in
blowfish.phps (hashcfg=1 -> SHA1, encryptmode=1 -> CBC-with-prepended-IV);
5. submit the printable plaintext as the challenge password.

The Blowfish class below is a line-for-line Python port of the PHP reference at
/missions/prog/9/blowfish.phps. It is NOT stock Blowfish: the round function
`F` uses `<<` (not `>>`) when slicing the four S-box indices, and the CBC "IV"
is `[time(), microtime()*1e6]` which is emitted as the first ciphertext block
instead of being transmitted separately. Both quirks must be reproduced.

Run from the CTF workspace root:
export HTS_COOKIE='HackThisSite=...'
uv run python challenges/hts-prog/9/solve.py
"""

import base64
import hashlib
import os
import re
import struct
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.abspath(os.path.join(HERE, "..")))
from common import session, fetch_level, body_text, submit # noqa: E402

LEVEL = 9
FIELD = "password"


# --------------------------------------------------------------------------
# Blowfish (PHP blowfish.phps port)
# --------------------------------------------------------------------------
PBASE = [
0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344,
0xa4093822, 0x299f31d0, 0x082efa98, 0xec4e6c89,
0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917,
0x9216d5d9, 0x8979fb1b,
]


def _load_sboxes():
"""Read the four S-box tables straight out of the challenge's PHP source."""
php = os.path.join(HERE, "blowfish.php")
if not os.path.exists(php):
raise SystemExit("blowfish.php missing — fetch it from "
"/missions/prog/9/blowfish.phps first")
src = open(php, encoding="utf-8").read()
boxes = []
for name in ("sbox0", "sbox1", "sbox2", "sbox3"):
m = re.search(r"\$%s\s*=\s*Array" % name, src)
j = m.end() - 1
depth = 0
for k in range(j, len(src)):
if src[k] == "(":
depth += 1
elif src[k] == ")":
depth -= 1
if depth == 0:
body = src[j:k]
break
boxes.append([int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", body)])
return boxes


SBASE = _load_sboxes()
MASK32 = 0xFFFFFFFF


class Blowfish:
"""Faithful port of the PHP reference implementation."""

def __init__(self):
self.P = PBASE[:]
self.S = [b[:] for b in SBASE]

def sbox_round(self, integer):
# NOTE: the original uses `<<` here, not `>>`. On a 64-bit interpreter
# (x<<24)&0xFF, (x<<16)&0xFF and (x<<8)&0xFF all collapse to 0, so only
# the low byte survives. Keep it exactly as written.
b0 = (integer << 24) & 0xFF
b1 = (integer << 16) & 0xFF
b2 = (integer << 8) & 0xFF
b3 = integer & 0xFF
r = self.S[0][b0] + self.S[1][b1] % 4294967295
r = (r ^ self.S[2][b2]) + self.S[3][b3] % 4294967295
return r

def block_encrypt(self, left, right):
vl, vr = left, right
for i in range(16):
vl ^= self.P[i]
vr ^= self.sbox_round(vl)
vl, vr = vr, vl
vl, vr = vr, vl
vr ^= self.P[16]
vl ^= self.P[17]
return vl, vr

def block_decrypt(self, left, right):
vl, vr = left, right
vl ^= self.P[17]
vr ^= self.P[16]
vl, vr = vr, vl
for i in range(15, -1, -1):
vl, vr = vr, vl
vr ^= self.sbox_round(vl)
vl ^= self.P[i]
return vl, vr

def keys(self, key):
"""Key schedule; `key` is the raw string the server feeds to keys()."""
if isinstance(key, str):
key = key.encode()
key_hash = hashlib.sha1(key).hexdigest().encode() # PHP sha1() -> hex
if len(key) >= 16:
material = key[:16]
else:
material = (key + key_hash * (1 + 16 // len(key_hash)))[:16]
kw = list(struct.unpack(">4I", material))
for i in range(18):
self.P[i] ^= kw[i % 4]
v0 = v1 = 0
for i in range(0, 18, 2):
v0, v1 = self.block_encrypt(v0, v1)
self.P[i] = v0
self.P[i + 1] = v1
for bi in range(4):
for i in range(0, 256, 2):
v0, v1 = self.block_encrypt(v0, v1)
self.S[bi][i] = v0
self.S[bi][i + 1] = v1


def blowfish_cbc_decrypt(b64_text, key):
"""Decrypt Base64 CBC-Blowfish where block 0 is the prepended IV."""
bf = Blowfish()
bf.keys(key)
data = base64.b64decode(b64_text)
words = list(struct.unpack(">%dI" % (len(data) // 4), data))
prev = (words[0], words[1]) # emitted IV block
out = bytearray()
for i in range(2, len(words), 2):
pl, pr = bf.block_decrypt(words[i], words[i + 1])
pl ^= prev[0]
pr ^= prev[1]
out += struct.pack(">II", pl & MASK32, pr & MASK32)
prev = (words[i], words[i + 1])
return bytes(out)


# --------------------------------------------------------------------------
# Sudoku
# --------------------------------------------------------------------------
def parse_puzzle(cells):
return [[0 if cells[r * 9 + c] == "" else int(cells[r * 9 + c])
for c in range(9)] for r in range(9)]


def solve_sudoku(grid, limit=64):
solutions = []

def valid(g, r, c, v):
for i in range(9):
if g[r][i] == v or g[i][c] == v:
return False
br, bc = 3 * (r // 3), 3 * (c // 3)
for i in range(br, br + 3):
for j in range(bc, bc + 3):
if g[i][j] == v:
return False
return True

def backtrack(g):
if len(solutions) >= limit:
return
for r in range(9):
for c in range(9):
if g[r][c] == 0:
for v in range(1, 10):
if valid(g, r, c, v):
g[r][c] = v
backtrack(g)
g[r][c] = 0
return
solutions.append([row[:] for row in g])

backtrack([row[:] for row in grid])
return solutions


def check_solution(g):
want = set(range(1, 10))
for r in range(9):
if set(g[r]) != want:
return False
for c in range(9):
if {g[r][c] for r in range(9)} != want:
return False
for br in (0, 3, 6):
for bc in (0, 3, 6):
if {g[br + i][bc + j] for i in range(3) for j in range(3)} != want:
return False
return True


# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def parse_page(text):
puzzle = re.search(r'copy/paste form: <input type="text" value="([^"]*)"',
text).group(1)
cipher = re.search(r"Blowfish encrypted string:\s*([A-Za-z0-9+/=]+)",
text).group(1)
return puzzle, cipher


def main():
s = session()
page = fetch_level(s, LEVEL)
puzzle, cipher = parse_page(page)
cells = puzzle.split(",")
if len(cells) != 81:
raise SystemExit("expected 81 cells, got %d" % len(cells))
print("[*] puzzle :", puzzle)
print("[*] cipher :", cipher)

grid = parse_puzzle(cells)
sols = solve_sudoku(grid)
print("[*] solutions:", len(sols))

answer = None
for idx, sol in enumerate(sols):
assert check_solution(sol), "invalid sudoku solution"
solstr = ",".join(str(v) for v in sum(sol, []))
digest = hashlib.sha1(solstr.encode()).hexdigest()
# The server feeds the SHA1 *hex string* to keys(); keys() keeps the
# first 16 characters because the digest is longer than 16 bytes.
plain = blowfish_cbc_decrypt(cipher, digest.encode())
printable = all(32 <= b < 127 for b in plain)
print("[*] sol #%d sha1=%s -> %r (printable=%s)"
% (idx, digest, plain, printable))
if printable:
answer = plain.decode().rstrip(" ") # strip space padding
break

if not answer:
raise SystemExit("no printable plaintext — key derivation wrong")

print("[*] password :", answer)
if os.environ.get("HTS_DRY"):
print("[*] HTS_DRY set — skipping submission")
return
time.sleep(3)
ok, resp = submit(s, LEVEL, answer, field=FIELD)
print("[*] verdict :", ok)
print(body_text(resp)[-1200:])


if __name__ == "__main__":
main()
本题实例解出的明文密码是 f6hv(/(被服务端接受);该关卡每次加载都会换一组数独并现算一次性口令,所以实际提交必须用脚本在同一次请求内 fetch → 解 → 解密 → 提交。