CodeShell.kr - Switchyard

Challenge

与 Opcode Picnic 出自同一生成器模板,只是常数不同。难度在于要先把模板认出来,再把常数抽成参数复用求解器。

The dispatcher is the puzzle. Recover the accepted input.

dispatcher 本身就是谜题。恢复被接受的输入。

1
https://codeshell.kr/challenges/switchyard/

Solution

附件与 Opcode Picnic 同构同尺寸。

复用同一套寄存器模型逐字节求解,得到 28 字节答案(与长度检查一致)。二进制回 accepted,错误输入返回非零退出码。

1
2
3
4
$ cp assets/challenge-files/02_switchyard_lqFsLaz.bin sw.bin
$ chmod +x sw.bin
$ ./sw.bin 'CodeShell{switches_hide_state}'
accepted

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
#!/usr/bin/env python3
"""CodeShell.kr — generic solver for the opcode-picnic binary family.

Family members seen so far (all 14472-byte stripped x86-64 PIE ELFs built from
the same generator, differing only in constants):

binary len rounds seed expected table
01 opcode-picnic 28 4 0x9f80c83a 0x2040
02 switchyard 28 ? ? 0x2040
03 glass-register 33 11 0x4a9a4ce9 0x2060
04 threaded-echo 32 17 0x8747b8df 0x2040
05 dead-branches 32 23 0x5996dc4d 0x2040
06 blackbox-vm 33 37 0xce9ca13f 0x2060

Structure (from the disassembly of 04, identical in shape everywhere):

strlen(argv[1]) == LEN else exit(2)
r10d = 0 ; i*0x45d9f3b accumulator
for i in 0..LEN-1:
b = input[index[i]] ; index[] = reversed table at 0x2020
eax = (i*0x45d9f3b) ^ SEED
r8 = 0; r13 = 0; edi = 0
repeat ROUNDS times:
n = edi + i
edx = rol32(eax,13) ^ (eax >> 7)
eax = eax ^ edx ^ r8
cl1 = (edi & 3) * 8
r8 += 0x7f4a7c15
edx = (eax >> cl1) ^ b
cl2 = (n % 7) + 1
esi = (eax >> 19) + r13
r13 += 0xb
edx = (edx & ~0xff) | rol8(edx & 0xff, cl2)
esi = esi + edx
edi += 1
acc = esi & 0xff
must equal expected[i] ; else ebx &= 0
ebx must be all ones, and the chained accumulator r15d != r13d
(the final `je rejected` is inverted: equal => rejected).

Because each round's result depends only on (i, b), every byte can be brute
forced independently in 0..255.
"""
import sys
from pathlib import Path

M32 = 0xffffffff


def rol32(x, c):
c &= 31
return ((x << c) | (x >> (32 - c))) & M32 if c else x & M32


def rol8(x, c):
c &= 7
return ((x << c) | (x >> (8 - c))) & 255 if c else x & 255


def round_byte(i, b, seed, rounds):
"""The 8-bit accumulator for byte index i holding value b.

Note the self-reference: `esi` starts as the input byte and is then
overwritten by the accumulator each round, so round k uses the accumulator
of round k-1 (this is the "threaded" part of the family). Only the low
byte of the shifted value enters the accumulator, not the full word.
"""
eax = ((i * 0x45D9F3B) & M32) ^ seed
r8 = r13 = 0
edi = 0
esi = b & 0xFF
for _ in range(rounds):
edx = (rol32(eax, 13) ^ (eax >> 7)) & M32
eax = (eax ^ edx ^ r8) & M32
r8 = (r8 + 0x7F4A7C15) & M32
edx = ((eax >> ((edi * 8) & 31)) ^ esi) & M32
n = (edi + i) & M32
rem = n - 7 * (n // 7)
esi = ((eax >> 19) + r13 + rol8(edx & 0xFF, (rem + 1) & 7)) & M32
r13 = (r13 + 0xB) & M32
edi += 1
return esi & 0xFF


def solve(binary, length, rounds, seed, idx_off, exp_off):
data = Path(binary).read_bytes()
index = list(data[idx_off:idx_off + length])
expected = list(data[exp_off:exp_off + length])
if sorted(index) != list(range(length)):
print(f" warning: index table is not a permutation of 0..{length-1}")
out = bytearray(length)
ambiguous = 0
for i in range(length):
hits = [b for b in range(256) if round_byte(i, b, seed, rounds) == expected[i]]
if len(hits) != 1:
ambiguous += 1
print(f" byte {i:2d}: {len(hits)} candidates {hits[:6]}")
out[i] = hits[0] if hits else 0
# the index table maps round i to input position index[i]
text = bytearray(length)
for i in range(length):
text[index[i]] = out[i]
return bytes(text), ambiguous


FAMILY = {
"01_opcode_picnic_JsAiqDC.bin": (28, 4, 0x9F80C83A, 0x2020, 0x2040),
"02_switchyard_lqFsLaz.bin": (30, 7, 0x37326173, 0x2020, 0x2040),
"03_glass_register_Ytqr6gK.bin": (33, 11, 0x4A9A4CE9, 0x2020, 0x2060),
"04_threaded_echo_eboZwXv.bin": (32, 17, 0x8747B8DF, 0x2020, 0x2040),
"05_dead_branches_EIpI3C4.bin": (32, 23, 0x5996DC4D, 0x2020, 0x2040),
"06_blackbox_vm_p1pFD61.bin": (33, 37, 0xCE9CA13F, 0x2020, 0x2060),
}


def main():
root = Path(__file__).resolve().parents[1] / "assets/challenge-files"
for name, (length, rounds, seed, io, eo) in FAMILY.items():
if rounds is None:
continue
text, amb = solve(root / name, length, rounds, seed, io, eo)
print(f"{name}: {text!r} ambiguous_bytes={amb}")


if __name__ == "__main__":
sys.exit(main())
CodeShell{switches_hide_state}