Melaton's Blog

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;
}

## 创建内存中可执行文件

执行流程:

  1. 使用memfd_create创建只存在于内存中的匿名文件描述符
  2. 使用ftruncate设置内存大小,确保文件可以正常加载
  3. 打开文件流fdopen(fd, "r+")
  4. 使用fwrite写数据
  5. 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;
}

## 执行文件描述符

这个方法通常用于执行已经打开的文件

  1. 恢复文件描述符
  2. 构建路径
  3. 调用这个路径的文件
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_offsetLOAD的偏移,p_vaddrLOAD的虚拟地址

使用该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分钟即可遍历完四位所有组合