Matryoshka套娃的Writeup
Table of Contents
刷到了一个(对我来说)很新颖的crackme,于是记录下
下载链接 crackmes.one
Matryoshka 直译套娃,采用动态解密的方式从文件中解密可执行文件。其会加载到内存中执行,不会在本地磁盘上生成文件
这个crackme从用户的输入中读取一个key,xor解密.data中的数据,然后写入Linux独有的匿名内存文件中(memfd_create),使用/proc/self/fd/<fd>运行内存中的这个ELF,依次循环往复,直到最后一层执行真正的检查,就像套娃一样
本文会讲述遇到该类型程序该如何逆向
#
Step 1 获取基本信息
在IDA中打开这个可执行文件,查看imports,在这个例子中,可以看到比较重要的几个
- memfd_create, ftruncate, fdopen, fwrite, rewind
- execve, fileno, sprintf
查看strings,能看到非常重要的信息,即采用文件描述符运行
- /proc/self/fd/%d
这个程序很有可能构建类似/proc/self/fd/3的路径并调用
#
Step 2 查看 Main
一般来说,Main中会包含
- 输入是如何被处理的
- 输入是如何被转化和参与加解密的
- 加密内容的位置及大小(constant/global)
在这个例子中,Main中包含了
- argc == 2,只接受一个命令行参数
- arg_1,一字节xor_key,值为input[1]-0x57
- arg_2, input剩下的部分将作为值继续向下阶段传入
从这里就可以看出套娃的特征,即每次消耗部分输入解密,一步步传下去剩余部分
以下为main函数的伪c代码
__int64 __fastcall main(int input_length, char **input_1, char **input_2)
{
unsigned __int8 arg_1; // [rsp+1Fh] [rbp-21h]
_QWORD arg_2[4]; // [rsp+20h] [rbp-20h] BYREF
arg_2[3] = __readfsqword(0x28u);
if ( input_length == 2 )
{
arg_1 = *input_1[1] - 0x57;
arg_2[0] = &unk_201C;
arg_2[1] = input_1[1] + 1;
if ( (unsigned int)sub_1344((__int64)&unk_4080, dword_EA60, arg_1, (__int64)arg_2) == -1 )
sub_142C();
return 0;
}
else
{
puts("usage: ./matryoshka key");
return 0;
}
}
#
Step 3 分析最外层Loader
最外层Loader有三个重要的部分,下面进行讲解
__int64 __fastcall sub_1344(__int64 ptr, unsigned int len, char key_byte, __int64 a4)
{
__int64 v6; // [rsp+28h] [rbp-8h]
xor_buffer_inplace(ptr, len, key_byte);
v6 = memfd_from_blob(ptr, len, 1);
return execve_via_proc_self_fd(v6, &unk_201D, a4);
}
##
XOR 解密
没什么好说的,纯粹的xor解密
__int64 __fastcall xor_buffer_inplace(__int64 ptr, unsigned int len, char key_byte)
{
__int64 result; // rax
unsigned int i; // [rsp+1Ch] [rbp-4h]
for ( i = 0; ; ++i )
{
result = i;
if ( i >= len )
break;
*(_BYTE *)((int)i + ptr) ^= key_byte;
}
return result;
}
##
创建内存中可执行文件
执行流程:
- 使用
memfd_create创建只存在于内存中的匿名文件描述符 - 使用
ftruncate设置内存大小,确保文件可以正常加载 - 打开文件流
fdopen(fd, "r+"), - 使用
fwrite写数据 rewind文件流
FILE *__fastcall memfd_from_blob(const void *a1, unsigned int a2, unsigned int a3)
{
int fd; // [rsp+14h] [rbp-Ch]
FILE *s; // [rsp+18h] [rbp-8h]
fd = memfd_create(&unk_201D, a3);
ftruncate(fd, a2);
s = fdopen(fd, "r+");
fwrite(a1, 1u, a2, s);
rewind(s);
return s;
}
##
执行文件描述符
这个方法通常用于执行已经打开的文件
- 恢复文件描述符
- 构建路径
- 调用这个路径的文件
int __fastcall execve_via_proc_self_fd(FILE *a1, __int64 a2, char *const *a3)
{
int v5; // [rsp+24h] [rbp-5Ch]
char *envp; // [rsp+28h] [rbp-58h] BYREF
char s[72]; // [rsp+30h] [rbp-50h] BYREF
unsigned __int64 v8; // [rsp+78h] [rbp-8h]
v8 = __readfsqword(0x28u);
v5 = fileno(a1);
sprintf(s, "/proc/self/fd/%d", v5);
envp = 0;
return execve(s, a3, &envp);
}
#
Step 4 恢复最外层XOR Key
从代码中来看,内存中的直接就是可执行文件,所以其最前面四个字节应该是
0x7f 45 4c 46 ("\x7fELF")
由于xor的可逆性,我们可以使用
key = cipher[0] ^ 0x7f
来获取xor key
#
Step 5 解密套娃
解密套娃需要两个变量,可执行文件的偏移和大小
在代码中可以读到,ELF偏移0x4080,大小是0xA9E0
我们还需要将这个虚拟地址转换为文件偏移
使用
readelf -lW Matryoshka
LOAD 0x002dd0 0x0000000000003dd0 0x0000000000003dd0 0x00ac94 0x00ac98 RW 0x1000
再使用以下公式计算文件偏移
file_off = (va - p_vaddr) + p_offset = (0x4080 - 0x3dd0) + 0x2dd0 = 0x3080
其中p_offset是LOAD的偏移,p_vaddr是LOAD的虚拟地址
使用该python脚本提取ELF
import sys
path='Matryoshka'
va=0x4080
size=0xA9E0
p_offset=0x2dd0
p_vaddr=0x3dd0
off = (va - p_vaddr) + p_offset
with open(path,'rb') as f:
f.seek(off)
blob=bytearray(f.read(size))
key = blob[0] ^ 0x7f
for i in range(len(blob)):
blob[i] ^= key
open('stage1_decrypted.elf','wb').write(blob)
print('file_off=',hex(off))
print('key_byte=',hex(key))
print('magic=',blob[:4])
#
Step 6 循环解密
最开始就已提到该程序使用第一字节来进行当前层解密,剩余部分传下去进行相同的操作
使用python脚本辅助这个过程
import sys
path='Matryoshka'
va=0x4080
size=0xA9E0
p_offset=0x2dd0
p_vaddr=0x3dd0
off = (va - p_vaddr) + p_offset
stage = 0
while True:
if stage == 0:
with open(path,'rb') as f:
f.seek(off)
blob=bytearray(f.read(size))
else:
with open('elf_stage'+str(stage-1)+'.elf','rb') as f:
f.seek(off)
blob=bytearray(f.read(size))
key = blob[0] ^ 0x7f
for i in range(len(blob)):
blob[i] ^= key
if blob[:4] != b'\x7fELF':
print("Invalid ELF file at stage", stage)
sys.exit(1)
open('elf_stage'+str(stage)+'.elf','wb').write(blob)
print("stage" + str(stage))
print('file_off=',hex(off))
print('key_byte=',hex(key))
print('magic=',blob[:4])
stage += 1
通过类似的解密方式我们可以得到
- 第一层key:0x0f
- 第二层key:0x0b
- 第三层key:0x03
#
Step 7 解密最后一层
把elf_stage_2拖进ida得到以下代码
__int64 __fastcall main(int a1, char **a2, char **a3)
{
const char *v3; // rdi
v3 = a2[1];
if ( atoi(v3) == 9 )
puts("u win good job!!!!");
else
sub_13D0(v3);
return 0;
}
点进atoi函数发现函数体没有任何逻辑,只发现了thunk属性,这意味着atoi函数是动态链接的
我们可以使用ldd命令查看动态链接库
ldd: warning: you do not have execution permission for `./elf_stage2.elf'
linux-vdso.so.1 (0x00007f013ccc9000)
/usr/lib/libinput-config.so (0x00007f013ccb6000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007f013ca00000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f013cccb000)
使用
readelf -Ws /usr/lib/libc.so.6 | rg -n " atoi(@@|$)"
查看导出函数
1816: 1812: 000000000003f8f0 22 FUNC GLOBAL DEFAULT 14 atoi@@GLIBC_2.2.5
因此我们知道atoi函数是libc库中的函数。通过查阅资料,我们可以发现atoi函数是ASCII to integer的意思
因此,最后一层的key应该是9
#
Step 8 验证
通过先前的发现,我们可以得出四位数密钥fb39!
./Matryoshka fb39
u win good job!!!!
#
Step ?另一种方式
通过观察,我们可以发现每层只对一个字节进行操作,因此我们可以尝试暴力破解
尝试的次数为62^x次,是可以接受的范围
可以使用以下脚本尝试爆破
import concurrent.futures
import itertools
import os
import subprocess
import tqdm
BIN = "./Matryoshka"
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
KEY_LENGTH = 4
def check_key(key):
try:
result = subprocess.run(
[BIN, key],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
if result.stdout:
return key
except Exception:
pass
return None
def main():
total_combinations = len(charset) ** KEY_LENGTH
print(f"Checking {total_combinations:,} combinations...")
iterator = map("".join, itertools.product(charset, repeat=KEY_LENGTH))
with concurrent.futures.ProcessPoolExecutor() as executor:
results = executor.map(check_key, iterator, chunksize=1000)
for result in tqdm.tqdm(results, total=total_combinations, unit="keys"):
if result:
tqdm.tqdm.write(f"\n[+] FOUND PASSWORD: {result}")
os._exit(0)
if __name__ == "__main__":
main()
实测在32核cpu上约30分钟即可遍历完四位所有组合