*CTF WP

GoGpt

随机种子用34

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
// solve.go
package main

import (
"encoding/base64"
"fmt"
"log"
"math/rand"
"time"
)

func main() {
src := []byte("cH@t_GpT_15_h3R3")

// 复现 main_shuffle:使用 seed=34,使用 math/rand 的 Rand.Intn 做 Fisher-Yates
r := rand.New(rand.NewSource(34))

// 做 Fisher-Yates(与题中循环等价)
// 创建一个可写的副本并打乱
buf := make([]byte, len(src))
copy(buf, src)
for i := len(buf) - 1; i > 0; i-- {
j := r.Intn(i + 1)
buf[i], buf[j] = buf[j], buf[i]
}

fmt.Printf("shuffled (hex): ")
for _, b := range buf {
fmt.Printf("%02x", b)
}
fmt.Printf("\nshuffled (ascii): %s\n", string(buf))

b64 := "fiAGBkgXN3McFy9hAHRfCwYaIjQCRDFsXC8ZYBFmEDU="
T, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
log.Fatalf("base64 decode failed: %v", err)
}
if len(T) != 32 {
log.Fatalf("unexpected decoded length: %d", len(T))
}

input := make([]byte, len(T))
for i := 0; i < len(T); i++ {
input[i] = T[i] ^ buf[i%len(buf)]
}

fmt.Printf("recovered input (bytes): %x\n", input)
fmt.Printf("recovered input (utf-8 / ascii): %s\n", string(input))

out := make([]byte, len(input))
for i := 0; i < len(input); i++ {
out[i] = input[i] ^ buf[i%len(buf)]
}
b2 := base64.StdEncoding.EncodeToString(out)
fmt.Printf("re-encoded base64: %s\n", b2)
fmt.Printf("match original base64? %v\n", b2 == b64)

_ = time.Now()
}

image-20250919212953445

*CTF{ch@tgpT_3nCRypt10n_4_FUN!!}

ez_code

$(@{}) 就是“把一个字面量哈希表传进子表达式运算符”。

在 PowerShell 里,对象转成字符串的时候会走它的 .ToString()

空哈希表 (@{}) 是 System.Collections.Hashtable,所以 "$(@{})" 展开成 "System.Collections.Hashtable"

同理,"$?" 在 PowerShell 里是布尔值上一次命令的返回结果(True/False),拼接到字符串时会变成 "True""False"

类似这样:可以发现最开头的那段规则:

看开头那一长串花括号的写法:

1
2
3
4
5
{ ${-``} = + $() } 
{ ${]} = ${-``} }
{ ${!;*} = ++ ${-``} }
{ ${*@ } = ( ${-``} = ${-``} + ${!;*} ) }
...
  • 第一个 { ${-``} = + $() } 是把 ${-``} 初始化为 0 (+$() 相当于 0)。
  • 接着 { ${]} = ${-``} }${]} 设成 ${-``},所以 ${]}=0。
  • 然后 { ${!;*} = ++ ${-``} } 是自增 ${-``},所以 ${!;*}=1。
  • 再后面每次出现 = ${-``} + ${!;*} 之类,都是用“上一个值+1”往后加,依次给 ${*@ }${=$``}${ ]}${!}${#.}……赋成 2、3、4、5、6…

也就是说,它在用这种写法构造了一个“变量名 → 数字”的映射表,刚好覆盖 0–9 十个数字。

再往下看

1
{ ${$%} = "[" + "$(@{  })"[${(}] + "$(@{  })"[  "${!;*}${``*%}"  ] + ... + "$?"[${!;*}] + "]" }

这段很绕,但本质是:

  • 开头有 "[",结尾有 "]"
  • 中间用 $(@{})[...]$?[] 去从 "System.Collections.Hashtable""True" 里切字符出来。
  • 拼成的整体恰好是 " [char] "

所以之后 ${$%}${!;*}${!} 这样的片段,其实就是 [char]51 这种形式。换句话说,${$%} 充当了字符串前缀 [char]

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import sys
from typing import Dict, List, Tuple

def parse_digit_mapping(src: str) -> Dict[str, str]:
"""
从开头花括号块自动抽取“奇怪变量名”→数字(0..9)映射
规则:
1) 找到把 ${-``} 初始化为0的地方(通常是 '+ $()')
2) 紧跟着把某个变量设为 ${-``} → 该变量=0
3) 再找 '++ ${-``}' 赋给另一个变量 → 该变量=1
4) 然后若干个 '( ${-``} = ${-``} + ${!;*} )' 形式把不同变量依次设为2..9
"""
# 预清理: 把所有空白压成单空格,便于正则匹配(不影响索引顺序判断)
s = re.sub(r"\s+", " ", src)

# 1) 找“初始化-0”
# 允许出现 '+ $()' 或等价写法;我们只需要它之前附近的块来确定顺序
init_idx = s.find("${-``} = + $()")
if init_idx == -1:
# 有些题会写成 '${-``} = +$()',再试一次更宽松匹配
m_init = re.search(r"\{\s*\$\{-``\}\s*=\s*\+\s*\$\(\s*\)\s*\}", s)
if not m_init:
raise ValueError("找不到 ${-``} 的初始化位置")
init_idx = m_init.start()

# 2) 在初始化之后,寻找“= ${-``}”的赋值作为 0
m_zero = re.search(r"\{\s*\$\{([^}]+)\}\s*=\s*\$\{-``\}\s*\}", s[init_idx:])
if not m_zero:
raise ValueError("找不到映射到0的变量(= ${-``})")
zero_var = "${" + m_zero.group(1) + "}"

# 3) 寻找“= ++ ${-``}”的赋值作为 1
m_one = re.search(r"\{\s*\$\{([^}]+)\}\s*=\s*\+\+\s*\$\{-``\}\s*\}", s[init_idx:])
if not m_one:
raise ValueError("找不到映射到1的变量(++ ${-``})")
one_var = "${" + m_one.group(1) + "}"

# 4) 之后按顺序寻找形如 '( ${-``} = ${-``} + ${!;*} )' 的赋值(把左侧变量依次记为2..9)
# 允许括号/空格变体,尽量宽松
pattern_inc = re.compile(
r"\{\s*\$\{([^}]+)\}\s*=\s*\(\s*\$\{-``\}\s*=\s*\$\{-``\}\s*\+\s*\$\{!;\*\}\s*\)\s*\}",
re.I)
inc_vars: List[str] = [ ]
for m in pattern_inc.finditer(s[init_idx:]):
inc_vars.append("${" + m.group(1) + "}")
if len(inc_vars) >= 8: # 需要补齐到2..9共8个
break
if len(inc_vars) < 8:
# 有些题把同样的“加一”赋值不包在括号里,做一次降级搜索
pattern_inc2 = re.compile(
r"\{\s*\$\{([^}]+)\}\s*=\s*\$\{-``\}\s*\+\s*\$\{!;\*\}\s*\}", re.I)
for m in pattern_inc2.finditer(s[init_idx:]):
v = "${" + m.group(1) + "}"
if v not in inc_vars:
inc_vars.append(v)
if len(inc_vars) >= 8:
break
if len(inc_vars) < 8:
raise ValueError(f"加一链条变量不足(只找到{len(inc_vars)}个)")

# 组装映射
mapping: Dict[str, str] = {}
mapping[zero_var] = "0"
mapping[one_var] = "1"
for i, v in enumerate(inc_vars, start=2):
mapping[v] = str(i)
return mapping

def extract_payload(src: str) -> str:
"""
提取 ${@*} = " ... " 里的字符串正文
"""
# 用非贪婪匹配,允许内部出现转义(此题一般没有)
m = re.search(r"\$\{@\*\}\s*=\s*\"(.*?)\"", src, re.S)
if not m:
raise ValueError("找不到 ${@*} 的字符串赋值")
return m.group(1)

def extract_char_prefix_token(src: str) -> str:
"""
识别 ${$%} 这种“[char]”前缀用到的变量名。
直接从 ${@*} 之前的定义处取左值变量名即可,无需解析其右边复杂拼接。
"""
m = re.search(r"\{\s*\$\{([^}]+)\}\s*=\s*\"?\[\s*\"?\s*\+", src)
# ↑ 这条匹配 `${$%} = "[" + ...` 这类开头;若题面略有不同可再放宽
if not m:
# 更宽: 找到某个 ${xxx} = "[" 开头的定义
m = re.search(r"\{\s*\$\{([^}]+)\}\s*=\s*\"\[\"", src)
if not m:
raise ValueError("找不到充当 [char] 前缀的变量定义")
return "${" + m.group(1) + "}"

def decode_payload(payload: str, char_prefix_var: str, digit_map: Dict[str, str]) -> str:
"""
在 payload 中扫描 `${<char_prefix>}<digit_vars...}` 片段:
每遇到一次 `${<char_prefix>}` 就从后面贪婪地吃尽能拼出数字(0..9的奇怪变量名串),
把得到的十进制整数转为对应 Unicode 字符。
"""
out_chars: List[str] = []
i = 0
L = len(payload)

# 为了更快查找,把digit_map按变量名长度降序排列,避免前缀冲突
digit_items = sorted(digit_map.items(), key=lambda kv: -len(kv[0]))

while i < L:
j = payload.find(char_prefix_var, i)
if j == -1:
break
k = j + len(char_prefix_var)
# 读紧随其后的“数字变量名串”
digits = []
moved = True
while moved and k < L:
moved = False
for var, dch in digit_items:
if payload.startswith(var, k):
digits.append(dch)
k += len(var)
moved = True
break
if digits:
val = int("".join(digits), 10)
try:
out_chars.append(chr(val))
except ValueError:
# 超出Unicode范围就跳过/也可抛错
pass
i = k
return "".join(out_chars)

def decode_obfuscated_text(src: str) -> Tuple[str, Dict[str, str]]:
digit_map = parse_digit_mapping(src)
payload = extract_payload(src)
char_var = extract_char_prefix_token(src)
decoded = decode_payload(payload, char_var, digit_map)
return decoded, digit_map

def main():
if len(sys.argv) == 2 and sys.argv[1] != "-":
with open(sys.argv[1], "r", encoding="utf-8", errors="ignore") as f:
src = f.read()
else:
# 把整段混淆文本粘到这里;或用: python3 script.py input.txt
OBFUSCATED = r"""
# 在此粘贴完整混淆代码(包含开头花括号块与 ${@*}="..." 这一行)
"""
src = OBFUSCATED

decoded, dmap = decode_obfuscated_text(src)
print("==== decoded ====")
print(decoded)
print("==== digit-map ====")
for k, v in dmap.items():
print(f"{k} -> {v}")

if __name__ == "__main__":
main()

解开来是

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
==== decoded ====
class chiper():
def __init__(self):
self.d = 0x87654321
k0 = 0x67452301
k1 = 0xefcdab89
k2 = 0x98badcfe
k3 = 0x10325476
self.k = [k0, k1, k2, k3]

def e(self, n, v):
from ctypes import c_uint32

def MX(z, y, total, key, p, e):
temp1 = (z.value >> 6 ^ y.value << 4) + \
(y.value >> 2 ^ z.value << 5)
temp2 = (total.value ^ y.value) + \
(key[(p & 3) ^ e.value] ^ z.value)
return c_uint32(temp1 ^ temp2)
key = self.k
delta = self.d
rounds = 6 + 52//n
total = c_uint32(0)
z = c_uint32(v[n-1])
e = c_uint32(0)

while rounds > 0:
total.value += delta
e.value = (total.value >> 2) & 3
for p in range(n-1):
y = c_uint32(v[p+1])
v[p] = c_uint32(v[p] + MX(z, y, total, key, p, e).value).value
z.value = v[p]
y = c_uint32(v[0])
v[n-1] = c_uint32(v[n-1] + MX(z, y, total,
key, n-1, e).value).value
z.value = v[n-1]
rounds -= 1
return v

def bytes2ints(self,cs:bytes)->list:
new_length=len(cs)+(8-len(cs)%8)%8
barray=cs.ljust(new_length,b'\x00')
i=0
v=[]
while i < new_length:
v0 = int.from_bytes(barray[i:i+4], 'little')
v1 = int.from_bytes(barray[i+4:i+8], 'little')
v.append(v0)
v.append(v1)
i += 8
return v

def check(instr:str,checklist:list)->int:
length=len(instr)
if length%8:
print("Incorrect format.")
exit(1)
c=chiper()
v = c.bytes2ints(instr.encode())
output=list(c.e(len(v),v))
i=0
while(i<len(checklist)):
if i<len(output) and output[i]==checklist[i]:
i+=1
else:
break
if i==len(checklist):
return 1
return 0

if __name__=="__main__":
ans=[1374278842, 2136006540, 4191056815, 3248881376]
# generateRes()
flag=input('Please input flag:')
res=check(flag,ans)
if res:
print("Congratulations, you've got the flag!")
print("Flag is *ctf{your_input}!")
exit(0)
else:
print('Nope,try again!')
==== digit-map ====
${]} -> 0
${!;*} -> 1
${*@ } -> 2
${=$``} -> 3
${ ]} -> 4
${!} -> 5
${#.} -> 6
${(} -> 7
${)``} -> 8
${``*%} -> 9

是个xxtea

解密代码:

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
# Implement the recovered cipher and solve for the plaintext that encrypts to the given 'ans'.
from ctypes import c_uint32

DELTA = 0x87654321
K = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]

def MX(z, y, total, key, p, e):
temp1 = (z.value >> 6 ^ y.value << 4) + (y.value >> 2 ^ z.value << 5)
temp2 = (total.value ^ y.value) + (key[(p & 3) ^ e.value] ^ z.value)
return c_uint32(temp1 ^ temp2)

def enc(v):
n = len(v)
key = K
delta = DELTA
rounds = 6 + 52//n
total = c_uint32(0)
z = c_uint32(v[n-1])
e = c_uint32(0)
v = [c_uint32(x).value for x in v]
while rounds > 0:
total.value = (total.value + delta) & 0xFFFFFFFF
e.value = (total.value >> 2) & 3
for p in range(n-1):
y = c_uint32(v[p+1])
v[p] = c_uint32(v[p] + MX(z, y, total, key, p, e).value).value
z.value = v[p]
y = c_uint32(v[0])
v[n-1] = c_uint32(v[n-1] + MX(z, y, total, key, n-1, e).value).value
z.value = v[n-1]
rounds -= 1
return v

def dec(v):
n = len(v)
key = K
delta = DELTA
rounds = 6 + 52//n
total = c_uint32((rounds * delta) & 0xFFFFFFFF)
e = c_uint32(0)
v = [c_uint32(x).value for x in v]
while total.value != 0:
e.value = (total.value >> 2) & 3
# p from n-1 down to 1
for p in range(n-1, 0, -1):
z = c_uint32(v[p-1])
y = c_uint32(v[(p+1) % n])
v[p] = c_uint32(v[p] - MX(z, y, total, key, p, e).value).value
# p = 0
z = c_uint32(v[n-1])
y = c_uint32(v[(0+1) % n])
v[0] = c_uint32(v[0] - MX(z, y, total, key, 0, e).value).value
total.value = (total.value - delta) & 0xFFFFFFFF
return v

def ints2bytes(v):
out = bytearray()
for i in range(0, len(v), 2):
out += int(v[i]).to_bytes(4, 'little')
out += int(v[i+1]).to_bytes(4, 'little')
return bytes(out)

def bytes2ints(cs: bytes):
# exact reimplementation of the challenge's bytes2ints padding
new_length = len(cs) + (8 - len(cs) % 8) % 8
barray = cs.ljust(new_length, b'\x00')
i = 0
v = []
while i < new_length:
v0 = int.from_bytes(barray[i:i+4], 'little')
v1 = int.from_bytes(barray[i+4:i+8], 'little')
v.append(v0); v.append(v1)
i += 8
return v

ans = [1374278842, 2136006540, 4191056815, 3248881376]

# Decrypt 2-block (4 words) ciphertext to get 16-byte plaintext
pt_words = dec(ans[:])

pt_bytes = ints2bytes(pt_words)

pt_words, pt_bytes, len(pt_bytes)

print(pt_words)
print('*CTF{' +pt_bytes.decode()+'}')

*CTF{yOUar3g0oD@tPw5H}

flagfile

flag.mgcfile二进制编译过的,里面包含一组“测试”:例如在某个偏移位置检查若干字节是否等于某值、或匹配某正则、或文件长度、或文件头等;如果某条测试通过,会输出 yes, it's a flag! 这样的描述。

  • 先按照 readme.txt 提示,用 file -m flag.mgc flag 验证时返回 “empty”,说明 flag.mgc 里是对 flag 内容的校验逻辑;strings flag.mgc 只看到固定提示文字,真正的判定数据必须解包。
  • 参考 file 的魔术文件格式,用 python 把 flag.mgc 当成结构体数组解析:前 0x178 字节是头部,其后每 0x178 字节一条规则。为了搞清字段含义,另外现写几份简单的 .magic,用 file -C 编译,再观察生成的 .mgc,逐项对照出各字段用途(类型码、偏移、间接寻址、运算符等)。
  • flag.mgc 的规则共有 66 条:第 0 条匹配 flag{ 开头,第 65 条匹配末尾描述字符串;132 号为 leshort 类型,给出每个字符的基准值;3364 号为 byte 类型,设置“运算值”。通过比较这些条目的 operand(0x18 处)与 result(0x20 处)可以看出是在做逐字节 XOR。
  • 编写脚本遍历这 32 组 ,执行 operand ^ result 就还原出真正的 flag 内部串,最后再补上 flag{ 与 } 即得到完整 flag。

开始并不知道 .mgc 的字段含义,只能自己做对照实验。具体操作是:

先写一个极简的 test.magic:

0 string foo test

用 file -C -m test.magic 编成 test.magic.mgc,接着用 xxd / python 读里面的 16 进制,观察 header(前 0x178 字节)之后再看第一条规则的 0x178 字节数据。

可以看到:

  • 偏移 0x00 处写着 0x00200000 等值,对应 flag 字段(ASCII 匹配时必须是 0x20)。

  • 偏移 0x04 的 0x0005033d 里,0x3d = ‘=’,说明比较符;

  • 偏移 0x06 的 0x05 就是类型码(string);

  • 偏移 0x0c 的 0x00000000 是 offset;

  • 偏移 0x20 开始的区域存放匹配字面量 “foo”。

  • 偏移 0x20 开始的区域存放匹配字面量 “foo”。

  • 再扩展出更多特性,比如 type_test.magic 里分别写 byte/ubyte/short/string/pstring/lelong,编译后用脚本打印每条记录的字段:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ENTRY_SIZE = 0x178

    buf = Path('type_test.magic.mgc').read_bytes()

    entry = buf[0x178 + idx*ENTRY_SIZE : 0x178 + (idx+1)*ENTRY_SIZE]

    print(entry[6]) # 类型码

    print(int.from_bytes(entry[12:16],'little')) # offset

    print(entry[32:40]) # 把具体常量读出来

    这样一来可以确认:类型从 byte 到 string 对应不同的 type code,flag 字段里 0x20 表示“匹配即成功”,0x28 表示无符号比较等。

  • 还写了 op_test.magic,里面放 byte^0x55、byte+0x10、byte-0x10 等语法,编译后读取同样的字段就能看出:

    • 0x18~0x1c 位置存运算数(例如 +0x10 就出现 0x10);
    • 0x20~0x24 是比较目标(运算完希望得到的值);
    • flag 还是 0x20,说明只是普通比较。
  • 处理间接寻址时,又写了 indir*.magic 系列,例如:

    0 string AB

    (0x200.b+64) byte =0x41 desc

    编译后观察第二条规则,发现 in_type = 1(表示开启了 (offset).b 的间接寻址),in_offset = 3 对应 +64 的表达式,offset = 512(即 0x200 << 3 位)。这样就知道 mgc 里是怎样记录 “取 (0x200.b + 64) 的地址” 的。

通过这些对照样本,把各字段到底存了什么都摸清楚以后,再去分析真正的 flag.mgc 就顺理成章了:看到 type=10(leshort)、type=1(byte),operand 与 result 都在我们实验确认过的位置,于是直接套公式 XOR 就还原出每个字符。整个过程就是靠自制最小化样例 + diff 的方式倒推格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pathlib import Path

ENTRY_SIZE = 0x178
buf = Path('flag.mgc').read_bytes()
flag_mid = []

for idx in range(33, 65):
entry = buf[0x178 + idx * ENTRY_SIZE: 0x178 + (idx + 1) * ENTRY_SIZE]
op = int.from_bytes(entry[0x18:0x1c], 'little', signed=True) & 0xff
res = int.from_bytes(entry[0x20:0x24], 'little', signed=True) & 0xff
flag_mid.append(op ^ res)

flag = "flag{" + ''.join(map(chr, flag_mid)) + "}"
print(flag)
1
flag{f_o_a__lhy_s_y^^hete_ug___goo_t_}

boring cipher

rust逆向

image-20250920145740216

这里有个方法,跟进

image-20250920151025875

下面的就是在做一个复杂的数据变换

这段循环正是把 64 位整数按阶乘数制(factoradic)展开成一个 21 元素的排列:

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
v4 = _byteswap_uint64(v3);          // 把当前 u64 (作为大端) 取出来
n20 = 20;
v6 = &n8; // n8 是静态数组 [0,1,2,...,20]
v7 = 0x21C3677C82B40000LL; // 20!,阶乘基
v8 = 0;

do {
while (1) {
if (!v7) panic(); // 防御:理论上不会零
// 下面根据 v7 是否大于 2^32 选择 64 位或 32 位除法,避免溢出
if ((v7 | v4) >> 32) {
v9 = v4;
v4 %= v7;
n0x15 = v8 + v9 / v7;
if (n0x15 >= 0x15) panic_bounds_check();
} else {
v11 = (uint32_t)v4 / (uint32_t)v7;
v4 = (uint32_t)v4 % (uint32_t)v7;
n0x15 = v8 + v11;
if (n0x15 >= 0x15) panic_bounds_check();
}

// 交换 n8[offset] 和 n8[offset + 商],典型的 factoradic 解码
v12 = n8[n0x15];
n8[n0x15] = *v6;
*v6 = v12;

// 如果 n20 或 v7 还大于 2^32,就继续用 64 位数据更新下一阶 factorial
if (!((n20 | v7) >> 32))
break;
v7 /= n20; // 下一步用 (当前 factorial)/(当前位数)
v6 = (__int128 *)((char *)v6 + 1);
++v8; // 相当于 offset++
if (!--n20)
goto LABEL_13; // 处理完 21 位,提前跳出
}

// 走到这里说明 (n20 | v7) fits 32 bit,再用 32 位除法更新下一轮参数
v7 = (unsigned int)v7 / (unsigned int)n20;
v6 = (__int128 *)((char *)v6 + 1);
++v8;
--n20;
} while (n20);

概念上可以按更直观的伪代码理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let mut slots = [0,1,2,...,20];
let mut base = factorial(20); // 20!
let mut offset = 0;

loop {
let q = value / base; // factoradic 的“这一位”
value %= base;

// 把 slots[offset] 和 slots[offset + q] 交换
slots.swap(offset, offset + q);

if n == 0 { break; } // 处理完 21 位
base /= n; // 下一个阶乘基
offset += 1;
n -= 1;
}

这段代码在把一个整数value映射成一个长度为21(0..20)的排列,用的是“阶乘数制/Lehmer码→排列”的经典过程,只是用swap实现“从剩余元素中取第q个”的动作。要点如下:

  1. slots初始化为[0,1,2,…,20],最终会被就地打乱成为目标排列。
  2. base从20!开始,每一步计算q=value/base,这就是当前位的factoradic数字(第几号剩余元素)。随后value%=base,丢掉已用的高位。
  3. 用slots.swap(offset,offset+q)把“剩余区间里第q个元素”换到当前位置offset。之所以只需一次swap,是因为之前的步骤保证了offset左侧都已固定,右侧保持相对顺序不变,因此offset右边的第q个恰是我们要选的元素。
  4. 更新步进:n从20递减到0,base依次除以n,于是base序列依次是20!,19!,…,1!,0!(最后一位),offset逐步右移。处理完n==0即21位后结束。
  5. 前提:允许的value范围是[0,21!-1],不同的value对应唯一的排列。

小例子(用4个元素便于看,即slots=[0,1,2,3],起始base=3!,n=3)。设value=13:

步骤0:base=6,q=13/6=2,swap(0,0+2)→[2,1,0,3],value=13%6=1,base/=n→6/3=2,offset=1,n=2

步骤1:base=2,q=1/2=0,swap(1,1)不变,[2,1,0,3],value=1%2=1,base/=n→2/2=1,offset=2,n=1

步骤2:base=1,q=1/1=1,swap(2,3)→[2,1,3,0],value=1%1=0,base/=n→1/1=1,offset=3,n=0

步骤3:base=1,q=0/1=0,swap(3,3)不变,结束

结果排列[2,1,3,0]。

这正是Lehmer码(2,0,1,0)对应的排列

(2,0,1,0)就是Lehmer码(factoradic表示法的另一种叫法),它的作用是:

把一个排列用一组整数唯一地编码,或者反过来把整数唯一地解码成一个排列。

假设我们要表示排列 [2,1,3,0],元素是 0..3

Lehmer码的定义:第 i 个数字 = 在排列的第 i 个位置上,比它小的剩余元素的个数

算一下:

  1. 第0位 = 元素2,剩余集合 {0,1,3} 中有两个比2小 → 2
  2. 第1位 = 元素1,剩余集合 {0,3} 中有0个比1小 → 0
  3. 第2位 = 元素3,剩余集合 {0} 中有1个比3小 → 1
  4. 第3位 = 元素0,剩余集合 {} → 0

可以通过[2,1,3,0]可以还原最初的value

1
2×3! + 0×2! + 1×1! + 0×0! = 12 + 0 + 1 + 0 = 13
1
2
3
4
5
6
[0,1,2,3] → 排名 0
[0,1,3,2] → 排名 1
[0,2,1,3] → 排名 2
...
[2,1,3,0] → 排名 13
...

所以说是第13个排列

用途

  1. 排列和整数的双射
    • Lehmer码是“factoradic”的直观形式,可以把一个排列映射成唯一的整数(排列的字典序排名),也能从整数还原回排列。
    • 比如 (2,0,1,0) 代表整数 2×3! + 0×2! + 1×1! + 0×0! = 13
    • 所以它把“第13个排列”和 [2,1,3,0]一一对应起来。
  2. 压缩存储
    • 存储排列可以只存 Lehmer码,节省空间(因为码值范围是逐步减小的)。
    • 对于 n 元素的排列,Lehmer码的各位范围是 0..(n-i-1)
  3. 组合数学和随机化
    • 用来快速生成第 k 个排列;
    • 用来做哈希/映射(比如把排列当成一个大整数键)。
  4. 算法设计:在很多搜索/枚举问题里,可以直接用 Lehmer码来作为排列的“序号”,避免存储所有排列。

后面有一大段

image-20250920152942822

看到“84 行”是因为后面处理排列时,把它们当成索引去访问一张在 .rodata 里的大表(就是 asc_3F06C 那块数据)。关键代码在这段:

1
2
3
4
5
6
7
v13 = 21 * n4_1;        // n4_1 在外层循环里是 0,1,2,3
for (i = 0; i != 21; ++i) {
n0x53 = v13 + *((unsigned __int8 *)&n8_2 + i);
...
n0xFF = *(unsigned int *)&asc_3F06C[60 * n0x53];
...
}

解释一下:

  1. 外层循环 n4_1 从 0 到 3,一共 4 个区块。
  2. 每个区块对 i = 0..20(共 21 个)操作。
  3. n8_2[i] 是前面那段“阶乘数制”转换出的排列里的第 i 个元素,它的取值范围是 0..20。
  4. n0x53 = 21 * block + perm[i],所以 n0x53 的可能取值就是 0..83(21*4 = 84 个组合)。
  5. 随后用 n0x53 去访问 asc_3F06C,每行占 0x3C=60 字节(15 个小索引,每个 4 字节,最后不足补 0xFFFFFFFF)。我们之前在脚本里把那块数据读出来,也正好读了 84 * 60 字节。

因此,所谓“84 行查表”指的就是这一段:四个 21 元素的排列组合成了 84 个行索引,每个索引对应 asc_3F06C 里的一行用来更新 256 字节的加法表。这样一来,阶乘数制得出的排列就直接决定了哪些 row 会被加权,从而最终决定整个加密过程。

这里有个写操作,写到output

image-20250920153550339

题目逻辑

  • 读入数据:程序的真正入口在 encrypt_self_to_output(cipher-release:0x8940)。它从标准输入一次性读取 32 字节,必须正好读满,否则直接 panic。
  • 拆分与“阶乘数制”展开:把这 32 字节按 4 组 u64(大端)处理。每组数字通过阶乘数制(factoradic)展开,生成长度为 21 的排列,这里实际做的是:
    1. 初始有数组 [0,1,2,…,20]。
    2. 以 20!、19!、… 的权重不断整除,拿到的商当作当前位置要跟谁交换的索引。
    3. 最终得到 21 个“权值”——其实是 Row 的索引,这 21 个索引出现次数恰好覆盖 0..20 各一次。
  • 构造加法表:二进制里有一张 84 行、最多 15 列的查表(0x3f06c 起 84*60 字节,每行不足的填 0xFFFFFFFF)。四个排列正好一一对应这 84 行(4×21)。程序遍历排列里的第 i 个元素,把该行中的所有字节索引 idx 在一个 256 字节表 T 中累加 i(mod 256)。这样最终形成本题的核心“加法表”。
  • 自加密:程序打开 /proc/self/exe,读到内存后,对文件除最后 1 字节外的每个字节 b 做 b += T[b](同样 mod 256),然后把结果写到当前目录 ./output。

解题思路

  1. 既然给了生成后的 output,那就反推出加法表 T。显然:
    • 遍历原始 cipher-release 和目标 output 的每个字节 (o, n),差值 (n - o) mod 256 就是 T[o]。
    • 因为最后 1 字节没动,这一步会发现有些字节差值为 0;还有的字节可能出现多个差值,需要看高频值取众数即可。
  2. T 有 256 项,但我们的未知变量只有 84 个(每行一个“权值”)。把 T 看作 84 个行向量(每行给 0/1 标记表示该行负责哪些字节),求和即可。数学上是解线性方程组 A·w = v,而 A 是 256×84 的 0/1 矩阵。计算时直接用法方程 AᵀA w = Aᵀv,再用 Gauss–Jordan 消元(使用分数避免精度

总体来说,这题核心难点在于认出表结构 + 阶乘数制,并通过线性代数把排列反推出原始输入。只要理解数据流,按上面步骤可以稳稳复现。

对数学知识的考验了

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
#!/usr/bin/env python3
from collections import Counter
from fractions import Fraction
from math import factorial
import struct
from pathlib import Path

BASE = Path(__file__).resolve().parent
ORIG = BASE / "cipher-release"
MUT = BASE / "output"

original = ORIG.read_bytes()
mutated = MUT.read_bytes()
if len(original) != len(mutated):
raise SystemExit("cipher-release and output must have identical sizes")

# 1. recover byte-add table
counts = {b: Counter() for b in range(256)}
for ob, nb in zip(original, mutated):
counts[ob][(nb - ob) & 0xFF] += 1

v47 = [0] * 256
for b, counter in counts.items():
if counter:
v47[b] = counter.most_common(1)[0][0]

# 2. load 84×15 table rows
with ORIG.open("rb") as f:
f.seek(0x3F06C)
raw = f.read(84 * 60)

rows, row_sets = [], []
for i in range(84):
row = struct.unpack("<15I", raw[i * 60 : (i + 1) * 60])
row = [x for x in row if x != 0xFFFFFFFF]
rows.append(row)
row_sets.append(set(row))

# 3. solve normal equations (AᵀA w = Aᵀv) via Gauss–Jordan
n = 84
ATA = [
[Fraction(len(row_sets[i] & row_sets[j])) for j in range(n)]
for i in range(n)
]
ATv = [Fraction(sum(v47[idx] for idx in rows[i])) for i in range(n)]

for col in range(n):
pivot = next((r for r in range(col, n) if ATA[r][col]), None)
if pivot is None:
raise SystemExit(f"Singular matrix at column {col}")

if pivot != col:
ATA[col], ATA[pivot] = ATA[pivot], ATA[col]
ATv[col], ATv[pivot] = ATv[pivot], ATv[col]

piv = ATA[col][col]
ATA[col] = [val / piv for val in ATA[col]]
ATv[col] /= piv

for r in range(n):
if r == col:
continue
factor = ATA[r][col]
if factor:
ATA[r] = [val - factor * ATA[col][c] for c, val in enumerate(ATA[r])]
ATv[r] -= factor * ATv[col]

weights = [int(ATv[i]) for i in range(n)]

# 4. invert weights → permutations
perms = []
for block in range(4):
block_weights = weights[block * 21 : (block + 1) * 21]
if sorted(block_weights) != list(range(21)):
raise SystemExit(f"Unexpected weights in block {block}")

perm = [0] * 21
for row_idx, weight in enumerate(block_weights):
perm[weight] = row_idx
perms.append(perm)

# 5. convert permutations back to factorial integers
def perm_to_value(final_arr):
arr = list(range(21))
value = 0
v7 = factorial(20)
n20 = 20

for offset in range(21):
target = final_arr[offset]
idx = arr.index(target)
q = idx - offset
value += q * v7

arr[offset], arr[idx] = arr[idx], arr[offset]

if n20 == 0:
break

if ((n20 | v7) >> 32) != 0:
v7 //= n20
else:
v7 //= n20

n20 -= 1

return value


values = [perm_to_value(perm) for perm in perms]
flag = b"".join(val.to_bytes(8, "big") for val in values)
print(flag.decode())

*ctf{b0rIn9_67hdnm_cIph3ri_7292}