D^3CTF2025 Re-wp&复现
D3piano
按正确的音色弹琴拿到flag。
逻辑在so里面,加载了D3piano。查看后看字符串可以定位到check函数。
里面是一个gmp库实现的ras校验,获取公钥即可解密。这里用fridahook获取。
因为是大数,寄存器里应该是一个结构体,这里我们用frida调用gmpz_get_str把hook的数据转为十六进制。
有frida检测,在player.so的init_array里面,hook去掉检测。
1 | //frida version 17.1.0 |
拿到pq
1 | from Crypto.Util.number import long_to_bytes |
解密到fake flag。
仔细看init_array中的内容可知它内置了一个frida,把加密函数和获取声音的函数全部hook了。我们要去寻找hook的listener来看hook后的操作。
根据多线程的信号量可以知道执行顺序。
listener1 hook获取sound顺序的函数的返回值,让sound的顺序固定。
listener2 hook check调用两个虚表函数进行检测,onLeave修改原函数的返回值。
listener3 hook 十二进制转str的函数,把结果转为16进制字符串。
check:
LZW压缩算法。
密钥加密和魔改chacha20。
只要先把chacha20解密出来就能看出是压缩算法了。
1 |
|
chacha20,注释的地方就是bia原来
1 |
|
LZW就用官方wp里的了。
1 |
|
把十二进制转为字符串
1 | from Crypto.Util.number import long_to_bytes |
D3Kernel
用层通过通过动态解析函数地址来隐藏了函数的调用,暂时没发现反调试,动调看一下函数调用
从上往下依次调用
kernelbase_GetModuleHandleA(Wkernel32.dll) 加载dll
kernel32_GetProcAddress() 获取api
ntdll_RtlAddVectoredExceptionHandler() 设置异常处理函数,这里我重名名为except_func1
kernel32_VirtualAlloc() 动态分配一段内存
shellcode 触发除0异常
后执行的shellcode触发异常使得ntdll_RtlAddVectoredExceptionHandler()中的函数被执行。异常处理函数里面有很多东西没有被反编译出来。
有反调试,在_scrt_common_main_seh下断点,用scyllahide注入自带的dll可以去掉反调试。(后面找到了反调试的位置)
去除反调试加载驱动之后继续研究程序逻辑,发现跳转到了另一个异常处理函数,在第一个异常处理的函数(except_func1)的右边,重命名为except_func2
同样的except_func2里面执行的函数也是通过kernel32_GetProcAddress() 获取的,动调进去看调用的api
从上往下是
kernel32_IsDebuggerPresent()
kernel32_CheckRemoteDebuggerPresent()
发现反调试,已经用scyllahide绕过了,不必理会
继续往下执行,发现正式开始加载驱动了,逻辑来到了except_func2的左侧,
api调用按顺序依次是
kernel32_CreateFileW(”\\.\d3ctf”)
输入name和passwd
kernel32_DeviceIoControl(IOCT:0X222000) 传入输入的数据
kernel32_DeviceIoControl(IOCT:0X222004) 校验
得到IOCT码,开始分析内核
驱动里面第二个函数就是IOCT处理函数,第一个函数是线程启动的一个反调试函数直接patch掉就行。
IOCT==0X222000时复制了用户名和密码的数据到内存中。
我们的输入被保存到140005200处并且前256字节是name,后256位是passwd。
IOCT==0X222004 是校验逻辑
后面是一个虚拟机,分析后得到opcode。
1 | code = { |
调用逻辑如下
直接提取最后的opcode查看加密逻辑
1 | unsigned int opc[] = {0x0000000a,0x00000000,0x00000018,0x005801c0,0xffa7fe4e,0x00000000,0x0000000a,0x00000000,0x00000018,0x20160070,0xdfe9ff9e,0x00000001,0x0000000a,0x00000000,0x00000018,0x4805801c,0xb7fa7ff2,0x00000002,0x0000000a,0x00000000,0x00000018,0x12016007,0xedfea007,0x00000003,0x0000000a,0x00000064,0x00000018,0x04805801,0xfb7fa80d,0x00000004,0x0000000c,0x0000000a,0x0000000c,0x00000000,0x00000005,0x00000008,0x0000004c,0x0000000a,0x0000000a,0x0000000c,0x00000000,0x00000001,0x00000016,0x0000000e,0x00000002,0x0000000a,0x0000000b,0x0000000c,0x00000000,0x00000001,0x00000016,0x0000000e,0x00000003,0x0000000c,0x00000002,0x0000000c,0x00000003,0x00000004,0x00000017,0x00000004,0x0000000c,0x00000000,0x0000000a,0x00000001,0x00000001,0x0000000e,0x00000000,0x0000000c,0x00000004,0x0000000a,0x00000001,0x00000001,0x0000000e,0x00000004,0x00000007,0x0000001e,0x00000012,0xffffc588,0x00000000,0x55555555,0x00000150,0x00000150,0x00000000,0x00000000,0x00000006,0x00000000,0x00000000,0x00000000}; |
分析一下内存结构
v13和stack2可从cal的分析得来,因为数据的加载和校验不在同一个虚拟机的函数,所以虚拟机是很可能有全局变量的。根据加载数据时的opcode(0E000000000000000Ah)发现他调用的存储是str1,这存储的就是全局变量,那么从全局变量中取出就是lea1。str2和lea2也都是在全局变量操作只不过索引不同。str和lea的存储位置和栈帧有关(在cal处可以看到)推测是函数的私有内存,而且私有内存的地址初始值是-101,只有在call的时候才会加上101,所以只有后面call的函数才有这个私有内存。
emm,算一下新cal的函数的内存分布。假设是第一个cal的函数:
1 | stacklen: size |
我们发现函数的私有内存和栈帧的起始地址是相同的,所以新call的函数就只有一块内存,一起作为栈帧和内存使用。
所以内存布置如下,因为直接用结构体调用,所以数组的大小只要不过小就行了,顺序没有要求。
1 | struct newStack{ |
1 | import string |
模拟执行并进行打印,大概可以看出是前一位异或后一位。最后的判断逻辑是先判断用户名再判断passwd,两者的加密方法是相同的。
但是这样来说最后的密文应该只有35位,可现实是36位,不难发现,其实字符长度36也参与了异或运算。
如果没有想到第一位是36,可以先忽略第一个字节把后面的字节进行前后异或解密,再用结果去爆破一下即可,因为缺的只是第一位,而异或是有连续性的,只需要全部再异或某一个字节就行。这里用cyber chef的xor brute force解密。结果证明这个字节是24。
密文如下,解密就行
1 | data = [0x45,0x57,0xE,0x5C,0x2,0x4,0x52,0x6,0x1B,0x1A,0xE,0x1,0x5E,0x4B,0x19,0x56,0x6,0x55,0x1C,0x14,0x5C,0x5D,0x9,0x1C,0x1D,0x1,0x0,0x50,0x0,0x4,0x6,0x52,0x0,0x2,0x55,0x56,0x6A,0x4,0x1D,0x7,0x6,0x1D,0x9] |