CodeShell.kr - Involution

Challenge

4096 条 8 字节指令的字节码 VM,题面要求「找不动点」。难点在于终态不是全零,而且 yes/no 字符串的顺序与跳转方向相反。

Find the fixed point

找到不动点。

1
https://codeshell.kr/challenges/infinity-involution/

Solution

contract.txt:Linux x86-64,stdin 收 64 个小写十六进制字符,提交 CodeShell{SHA256(bytes.fromhex(accepted_input))}

Step 1:64 个十六进制字符解析成 8 个小端 dwordstate = (state << 4) | nibble, 所以第一个字符是最高位 nibble)。.rodata+0x20 是一个 0x8000 字节的字节码程序 = 4096 条 8 字节指令,每条 [op, A, B, C, imm32]。一个 LCG(种子 0x0d5203ce、 乘数 0x19660d、增量 0x3c6ef35f)每条指令供 2 bit,真实 opcode = op ^ (lcg & 3)

1
2
3
4
5
op 0: r8 = imm ^ state[B];  state[A] += r8
op 1: r8 = imm ^ state[A]; state[A] = rol32(state[B], C) ^ r8
op 2: state[A] = rol32(state[A], C) ^ imm
op 3: t = state[A]; r8 = imm ^ state[B]; state[A] = r8; state[B] = t
op > 3: no-op

r8 每条指令都从 imm 字段重载,不跨指令传递,这一点容易读错。

Step 2:两个反直觉之处。其一,终态不是全零:最终检查把 state 与 .rodata+0xa020 的 32 字节常量异或后整体取或,只有结果为 0 才通过:

1
target state = ba4b8150 54240503 a6b845c2 9d50d3de c7644c06 8a6504ed 9038a228 b1d3a188

其二,.rodata+4"no".rodata+7"yes",而 cmovne rdi,rax 在非零时取 0x2004,所以全零状态会被拒绝(实测输入 64 个 0 得到 no)。

Step 3:四个 op 全是 256 位状态上的双射,所以不搜索,从目标态倒着跑即得初态:

1
2
3
4
op 0 逆: state[A] -= (imm ^ state[B])
op 1 逆: state[A] = rol32(state[B], C) ^ imm ^ state[A]
op 2 逆: state[A] = ror32(state[A] ^ imm, C)
op 3 逆: old_A = state[B]; old_B = state[A] ^ imm

正向模型先用 GDB 对齐(全零输入下 Python 与二进制在最终检查点处的 8 个 dword 完全一致),反向后再正向跑一遍确认回到目标态。

1
2
3
4
$ printf '88eebdb36f49bc3ed8c6de9ae0c2967b3adb6ac62a922243cadcee2a29606496\n' | ./involution
yes # rc=0
$ printf '88eebdb36f49bc3ed8c6de9ae0c2967b3adb6ac62a922243cadcee2a29606497\n' | ./involution
no # rc=1

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
#!/usr/bin/env python3
"""CodeShell.kr — infinity-involution (Reversing, 1600p) solver.

contract.txt: "Linux x86-64. The program accepts 64 lowercase hexadecimal
characters on stdin. Submit CodeShell{SHA256(bytes.fromhex(accepted_input))},
lowercase hex." Challenge title: "Find the fixed point".

Program structure (from the disassembly of `involution`)
-------------------------------------------------------
1. fgets(stdin, 0x50) into a 0x20-byte stack buffer; strcspn(..., "\\n") must
be 0x40 -> exactly 64 chars.
2. Those 64 hex chars are parsed into 8 little-endian dwords: state[0..7].
3. A bytecode program of 0x8000 bytes at .rodata+0x20 = 4096 instructions of
8 bytes. Each instruction is [op, A, B, C, imm32]. An LCG
(seed 0x0d5203ce, multiplier 0x19660d, increment 0x3c6ef35f) supplies two
bits per instruction, and the real opcode is `op ^ (lcg & 3)`:

op 0: r8 = imm ^ state[B]; state[A] += r8
op 1: r8 = imm ^ state[A]; state[A] = rol32(state[B], C) ^ r8
op 2: state[A] = rol32(state[A], C) ^ imm
op 3: t = state[A]; r8 = imm ^ state[B]; state[A] = r8; state[B] = t
op > 3: no-op

(`r8` is reloaded from the imm field every instruction, so it is not
carried across instructions.)
4. The program is "accepted" only when all eight state words are zero.

Every op above is a bijection on the 256-bit state, so instead of searching
for an input we run the whole program BACKWARDS from the all-zero state; the
resulting state is the required input. That is the "fixed point" the title
refers to (f(x) = 0 with f invertible).
"""
import hashlib
import sys
from pathlib import Path

M32 = 0xFFFFFFFF
LCG_SEED = 0x0D5203CE
LCG_MUL = 0x19660D
LCG_INC = 0x3C6EF35F
PROG_OFF = 0x2020
PROG_LEN = 0x8000


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


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


def decode(data):
"""Return the list of (op, A, B, C, imm) with the LCG-XORed opcode resolved."""
prog = data[PROG_OFF:PROG_OFF + PROG_LEN]
lcg = LCG_SEED
out = []
for i in range(0, len(prog), 8):
op, a, b, c = prog[i], prog[i + 1], prog[i + 2], prog[i + 3]
imm = int.from_bytes(prog[i + 4:i + 8], "little")
real = op ^ (lcg & 3)
lcg = (lcg * LCG_MUL + LCG_INC) & M32
out.append((real, a, b, c, imm))
return out


def forward(insns, state):
s = list(state)
for (op, A, B, C, imm) in insns:
if op == 0:
r8 = (imm ^ s[B]) & M32
s[A] = (s[A] + r8) & M32
elif op == 1:
r8 = (imm ^ s[A]) & M32
s[A] = (rol32(s[B], C) ^ r8) & M32
elif op == 2:
s[A] = (rol32(s[A], C) ^ imm) & M32
elif op == 3:
t = s[A]
r8 = (imm ^ s[B]) & M32
s[A] = r8
s[B] = t
return s


def backward(insns, state):
"""Invert the program: given the final state, return the initial state."""
s = list(state)
for (op, A, B, C, imm) in reversed(insns):
if op == 0:
# s[A] = old_A + (imm ^ s[B]) -> old_A = s[A] - (imm ^ s[B])
r8 = (imm ^ s[B]) & M32
s[A] = (s[A] - r8) & M32
elif op == 1:
# s[A] = rol32(s[B], C) ^ imm ^ old_A
s[A] = (rol32(s[B], C) ^ imm ^ s[A]) & M32
elif op == 2:
# s[A] = rol32(old_A, C) ^ imm
s[A] = ror32((s[A] ^ imm) & M32, C)
elif op == 3:
# s[A] = imm ^ old_B ; s[B] = old_A
old_a = s[B]
old_b = (s[A] ^ imm) & M32
s[A] = old_a
s[B] = old_b
return s


def main():
path = (sys.argv[1] if len(sys.argv) > 1 else
Path(__file__).resolve().parents[1] /
"extracted/infinity-involution/involution")
data = Path(path).read_bytes()
insns = decode(data)

from collections import Counter
print("opcode histogram:", dict(sorted(Counter(i[0] for i in insns).items())))

# The target is NOT the zero state: the final check XORs the state with the
# 32 bytes at .rodata+0xa020 (two XMM constants) and prints "yes" (at
# .rodata+7) only when the combined OR is zero. .rodata+4 is "no", so an
# all-zero state is actually REJECTED.
target = [int.from_bytes(data[0xA020 + 4 * i:0xA024 + 4 * i], "little")
for i in range(8)]
print("target state:", ["%08x" % w for w in target])

start = backward(insns, target)
end = forward(insns, start)
assert end == target, "forward/backward are not consistent"

# The hex parser accumulates each dword as (state << 4) | nibble, so the
# first character is the MOST significant nibble: the accepted input string
# is the big-endian hex of each dword, not the memory order.
input_hex = b"".join(w.to_bytes(4, "big") for w in start).hex()
print("accepted input (64 lowercase hex):", input_hex)
blob = bytes.fromhex(input_hex)
print(f"ANSWER CodeShell{{{hashlib.sha256(blob).hexdigest()}}}")


if __name__ == "__main__":
main()
CodeShell{a2abc1aad4440dc013b94271569f21985e344d930e97d8a304b6909aca12c331}