HackThisSite - Programming Mission 9

posted
read
937 words · 3 min
contents

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 实现:它不是标准算法,也没有现成库能直接对上。pycryptodome 的 Blowfish 解不出正确明文,必须逐行复刻这份 PHP。下文先列出从源码中提取的三个关键约定,再给完整脚本。

Step 1: 算法约定

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

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

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

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 函数,也是这份实现最不常规之处:

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 和输出格式:

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 用。

Step 2: 求解数独

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

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 个解;完整实现见文末 完整脚本。

Step 3: 复刻 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)。

Script

"""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=...'
243 collapsed lines
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()