CodeShell.kr - Opcode Picnic

Challenge

stripped 的 x86-64 二进制,要求恢复被接受的 28 字节输入。难度在于校验函数每轮只依赖(下标,输入字节),但寄存器模型容易读错。

A stripped executable with a deliberately misleading fast path.

一个 stripped 可执行文件,带一条故意误导人的快速路径。

1
https://codeshell.kr/challenges/opcode-picnic/

Solution

附件是 stripped x86-64 PIE ELF。

Step 1:程序要求 strlen(argv[1]) == 28.rodata 里有两张表:0x2020 处的 28 字节逆序索引 27,26,...,00x2040 处的 28 字节期望值。

Step 2:每轮只依赖 (i, 输入字节),因此 28 个字节可以各自在 0..255 独立暴力。

Step 3:用真实二进制验证:附件没有可执行位,拷贝后运行。

1
2
3
4
$ cp assets/challenge-files/01_opcode_picnic_JsAiqDC.bin op.bin
$ chmod +x op.bin
$ ./op.bin 'CodeShell{branching_is_data}'
accepted

纯静态复现这套混合运算容易读错,实测踩过的坑:内层循环回跳目标是 mov edx,eax 那条而不是外层头部;输入字节只在第 1 轮进入;第 2–4 轮 xor edx,esi 用的是完整 32 位上一轮累加器而不是低字节;shr edx,cl 的 cl 取自增前的 edi*8rol dl,cl 只旋转低字节,cl 为 ((edi+i) mod 7) + 1r8r13 每外层轮重置。模型用 GDB 单步逐轮对齐后才一次通过。

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

Recovers the accepted 28-byte input from 01_opcode_picnic_JsAiqDC.bin.

Method: the checker loops i = 0..27, taking input[27-i] (a reversed index table
in .rodata), runs a 4-round mixing loop, and compares the low byte of the
accumulator against a 28-byte target table. The mixing loop is fully determined
by (i, input_byte), so each byte can be brute-forced independently in 0..255.

The register-level model below was validated against a live GDB trace of the
real binary (every intermediate value matched for a test input), not inferred
from the disassembly alone.

Usage:
uv run python solvers/opcode_picnic.py \
assets/challenge-files/01_opcode_picnic_JsAiqDC.bin
"""

import sys

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 check_byte(i, b):
"""Low byte of the accumulator for outer round i with input byte b."""
r10 = (i * 0x45D9F3B) & M32
r8 = 0
r13 = 0
edi = 0
eax = (r10 ^ 0x9F80C83A) & M32
esi = b & 0xFF
while True:
edx = eax
ecx = (eax >> 7) & M32
edx = (rol32(edx, 13) ^ ecx) & M32
eax = (eax ^ edx) & M32
eax = (eax ^ r8) & M32
r8 = (r8 + 0x7F4A7C15) & M32
edx = (eax >> ((edi * 8) & 31)) & M32
edx = (edx ^ esi) & M32
x = (edi + i) & M32
q = x // 7
rem = x - 7 * q
esi = ((eax >> 19) + r13 + rol8(edx & 0xFF, (rem + 1) & 7)) & M32
r13 = (r13 + 0xB) & M32
edi += 1
if edi == 4:
break
return esi & 0xFF


def solve(path):
data = open(path, "rb").read()
key = list(data[0x2020:0x203C]) # index table, 27..0
target = list(data[0x2040:0x205C]) # 28 expected bytes

out = [None] * len(target)
for i, idx in enumerate(key):
hits = [b for b in range(256) if check_byte(i, b) == target[i]]
if len(hits) != 1:
raise SystemExit(f"round {i}: {len(hits)} candidates {hits}")
out[idx] = hits[0]
return bytes(out)


if __name__ == "__main__":
p = sys.argv[1] if len(sys.argv) > 1 else \
"assets/challenge-files/01_opcode_picnic_JsAiqDC.bin"
flag = solve(p)
print("recovered:", flag.decode())
CodeShell{branching_is_data}