Seccon 2023

jumpout

发现一堆jump(xxx)

用了一些混淆通过jump rax ,jump rcx的方式,本题看了一下好像代码量并不大,直接看汇编即可

unk_4030是明文,unk_4010是估计异或的key

动调下就行了

1
2
3
4
5
key = [0xF6,0xF5,0x31,0xC8,0x81,0x15,0x14,0x68,0xF6,0x35,0xE5,0x3E,0x82,0x9,0xCA,0xF1,0x8A,0xA9,0xDF,0xDF,0x33,0x2A,0x6D,0x81,0xF5,0xA6,0x85,0xDF,0x17]
enc = [0xF0,0xE4,0x25,0xDD,0x9F,0xB,0x3C,0x50,0xDE,0x4,0xCA,0x3F,0xAF,0x30,0xF3,0xC7,0xAA,0xB2,0xFD,0xEF,0x17,0x18,0x57,0xB4,0xD0,0x8F,0xB8,0xF4,0x23]

for i in range(len(enc)):
print(chr(enc[i] ^ key[i] ^ i ^ 0x55),end='')

SECCON{jump_table_everywhere}

Sickle

通过b = pickletools.dis(payload)方法可以反汇编

下面这段是处理输入

1
2
3
4
5
6
7
8
9
10
11
12
13
 0: \x8c SHORT_BINUNICODE 'builtins'
10: \x8c SHORT_BINUNICODE 'getattr'
19: \x93 STACK_GLOBAL
...
22: \x8c SHORT_BINUNICODE 'builtins'
32: \x8c SHORT_BINUNICODE 'input'
39: \x93 STACK_GLOBAL
...
49: R REDUCE
...
59: R REDUCE
...
62: \x94 MEMOIZE (as 1)

下面是检查输入长度

1
2
3
4
5
6
7
8
228: \x8c SHORT_BINUNICODE 'builtins'
238: \x8c SHORT_BINUNICODE 'len'
243: \x93 STACK_GLOBAL # 栈顶现在是 len 函数
244: g GET 1 # 从 memo[1] 获取用户输入的字节串,推到栈顶
247: \x85 TUPLE1 # 把栈顶的字节串打包成一个元组
248: R REDUCE # 执行 len(用户输入的字节串),结果(一个整数)被推到栈顶
249: M BININT2 64 # 把数字 64 推到栈顶
252: \x86 TUPLE2 # 把栈顶的两个数字(长度和64)打包成元组

另外注意到中间有pow,65537,大数可能跟RSA有关

注意到进行pow之前还有个xor操作

因此可以得到以下的代码,注意的是这种分析方法无法分析出xor_key = chunk,key是变化的,只能靠最后的猜测或者对密码的了解(因为不换key解出来的是seccon开头的后面却是乱码,可以猜测key变了)

XOR 所用的密钥并不是一成不变的。在加密完第一个数据块后,用于加密 下一个 数据块的密钥,会变成 上一个 数据块加密后的 密文 。这是一种经典的加密模式,叫做 密码块链接(CBC) ,它可以防止相同的明文块产生相同的密文块,从而提高安全性。

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
from calendar import c
import pickle

payload = b'\x8c\x08builtins\x8c\x07getattr\x93\x942\x8c\x08builtins\x8c\x05input\x93\x8c\x06FLAG> \x85R\x8c\x06encode\x86R)R\x940g0\n\x8c\x08builtins\x8c\x04dict\x93\x8c\x03get\x86R\x8c\x08builtins\x8c\x07globals\x93)R\x8c\x01f\x86R\x8c\x04seek\x86R\x94g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__add__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__mul__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x06__eq__\x86R\x940g3\ng5\n\x8c\x08builtins\x8c\x03len\x93g1\n\x85RM@\x00\x86RM\x05\x01\x86R\x85R.0g0\ng1\n\x8c\x0b__getitem__\x86R\x940M\x00\x00\x940g2\ng3\ng0\ng6\ng7\n\x85R\x8c\x06__le__\x86RM\x7f\x00\x85RMJ\x01\x86R\x85R.0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM@\x00\x86RMU\x00\x86RM\"\x01\x86R\x85R0g0\ng0\n]\x94\x8c\x06append\x86R\x940g8\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\nfrom_bytes\x86R\x940M\x00\x00p7\n0g9\ng11\ng6\n\x8c\x08builtins\x8c\x05slice\x93g4\ng7\nM\x08\x00\x86Rg4\ng3\ng7\nM\x01\x00\x86RM\x08\x00\x86R\x86R\x85R\x8c\x06little\x86R\x85R0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RMw\x00\x86RM\xc9\x01\x86R\x85R0g0\n]\x94\x8c\x06append\x86R\x940g0\ng12\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__xor__\x86R\x940I1244422970072434993\n\x940M\x00\x00p7\n0g13\n\x8c\x08builtins\x8c\x03pow\x93g15\ng10\ng7\n\x85Rg16\n\x86RI65537\nI18446744073709551557\n\x87R\x85R0g14\ng7\n\x85Rp16\n0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RM\x83\x00\x86RM\xa7\x02\x86R\x85R0g0\ng12\n\x8c\x06__eq__\x86R(I8215359690687096682\nI1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R.'

class Solver:
def __init__(self):
self.encrypted_chunks = [
8215359690687096682,
1862662588367509514,
8350772864914849965,
11616510986494699232,
3711648467207374797,
9722127090168848805,
16780197523811627561,
18138828537077112905,
]
self.e = 65537
self.n = 18446744073709551557

def solve(self):
phi_n = self.n - 1
d = pow(self.e, -1, phi_n)
xor_key = 1244422970072434993

decrypted_bytes = b""
for chunk in self.encrypted_chunks:
decrypted_chunk_int = pow(chunk, d, self.n)
original_int = decrypted_chunk_int ^ xor_key
decrypted_bytes += original_int.to_bytes(8, 'little')
xor_key = chunk

print(decrypted_bytes)
return decrypted_bytes.rstrip(b'\x00').decode('latin-1')

solver = Solver()
flag = solver.solve()
print(f"Flag: {flag}")

Flag: SECCON{Can_someone_please_make_a_debugger_for_Pickle_bytecode??}

我们通过dis不能看到完整代码的原因在这

1
261: .    STOP

要把payload里在运行期永远跳不到的STOP字节先改掉,才能让pickletools把后面的字节继续“线性反汇编”。也就是说,做了一个离线分析用的补丁:把偏移261处的.(STOP,0x2e)改成N(NONE,0x4e),再次dis才看到第二段指令流;同理偏移330处再改一次,才能继续看到第三段。这样做只是为了让反汇编不中途停止,真实执行时之所以不会触发这些STOP,是因为前面构造了f.seek(...)的“跳转”,反序列化过程会把流位置挪到别处,根本不会线性走到这些STOP。

1
118: \x8c SHORT_BINUNICODE 'seek'

之后改完后还会报错

在这道 CTF 题里,出题人故意“滥用”了 pickle,重复写 memo[7] 来做某种控制流/数据流的技巧。真实执行时,Python 的 pickle.load 是允许覆盖的,不会出错;只是 dis 工具觉得“不规范”,所以停下来了。

改成如下这样即可

image-20250901145617940

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ... 省略了切片和 from_bytes 的指令 ...
# 栈顶现在是 flag 的第一个 8 字节块转换成的整数

458: g GET 13 # 从 memo[13] 获取初始 XOR 密钥 (1244422970072434993)
461: \x86 TUPLE2 # 打包 (块整数, XOR密钥)
462: R REDUCE # 执行 XOR 操作

# ... 省略了 RSA 加密的指令 ...
# 栈顶现在是 RSA 加密后的结果

642: g GET 12 # 从 memo[12] 获取硬编码的密文列表
645: M BININT2 0 # 推入数字 0
648: \x8c SHORT_BINUNICODE '__getitem__' # 获取列表的 __getitem__ 方法 (即 [])
659: \x86 TUPLE2
660: R REDUCE # 执行 list[0],获取第一个硬编码的密文
661: \x94 MEMOIZE (as 17) # 将这个密文存入 memo[17]

# ... 比较栈顶的两个值(加密结果和硬编码密文)...

在第一次循环的结尾,硬编码的第一个密文 8215359690687096682 被存入了 memo[17] 。

1
2
3
4
5
6
# ... 第二次循环开始,切片、转换字节 ...
# 栈顶现在是 flag 的第二个 8 字节块转换成的整数

701: g GET 17 # <--- 关键点!从 memo[17] 获取密钥
704: \x86 TUPLE2 # 打包 (块整数, 新的XOR密钥)
705: R REDUCE # 执行 XOR

在第 701 行,程序没有像第一次那样去 GET 13 (获取初始密钥),而是去 GET 17 。而 memo[17] 里存的,正是上一次循环中用来做比较的那个 硬编码的密文 !

当然如果这个看不出来还可以再patch一下,打印出详细的堆栈信息。

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
import importlib,io,pickle,pickletools

#1)先修补STOP→NONE(按题解两处)
def patch_stops(data,stops=(261,330)):
b=bytearray(data)
for off in stops:
assert b[off]==0x2e #'.'
b[off]=0x4e #'N'
return bytes(b)

#2)把STACK_GLOBAL解析成真正对象
def resolve_stack_global(mod_name,attr_name):
mod=importlib.import_module(mod_name)
return getattr(mod,attr_name)

#3)把栈打印成人可读的样子
def qname(obj):
try:
m=getattr(obj,'__module__',None)
n=getattr(obj,'__name__',None)
if m and n:
return f"{m}.{n}"
except Exception:
pass
return repr(obj)

def stack_repr(stack):
return "[ "+", ".join(qname(x) for x in stack)+" ]"

#4)一个最小可用的pickle栈模拟器(覆盖本题所需指令)
def dis_with_stack(data):
stack=[]
memo={}
mark=pickletools.markobject

for opcode,arg,pos in pickletools.genops(data):
name=opcode.name

#====执行指令对栈的影响====
if name in ("BINUNICODE","SHORT_BINUNICODE"):
stack.append(arg)
elif name=="STACK_GLOBAL":
attr=stack.pop();mod=stack.pop()
stack.append(resolve_stack_global(mod,attr))
elif name=="DUP":
stack.append(stack[-1])
elif name in ("EMPTY_TUPLE","EMPTY_LIST"):
stack.append( () if name=="EMPTY_TUPLE" else [] )
elif name in ("TUPLE1","TUPLE2","TUPLE3"):
n=int(name[-1])
items=stack[-n:];del stack[-n:]
stack.append(tuple(items))
elif name in ("APPEND",):
item=stack.pop(); lst=stack.pop(); lst.append(item); stack.append(lst)
elif name in ("PUT","BINPUT","LONG_BINPUT","MEMOIZE"):
if name=="MEMOIZE":
idx=len(memo)
else:
idx=arg
#放宽:不做“重复key”报错
memo[idx]=stack[-1]
elif name in ("GET","BINGET","LONG_BINGET"):
stack.append(memo[arg])
elif name=="MARK":
stack.append(mark)
elif name=="REDUCE":
args=stack.pop(); fn=stack.pop()

#安全处理:不要真的调用input
fn_qual=qname(fn)

#case1:builtins.input('FLAG> ')->'flag_str'
if fn_qual=="builtins.input":
stack.append("flag_str")
#继续

#case2:getattr(flag_str,'encode')->占位“method:flag_str.encode”
elif fn is getattr and isinstance(args,tuple) and len(args)==2 and args[0]=="flag_str" and args[1]=="encode":
stack.append(("method","flag_str.encode"))

#case3:调用上一步得到的绑定方法().->'flag_bytes'
elif isinstance(fn,tuple) and fn==("method","flag_str.encode") and args==():
stack.append("flag_bytes")

#其余安全内建(按需要可扩)
elif fn in (getattr,globals,dict.get,int.__add__,int.__mul__,int.__eq__,int.__xor__,int.from_bytes,pow):
try:
stack.append(fn(*args))
except Exception as e:
stack.append(f"{fn_qual}{args}=>{e.__class__.__name__}")
else:
#未知或潜在危险调用:保持符号形式,避免执行
stack.append(f"{fn_qual}{args}")

elif name=="STOP":
pass
else:
#如果遇到没实现的指令,保守起见不改变栈,只打印提示
#你可以按需要补充更多分支
pass

#====打印一行:偏移、指令名、参数、栈快照====
if arg is None:
print(f"{pos:>5}: {name:<16}; {stack_repr(stack)}")
else:
print(f"{pos:>5}: {name:<16} {repr(arg):<20}; {stack_repr(stack)}")

#==============示例使用==============
payload = b'\x8c\x08builtins\x8c\x07getattr\x93\x942\x8c\x08builtins\x8c\x05input\x93\x8c\x06FLAG> \x85R\x8c\x06encode\x86R)R\x940g0\n\x8c\x08builtins\x8c\x04dict\x93\x8c\x03get\x86R\x8c\x08builtins\x8c\x07globals\x93)R\x8c\x01f\x86R\x8c\x04seek\x86R\x94g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__add__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__mul__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x06__eq__\x86R\x940g3\ng5\n\x8c\x08builtins\x8c\x03len\x93g1\n\x85RM@\x00\x86RM\x05\x01\x86R\x85R.0g0\ng1\n\x8c\x0b__getitem__\x86R\x940M\x00\x00\x940g2\ng3\ng0\ng6\ng7\n\x85R\x8c\x06__le__\x86RM\x7f\x00\x85RMJ\x01\x86R\x85R.0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM@\x00\x86RMU\x00\x86RM"\x01\x86R\x85R0g0\ng0\n]\x94\x8c\x06append\x86R\x940g8\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\nfrom_bytes\x86R\x940M\x00\x00p7\n0g9\ng11\ng6\n\x8c\x08builtins\x8c\x05slice\x93g4\ng7\nM\x08\x00\x86Rg4\ng3\ng7\nM\x01\x00\x86RM\x08\x00\x86R\x86R\x85R\x8c\x06little\x86R\x85R0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RMw\x00\x86RM\xc9\x01\x86R\x85R0g0\n]\x94\x8c\x06append\x86R\x940g0\ng12\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__xor__\x86R\x940I1244422970072434993\n\x940M\x00\x00p7\n0g13\n\x8c\x08builtins\x8c\x03pow\x93g15\ng10\ng7\n\x85Rg16\n\x86RI65537\nI18446744073709551557\n\x87R\x85R0g14\ng7\n\x85Rp16\n0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RM\x83\x00\x86RM\xa7\x02\x86R\x85R0g0\ng12\n\x8c\x06__eq__\x86R(I8215359690687096682\nI1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R.'

patched=patch_stops(payload,(261,330))
#打印“带栈快照”的反汇编(传patched!)
dis_with_stack(patched)

#如果你还想跑官方dis看说明文字,也行(但它不会打印栈快照,而且可能因严格校验报错)
#pickletools.dis(patched,annotate=1)

Perfect-Blu

题目提供的是一个包含 BDMV 的蓝光镜像(.iso),用 VLC 打开后会出现一个“键盘式”的交互菜单,用来输入 flag

image-20250901214242622

我本地还真没这个,ubuntu里有..

BDMV(Blu-ray Disc Movie)是Blu-ray光盘的一种文件格式,专门用于存储蓝光光盘上的视频内容,通常包含电影、视频和相关的多媒体文件。BDMV文件夹内包含多个子文件夹和文件,通常包括:

  1. BDMV:主文件夹,存储蓝光电影的相关数据文件。
  2. AACS:用于存储加密相关信息,用于保护Blu-ray内容不被非法复制。
  3. CERTIFICATE:用于存储数字证书文件,用于验证合法性和保护数据。

BDedit 0.40 / 0.55 捐赠免费下载 - VideoHelp

可以下载一个这个来打开

加载后点击菜单

image-20250901214917372

load选择stream可以加载

image-20250901215442036

  • 菜单里每个按钮(比如数字、字母键)都有一个位置,用屏幕像素坐标 (x, y) 标识。
  • 在蓝光的交互菜单里,这些坐标常用于定义“热点区域”或按钮中心点。

这是对应按钮被按下时触发的菜单脚本/命令(Call Object),通常用于跳转或执行某段逻辑。这里先不深究含义,重点是我们要把 (453, 672) 这个坐标映射成哪个“键”。

让我们快速编写一个辅助函数来将坐标转换为键盘字符:

image-20250901220031211

1
2
3
4
5
6
7
8
9
10
11
12
KEYS = [
'1234567890',
'QWERTYUIOP',
'ASDFGHJKL{',
'ZXCVBNM_-}',
]

def xy_to_key(x, y):
x = (x - 315) + 50
y = (y - 413) + 50
return KEYS[y // 130][x // 137]

参考Sickle (SecCon Quals 2023) - HackMD

xy怎么来的,多看几个格子的xy就能发现了,自己对照下

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
import struct

KEYS = [
'1234567890',
'QWERTYUIOP',
'ASDFGHJKL{',
'ZXCVBNM_-}',
]

def xy_to_key(x, y):
x = (x - 315) + 50
y = (y - 413) + 50
return KEYS[y // 130][x // 137]

def get_button(path, index):
print('processing', path)

with open(path, 'rb') as f:
data = f.read()

pattern = struct.pack('>III', 0x21820000, index + 1, 0)
pos = data.find(pattern)
if pos == -1:
# dirty hack for one corner case
pattern = struct.pack('>II', 0x21820000, 0)
pos = data.index(pattern)

pos = data[:pos].rindex(b'\x00\x04\x00\x00\x00\x00')
while True:
x, y = struct.unpack_from('>HH', data, pos)
if x == 0 and y == 0:
return None

# hacky way to find coordinates' location
if 315 <= x <= 1551 and 413 <= y <= 802:
break

pos -= 1

return xy_to_key(x, y)

flag = ''

for i in range(47):
c = get_button(f'/BDMV/STREAM/{i:05}.m2ts', i)
print(i, c)
flag += c

print(flag)

1
SECCON{JWBH-58EL-QWRL-CLSW-UFRI-XUY3-YHKK-KFBV}

差不多是下面这个意思

image-20250902000737472

这个object的数和你其他不一样的就是特殊的,并且是第一个不同的,绿色那个XY范围超了也不行

image-20250902001220721

1
print(xy_to_key(453,672))可以验证下
1
2
3
4
5
6
a = '1234567890QWERTYUIOPASDFGHJKL{ZXCVBNM_-}'
b = [26,11,10,25,38,4,7,12,28,38,10,11,13,28,38,32,24,21,11,38,16,23,13,17,38,31,16,15,2,38,15,25,27,27,38,27,23,34,33,39]
flag = 'SECCON{'
for i in b:
flag += a[i]
print(flag)

image-20250902000340232

xuyao

这个sbox看起来像AES的S盒,密文在下面,上面还有一个finsh和cat,目前不清楚作用

image-20250902142942246

main函数:

  1. mmap 0x1000 临时页(addr),构造两套“描述符表”(v20+v21),每个条目形如 {ptr=addr, off=i, size=1/2/4/8};
  2. 读入 Message,逐字节写入 addr+off,并把已写字节数记到 [addr+224];
  3. 计算填充到 16 字节边界,把总长度写入 [addr+228],用 [addr+236] 的 pad 值把 [len..len+pad-1] 填为 pad;
  4. 调用 encrypt(v20, addr, a3=0x4000000E4(高32位=4,低32位=228), key=”SECCON CTF 2023!”, v21 对 addr 中的明文做 128-bit 分组加密(ECB)并把密文写入 v21;
  5. 遍历 v21 指向的每个字节与全局 enc[] 逐一比较,全部相等打印 “Correct!”,否则 “Wrong…”

加密顶层 custom_encrypt(原 encrypt,已注释)

  • 参数:若干描述符表(r_descA/r_descB/out_desc)、输入基址与长度、固定16字节ASCII密钥。
  • 初始化子密钥材料:将密钥按32位分段,配合 INIT_KEY_XOR 做异或与字节序处理,获得初始轮密钥(还会叠加 ROUND_CONST)。
  • 进行64轮的子密钥演化,每轮通过 key_schedule_apply(见下)进一步混合。
  • 以128-bit为单位对输入做分组,逐块调用 encrypt_block_128 完成轮函数迭代。

非线性与线性层key_schedule_apply

  • sub_bytes(原 tnls,已注释):对32位输入的每个字节,分别用 SBOX 做替换,按字节拼回32位。
  • linear_mix(原 els,已注释):L(x) = x ^ rol3(x) ^ rol14(x) ^ rol15(x) ^ rol9(x),提供强扩散。
  • sub_bytes_then_linear_mix(原 sub_555…,已注释):F(x) = linear_mix(sub_bytes(x)),组合成轮函数的核心。

分组加密 encrypt_block_128(原 encrypt_block,已注释)

  • 读取4个32位字(注意每个字在读写时做了 bswap32,确保统一的字节序)。
  • 循环64轮:每轮调用一次 feistel_round,并对4个字做循环置换(四路Feistel风格的扩散)。
  • 写回时再次对4个字做 bswap32。

轮函数 feistel_round(原 r,已注释)

  • 从状态中取3个32位字做 XOR 汇总,结果再与本轮子密钥 XOR。
  • 将上述32位值送入 F 函数:sub_bytes_then_linear_mix(见下)得到非线性+线性扩散后的结果。
  • 把该结果 XOR 回目标字(实现Feistel式的“混入”),完成一轮。

子密钥演化

  • key_schedule_apply(原 ks,已注释):先做 sub_bytes,再做 rotate_xor_mix,得到新一轮子密钥。
  • rotate_xor_mix(原 kls,已注释):K(x) = x ^ rol11(x) ^ ror7(x),线性可逆、实现轻量扩散。
  • 初始子密钥由 “SECCON CTF 2023!” 与 INIT_KEY_XOR 的分段/字节序混合得到,并在轮间叠加 ROUND_CONST。

但是还是不太懂,得先学学AES和DES,还有SM4,RC4了

首先发现的这个SBOX是AES的SBOX,但是他在这里做了变换,起混淆作用可能

image-20250902201733344

这里的addr什么的内存操作主要起混淆作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 你的 SBOX:这里给一个占位(恒等映射),实际请替换成程序里的全局 SBOX 内容(长度 256)
SBOX = list(range(256))

def sub_bytes_word_le(x: int, sbox=SBOX) -> int:
"""
对 32 位小端整数 x 的每个字节做 SBOX 替换,并放回原位。
小端意味着最低有效字节在最低位(i=0),最高字节在 i=24。
"""
out = 0
for i in range(0, 32, 8):
b = (x >> i) & 0xff # 取第 i 位所在的字节
sb = sbox[b] & 0xff # SBOX 替换
out |= sb << i # 放回原字节位
return out

x = 0x11223344
y = sub_bytes_word_le(x)
print(hex(x), "->", hex(y))

简化下就是下面这样

1
2
3
4
5
def tnls(x):
r = 0
for i in range(0, 32, 8):
r |= sbox[(x >> i) & 0xff] << i
return r

kls呢,rotate_xor_mix:K(x) = x ^ rol11(x) ^ ror7(x),线性可逆、实现轻量扩散。

这个也非常简单,对 32 位输入值进行一些异向和旋转。

1
2
def kls(x):
return x ^ rol(x, 11) ^ ror(x, 7)

这段循环是在“按轮生成子密钥并做4字寄存器轮换”的关键流程,ROUND_CONST 就是每一轮参与异或的“轮常量”,用于打破轮与轮之间的对称性,避免出现滑移/对称弱点,让每一轮的输入都不同。

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
v16 = &ROUND_CONST;
do
{
if ( n4_8 != 4 )
BUG();
v17 = (int *)(v70 + v71);
v18 = *v17;
addr[11] = *v17;
if ( n4_9 != 4 )
BUG();
v19 = (int *)(v73 + v74);
v20 = *v19 ^ v18;
addr[11] = v20;
if ( n4_10 != 4 )
BUG();
v21 = (int *)(v76 + v77);
addr[11] = *v16 ^ *v21 ^ v20;
key_schedule_apply((__int64)addr, 0x40000002CLL, (__int64)addr, 0x400000030uLL);
if ( n4_11 != 4 )
BUG();
v22 = (int *)(v67 + v68);
v23 = addr[12] ^ *v22;
addr[11] = v23;
if ( *((_DWORD *)v12 + 3) != 4 )
BUG();
*(_DWORD *)(*v12 + *((unsigned int *)v12 + 2)) = v23;
*v22 = *v17;
*v17 = *v19;
*v19 = *v21;
*v21 = addr[11];
v12 += 2;
++v16;
}

这个循环有什么用呢?

把变量抽象成人话(用 4 个 32 位字寄存器表示当前状态):

  • 设当前 4 个 32 位状态字为 W0, W1, W2, W3(分别对应 v17、v19、v21、v22 指向的位置,具体是由前面“基地址+偏移”的描述符指向的内存)。

  • v16 从 &ROUND_CONST 开始,每次循环自增一次,相当于每轮取一个 32 位轮常量 RC = *v16。

  • addr[…] 是函数里的一块临时工作区(堆栈/映射区),拿来放中间值;v12 指向了“输出描述符数组”,用于把该轮的结果写出去(作为 round key 或本轮输出)。

    循环每一轮的逻辑可以压缩成下面这几步:

  1. 取 3 个字相加(按位异或)

    • tmp = W0 ^ W1
    • tmp = tmp ^ W2
  2. 注入轮常量

    • tmp = tmp ^ RC:作用:让每轮输入都有“回合编号”的差异,打破对称。
  3. 经过一个非线性+线性扩散的变换

    y = key_schedule_apply(tmp)

    其中 key_schedule_apply 等价于“sub_bytes 再 rotate_xor_mix”,也就是先对 tmp 按字节用 SBOX 做替换,再做线性旋转异或扩散(ROL11、ROR7 异或),产生强非线性和扩散。

  4. 与第4个字混合,得到本轮输出字 new_word

    new_word = W3 ^ y

    C 里对应 v23 = addr[12] ^ v22; 然后把 v23 写回 addr[11] 和目标位置(通过 v12 指向的描述符: (_DWORD *)(*v12 + *((unsigned int *)v12 + 2)) = v23)。

    这一步就是“Feistel风格”的把 F(tmp) 混入 W3。

  5. 4 字寄存器轮换(把新字注入进来)

    (W0, W1, W2, W3) ← (W1, W2, new_word, W0)
    对应代码里的:
    *v22 = *v17; // W3 = old W0
    *v17 = *v19; // W0 = old W1
    *v19 = *v21; // W1 = old W2
    *v21 = addr[11]; // W2 = new_word

    这是一种“四路Feistel/轮换寄存器”的常见扩散策略。

    因此,这个 do { … } 循环就是“按轮常量 RC 驱动”的轮迭代:

  • 组合前三个字异或,再异或本轮 RC;

  • 过一层非线性+线性(key_schedule_apply);

  • 与第四个字异或得到本轮输出字,既写回(或存到某个输出表)也更新状态;

  • 做一个 4 字的循环置换,把 new_word 放入状态,准备下一轮;

  • v16++ 取下一个 ROUND_CONST,v12++ 把“该轮输出”写到下一位置。
    ROUND_CONST 的作用总结

  • 是每一轮独有的常量,按位异或注入到“W0^W1^W2”的组合中。

  • 目的:

    • 打破轮与轮完全相同的结构导致的对称/滑移(slide)弱点;
    • 保证即使状态在某些情况下相同,每轮的输入仍然不同;
    • 提升密钥扩展/轮函数的不可预测性和抗分析性。
  • 可以把它类比为 AES 里每轮加的 Rcon,或是很多自定义分组密码/哈希里常见的“轮常量”

它把固定主密钥(ASCII 常量)变成一串“每轮不同”的子密钥,这串子密钥随后被 encrypt_block_128 在 64 轮中逐轮取用,直接决定了加密输出。

这段循环在整个程序里承担的是“按轮生成子密钥(round keys)并更新4字寄存器状态”的核心职责。通俗说:它就是定制分组密码的“密钥扩展/轮密钥调度”环节,决定每一轮用什么子密钥参与加密。

把它放回整体流程中的位置

  • 在 custom_encrypt 中,先用固定 ASCII 密钥 “SECCON CTF 2023!” 和 INIT_KEY_XOR 混合,得到4个32位的初始状态字(你可以理解为 K0..K3)。
  • 紧接着就是你看到的这段循环:它会迭代多次(64次),每一轮从这4个状态字推导出一个32位的“本轮子密钥”,同时更新这4个状态字,为下一轮准备。
  • 生成出的子密钥序列被写到一张“输出描述符/表”里(v12 指向的地方),随后 encrypt_block_128 在真正加密数据时,会一轮一轮取用这些子密钥。

encrypt_block(encrypt_block_128)

简而言之,它接收一个数据块(4 个 32 位值)、32 个 32 位子键和输出指针,交换字节顺序并为每个子键执行 32 轮调用函数

  • feistel_round 是每一轮的“F-函数混入”步骤:把三个32位字按位异或后再异或子密钥,过一层“字节S盒替换 + 线性扩散”,然后把结果异或到第四个字上。它本身不做寄存器轮换,四字轮换是在外层 encrypt_block_128 里做的。

    更具体一点:

  • 设本轮状态为四个32位字 (W0, W1, W2, W3),子密钥为 K。

  • 计算 tmp = W0 ^ W1 ^ W2 ^ K。

  • 计算 F(tmp) = linear_mix(sub_bytes(tmp)):

    • sub_bytes:对 tmp 的每个字节用 SBOX 做查表替换(逐字节)。
    • linear_mix:把输入做多个 32 位循环左移后按位异或,形成线性扩散。
  • 把 F(tmp) 混入 W3:W3’ = W3 ^ F(tmp)。

  • 返回 W3’。外层随后会做四字轮换 (W0,W1,W2,W3) → (W1,W2,W3’,W0)。

我们需要先关注feistel_round 里的这个函数

image-20250902210039147

image-20250902210105696

1
2
def els(x):
return x ^ rol(x, 3) ^ rol(x, 14) ^ rol(x, 15) ^ rol(x, 9)

这是linear_mix的逻辑

那么feistel的逻辑就是

1
2
def r(block, subkey):
return es(block[1] ^ block[2] ^ block[3] ^ subkey) ^ block[0]

因此我们有encrypt_block的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
def encrypt_block(block, subkeys):
assert len(block) == 16

block = array('I', block)
block.byteswap()
block = block.tolist()

for i in range(32):
block = [block[1], block[2], block[3], r(block, subkeys[i])]

result = array('I', block[::-1])
result.byteswap()
return result.tobytes()

这样综合来看跟哪个算法很像?SM4,但是S盒好像不太一样

我们可以编写一个 函数来解密:decrypt_block

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def decrypt_block(block, subkeys):
assert len(block) == 16

block = array('I', block)
block.byteswap()
block = block.tolist()[::-1]

for i in range(31, -1, -1):
block = [es(block[0] ^ block[1] ^ block[2] ^ subkeys[i]) ^ block[3], block[0], block[1], block[2]]

result = array('I', block)
result.byteswap()
return result.tobytes()

我们不知道的是subkeys

动调出来:

可以在此处下断点

image-20250902233424350

image-20250902233435543

或者在这,然后a2点进去向下找就行

image-20250902233631609

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
from Crypto.Util.Padding import unpad
from array import array

sbox = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)

def rol(x, n):
return (x >> (32 - n)) | (x << n) & 0xffffffff

def tnls(x):
r = 0
for i in range(0, 32, 8):
r |= sbox[(x >> i) & 0xff] << i
return r

def els(x):
return x ^ rol(x, 3) ^ rol(x, 14) ^ rol(x, 15) ^ rol(x, 9)

def es(x):
return els(tnls(x))

def decrypt_block(block, subkeys):
assert len(block) == 16

block = array('I', block)
block.byteswap()
block = block.tolist()[::-1]

for i in range(31, -1, -1):
block = [es(block[0] ^ block[1] ^ block[2] ^ subkeys[i]) ^ block[3], block[0], block[1], block[2]]

result = array('I', block)
result.byteswap()
return result.tobytes()

enc = bytes.fromhex('fe 60 a8 c0 3b fe bc 66 fc 9a 9b 31 9a d8 03 bb a9 e1 56 fc fc 11 9f 89 5f 4d 9f e0 9f ae 2a cf 5e 73 cb ec 3f ff b9 d1 99 44 1b 9a 79 79 ec d1 b4 fd ea 2b e2 f1 1a 70 76 3c 2e 7f 3f 3b 7b 66 a3 4b 1b 5c 0f be dd 98 5a 5b d0 0a 3d 7e 2c 10 56 2a 10 87 5d d9 b9 7f 3e 2e 86 b7 17 04 df b1 27 c4 47 e2 d9 7a 9a 48 7c db c6 1d 3c 00 a3 21')

# dumped with gdb
subkeys = [
0xf6067814, 0xed73cb7e, 0x1583a8b2, 0x0dde8d93, 0x23e2374b, 0x40b83c72, 0x0b3f811a, 0xd6e7a993,
0x2622de7c, 0xc581dcae, 0xa906524c, 0xdb4f2cc1, 0x0ddb3477, 0x8c1a92a4, 0x3bd711c0, 0x1bb16503,
0x00acd720, 0x2735f2d0, 0x9a9300fe, 0xfb2556a7, 0xcbe1fe58, 0xc03db8c9, 0xf77cb701, 0x0a1f85ae,
0x14dd27dc, 0xe1a5e3a9, 0x41d1f9ee, 0xfe6afce7, 0xd80eac32, 0xf43efead, 0x6475d80f, 0x38a310d6,
]

plaintext = b''
for i in range(0, len(enc), 16):
plaintext += decrypt_block(enc[i:i+16], subkeys)

print(unpad(plaintext, 16))

1
b'Congratulations! You have decrypted the flag: SECCON{x86_he2_zhuan1_you3_zi4_jie2_ma3_de_hun4he2}\n'

Optinimize

是一道NIM逆向题,可读性很差

Nim 是一门开源的静态类型编程语言,可以编译成 C 或 JavaScript,具有 Python 的表现力和 C 的速度。Nim 支持引用、垃圾回收、宏、模板等特性,适合开发

运行后输出flag会逐渐变慢,题目名跟优化有关,可能我们就是要优化这个打印Flag的过程

image-20250903144255428

程序每一轮会取一个目标计数 n,找出“第 n 个满足 p(t) % t == 0 的 t”,然后取 t % 256,并与一个按位异或表的对应元素异或,输出一个字节。

之所以越跑越慢,是因为 p(t)(整数拆分函数)增长极快,且每一轮都要从 0 重新枚举 t 去找第 n 个命中,使用大整数运算,没有缓存,复杂度爆炸

主流程与关键函数(已重命名)主打印循环函数: print_flag_loop (原 NimMainModule,地址 0x11c30)循环变量 idx 从 0 到 38(总计 39 次输出)。

每轮:

  1. 读取目标命中次数 n = target_counts[idx](全局数组)。
  2. 把 n 以大整数形式传入 Q 函数,得到一个大整数结果 T(详见 Q 函数解释)。
  3. 计算 T mod 256(以大整数 mod 后,再通过 bigint_to_uint64_checked 转成无符号整数)。
  4. 与 xor_key_table[idx] 异或,得到一个 0..255 的字节。
  5. 输出该字节;循环直到 idx > 38。

已在循环开始位置添加注释“主循环开始:for idx in 0..38,每轮计算并输出一个flag字符”

image-20250903152246399

find_nth_m_where_Pm_mod_m_eq_0: 给定目标计数 n,t 从 0 递增;每轮计算 P(t) 并判断 P(t) % t == 0;若成立则命中计数+1;当命中计数==n 时返回 t(以大整数形式写回 a1)

计数搜索函数: find_nth_m_where_Pm_mod_m_eq_0 (原 Q__main_u13,地址 0x11740)

输入:目标命中次数 n(以大整数形式)。

逻辑(高层等价):

  • 令 count = 0,t = 0。

  • do:

    • t = t + 1
    • 计算 p(t) = compute_P_of_n(t)(大整数)。
    • 若 p(t) % t == 0,则 count += 1。
  • 当 count 达到 n 时,返回该 t(以大整数写回到调用方的大整数缓冲)。

  • 代码里能清晰看到:

    • 通过 plus 把 t 自增;
    • 调用 compute_P_of_n 得到大整数;
    • 调用 mod(p(t), t);
    • 用 eqeq 与 0 比较,命中就把计数大整数 v55 自加 1;
    • 用 lt(v55, n) 判断是否已满足目标;不小于 n 时,搬运当前 t(v57/v58)作为返回值,结束循环。
1
2
3
4
5
6
7
8
9
def find_nth_m_where_Pm_mod_m_eq_0(n):
i, accum = 0, 0
while i < n:
accum += 1
p = compute_P_of_n (accum)
if p % accum == 0:
i += 1

return accum

compute_P_of_n :

compute_P_of_n: 递归-迭代混合的大整数多项式运算,包含 plus/minus/mod/lt/eqeq 等;核心是生成某序列 P(n)

这是“p(n)”的计算器,使用大量大整数操作 plus/minus/lt/eqeq/mod,形态上符合欧拉五边形数递推(generalized pentagonal numbers)实现的整数拆分函数 p(n):

  • 反复初始化 302 作为常用常量;

  • 内层循环按条件加减之前的项,按 lt 条件退出;这与五边形数递推的“+ + - - …”累加模式吻合;

  • 结果以大整数形式返回。

  • 大整数到整数转换: bigint_to_uint64_checked (原 toInt__main_u70,地址 0x10e30)

  • 负责把大整数安全地转换成 64 位无符号整数,有专门的负号和 64 位拼接处理分支(见对 a8+8、a8+12 两个 32 位字段的操作)。

  • 主循环里用它把“(T mod 256)”转成普通整数再异或输出。

1
2
3
4
5
6
7
8
9
10
11
12
def P__main_u4(x):
a, b, c = 3, 0, 2
if x == 0:
return a
if x == 1:
return b

while x > 2:
a, b, c = b, c, a + b
x -= 1

return c

主循环:

for idx in 0..38:

  • n = target_counts[idx]
  • t = find_nth_m_where_Pm_mod_m_eq_0(n)
  • c = (t mod 256) // 大整数取模 256
  • out_ch = c XOR xor_key_table[idx]
  • print byte(out_ch)

最后还有个异或逻辑

下面这个是代码的逻辑

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
def P(i: int):
num1 = 3
num2 = 0
num3 = 2
num4 = 0
if i == num4:
return num1
else:
num5 = 1
if i == num5:
return num2
else:
num6 = 2
if i == num6:
return num3
else:
num7 = 2
if num7 < i:
j = i
num8 = 2
while num8 < j:
v98 = num1 + num2
num1 = num2
num2 = num3
num3 = v98
j -= 1
return num3


def Q(n: int):
i = num = 0
while i < n:
num += 1
v63 = P(num) % num
if v63 == 0:
i += 1
return num

ns = [0x000000000000004A, 0x0000000000000055, 0x000000000000006F, 0x0000000000000079, 0x0000000000000080, 0x0000000000000095, 0x00000000000000AE, 0x00000000000000BF, 0x00000000000000C7, 0x00000000000000D5, 0x0000000000000306, 0x0000000000001AC8, 0x00000000000024BA, 0x0000000000003D00, 0x0000000000004301, 0x0000000000005626, 0x0000000000006AD9, 0x0000000000007103, 0x000000000000901B, 0x0000000000009E03, 0x00000000001E5FB6, 0x000000000026F764, 0x000000000030BD9E, 0x0000000000407678, 0x00000000005B173B, 0x00000000006FE3B1, 0x000000000078EF25, 0x0000000000858E5F, 0x000000000098C639, 0x0000000000AD6AF6, 0x0000000001080096, 0x00000000018E08CD, 0x0000000001BB6107, 0x0000000001F50FF1, 0x00000000025C6327, 0x0000000002A971B6, 0x0000000002D68493, 0x000000000362F0C0, 0x0000000003788EAD, 0x0000000003CAA8ED]

cs = [0x3C,0xF4,0x1A,0xD0,0x8A,0x17,0x7C,0x4C,0xDF,0x21,0xDF,0xB0,0x12,0xB8,0x4E,0xFA,0xD9,0x2D,0x66,0xFA,0xD4,0x95,0xF0,0x66,0x6D,0xCE,0x69,0x00,0x7D,0x95,0xEA,0xD9,0x0A,0xEB,0x27,0x63,0x75,0x11,0x37,0xD4,0x00,0x00,0x00,0x00,0x00,0x00,0x00]

for i in range(len(cs)):
print(chr((Q(ns[i]) & 0xff) ^ cs[i]), end='')

后面就是优化

现在我们知道了函数的作用

生成几个元素给了我们序列 。快速谷歌搜索显示它是 Perrin sequnceP__main_u4``3, 0, 2, 3, 2, 5, 5, 7, 10, 12, 17, 22, 29, 39, ...

它有一个适用于所有素数的属性。我们可以看到它正是在检查的内容,因此可以得出结论,它返回了 -th Perrin 素数。n|p(n)``Q__main_u13``n

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import gmpy2

ns = [ 74, 85, 111, 121, 128, 149, 174, 191, 199, 213, 774, 6856, 9402, 15616, 17153, 22054, 27353, 28931, 36891, 40451, 1990582, 2553700, 3194270, 4224632, 5969723, 7332785, 7925541, 8752735, 10012217, 11365110, 17301654, 26085581, 29057287, 32837617, 39609127, 44659126, 47613075, 56815808, 58232493, 63613165 ]
cs = [ 60, 244, 26, 208, 138, 23, 124, 76, 223, 33, 223, 176, 18, 184, 78, 250, 217, 45, 102, 250, 212, 149, 240, 102, 109, 206, 105, 0, 125, 149, 234, 217, 10, 235, 39, 99, 117, 17, 55, 212 ]

# https://oeis.org/A013998
pseudoprimes = [ 271441, 904631, 16532714, 24658561, 27422714, 27664033, 46672291, 102690901, 130944133, 196075949, 214038533, 517697641, 545670533, 801123451, 855073301, 903136901, 970355431, 1091327579, 1133818561, 1235188597, 1389675541, 1502682721, 2059739221, 2304156469, 2976407809, 3273820903 ]

n = max(ns)

seq = [ 0, 1 ]
while len(seq) <= n:
x = int(gmpy2.next_prime(seq[-1]))
while pseudoprimes and x > pseudoprimes[0]:
seq.append(pseudoprimes.pop(0))
seq.append(x)

flag = ''
for n, c in zip(ns, cs):
flag += chr(c ^ (seq[n] % 256))

print(flag)
1
SECCON{3b4297373223a58ccf3dc06a6102846f}