最近我玩了一个 ctf,有一个带有密钥生成功能的勒索软件,它执行以下操作:

  • 获取计算机名称
  • 获取mac地址
  • 连接它们
  • md5

勒索软件每次为同一台计算机生成相同的密钥,所以我想看看我是否可以使用内置的密钥生成功能从勒索软件中提取密钥。本次的测试环境均在WIN10虚拟机中。

我想出了两种方法,我花了几天时间搞出了一种办法。不过经过我的细心研究,偶然间发现了另一种方法。两种方法各有千秋,不过我认为第二种方法相较于第一种更加实用而且方便。

首先我们需要知道一条信息,我的目标是调用勒索软件中负责生成加密密钥的函数。为此,我需要将 dll 加载到我的进程中,但是当我加载它的第二次时,它进入入口点后,就会加密加密我的文件

所以基本上我想弄清楚的是一种在不调用其入口点/DllMain 的情况下加载 dll 的方法,我也想在这样做的同时学习一些新的东西,所以没有手动映射(并不是说它不是有效的解决方案),并且没有在dll文件上打打补丁啥的。

第一种方法:修补ntdll

我最初对我必须经历的过程的想法是:

  1. 将dll加载到我自己的进程中
  2. 调用密钥生成函数
  3. 利润

加载dll

这部分是最耗时的。因为接下来的部分将详细解释我采用的方法,以及它们是如何惨败的

第一个想法:LoadLibraryA

我有一个可以练习的 dll,它所做的只是使用MessageBoxW. 问题是,一旦我使用 加载它LoadLibraryA,就会弹出一个小消息框,提醒我这样一个事实,即所述函数不仅将 dll 加载到进程空间中,而且还调用它们的入口点,最终调用它DllMain导致 dll 执行它的东西,在这种情况下是加密我们的文件,所以这条路线不起作用

第二个想法:LoadLibraryExA

此功能基本上LoadLibraryA带有额外的加载选项,其中一个选项是DONT_RESOLVE_DLL_REFERENCES根据msdn docs执行以下操作

If this value is used, and the executable module is a DLL, the system does not call DllMain for process and thread initialization and termination. Also, the system does not load additional executable modules that are referenced by the specified module.

我想我自己“一个不调用的函数DllMain,太棒了!”。但后来我发现这个伤口不起作用有两个原因

根据 msdn 文档的第一个是当一个人只想访问 DLL 中的数据或资源时使用此标志,这意味着它不会是可执行的,但是这是我可以通过更改内存来处理的问题保护

另一个正如标志名称所DONT_RESOLVE_DLL_REFERENCES暗示的那样……是的,该函数不解析引用,包括函数导入,但这意味着什么?

在正常情况下,MessageBoxW在我的情况下,您的 dll 会调用一个函数。操作系统加载程序通过加载包含它的 dll 来确保函数存在于内存中,它还确保您的 dll 正在调用所述函数所在的正确地址。换句话说,操作系统加载器得到了你的支持

但是当使用 加载 dll 时DONT_RESOLVE_DLL_REFERENCES,您的 dll 尝试调用的函数在内存中不存在,并且当您的 dll 尝试调用它时,它只是调用了该函数应该位于的地址,并且由于一堆在该位置而不是原始函数中存在零,程序崩溃(我不得不以艰难的方式学习这一点)

第三个想法:深入了解 LoadLibraryA

到了这个时候,我知道使用LoadLibraryExA任何特殊标志都行不通,但是呢LoadLibraryA?(顺便说一句LoadLibrary,它只是一个包装器 arround LoadlibraryExA, callLoadLibraryA(libname)相当于 call LoadLibraryExA(libname, 0),第二个参数为 0 表示没有特殊的加载标志。这并不是真正调用了哪个函数,而是最终的 flags 参数LoadLibraryExA

LoadLibraryA做我想做的一切,它加载一个具有正确内存保护的 dll 和一切,它只做我不需要的一件事,那么为什么不尝试驾驭它呢?现在是时候反转LoadLibraryA来确定它在哪个点调用 dll 的入口点,这样我就可以修补它了

经过一些步骤和功能后,我找到了负责调用 Dll 入口点的函数以及它是如何从LoadLibrary

LoadLibrary()	-> kernel32.LoadLibraryEx
		-> kernelbase.LoadLibraryExA
		-> kernelbase.LoadLibraryExW
		-> ntdll.LdrLoadDll
		-> ntdll.LdrpLoadDll (not exported)
		-> ntdll.LdrpPrepareModuleForExecution (not exported)
		-> ntdll.LdrpInitializeGraph (not exported)
		-> ntdll.LdrpInitializeNode (not exported)
		-> ntdll.LdrpCallTlsInitialiazers (not exported)
		-> ntdll.LdrpCallInitRoutine (not exported)
		-> call rsi (rsi has a function pointer pointing at the dll entry point)

(函数名称可以在 windows 版本之间更改,即我发现 windows 10 有LdrpLoadDllInternal而不是LdrpLoadDll

ntdll.LdrpCallInitRoutine是负责通过执行以下指令调用 Dll 入口点的函数

00007FF88EA6DCEA            | 4D:8BC6          | mov r8,r14
00007FF88EA6DCED            | 8BD3             | mov edx,ebx
00007FF88EA6DCEF            | 48:8BCF          | mov rcx,rdi
00007FF88EA6DCF2            | FFD6             | call rsi

rsi指向 DLL 入口点的位置

现在,如果我们nop删除这些指令,则不会调用 dll 条目,但是对于正在加载的每个dll 都会发生这种情况,这会导致程序在使用新加载的 dll 中的函数时崩溃

所以我尝试挂钩ntdll.LdrpLoadDll,因为它是最后一个获取 dll 名称的导出函数。所以我的方法是

- intercept dlls being loaded
- if the dll name matches out target dll patch `ntdll.LdrpCallInitRoutine` so it doesn't call the entry point
- unpatch `ntdll.LdrpCallInitRoutine` so every other dll loads just fine

也是我ntdll.LdrpCallInitRoutine现在在内存中找到位置的方式,因为它没有被导出,至少在我的 ntdll 中,所述函数正好存在 274 个字节之后RtlActivateActivationContextUnsafeFast(一个随机函数位于我正在搜索的那个之前),所以我GetProcAddress在上述函数上使用然后从那里计算地址

所以基本上是这样的

#define CALL_RSI_OFFSET 274
// the address of the call instruction that invokes the dll entry point in ntdll.LdrpCallInitRoutine 
PBYTE call_rsi;
PVOID MyLdrLoadDll;

int main(void){

	// Get a handle to ntdll
	HANDLE HNtdll = GetModuleHandleA("ntdll.dll");
	if(!HNtdll){
		fprintf(stderr, "couldn't find ntdll.dll :(");
		return -1;
	}

	// get the address of the `call rsi` responsible for calling the dll entry point
	PVOID base = (PVOID)GetProcAddress(HNtdll, "RtlActivateActivationContextUnsafeFast");
	call_rsi = (PBYTE)base + CALL_RSI_OFFSET;

	// get the address of LdrLoadDll
	MyLdrLoadDll = (_LdrLoadDll)GetProcAddress(HNtdll, "LdrLoadDll");
	if(!MyLdrLoadDll){
		fprintf(stderr, "couldn't find LdrLoadDll :(");
		return -1;
	}

	// hook LdrLoadDll
	hook((PVOID)MyLdrLoadDll, (PVOID)FakeLdrLoadDll);

这是我的FakeLdrLoadDll功能

NTSTATUS WINAPI FakeLdrLoadDll( PWSTR IN SearchPath,
		PULONG IN LoadFlags,
		PUNICODE_STRING DllName,
		HMODULE *BaseAddress){

	char *original_bytes = malloc(call_rsi_len * sizeof(char));
	if(!original_bytes){
		fprintf(stderr, "calloc failed with error : %ld\n", GetLastError());
		exit(EXIT_FAILURE);
	}

	BOOL IsTargetDll = !!wcscmp(WIDE_DLL_NAME, DllName -> Buffer);

	if(!IsTargetDll)
		patch(call_rsi, original_bytes);

	// un-hook LdrLoadDll
	unhook(MyLdrLoadDll);

	// invoke the unhooked LdrLoadDll
	if(MyLdrLoadDll(SearchPath, LoadFlags, DllName, BaseAddress))
		fprintf(stderr, "I couldn't load %ls :(\n", DllName -> Buffer);

	// re-hook
	hook((PVOID)MyLdrLoadDll, FakeLdrLoadDll);

	// unpatch
	if(!IsTargetDll)
		unpatch(call_rsi, original_bytes);

	free(original_bytes);

	// return True meaning the dll was sucessfully loaded
	return TRUE;
}

patch只需放置 nops 而不是调用函数,unpatch将原始字节放回原处

这确保除了我们的目标之外的每个 dll 都将调用其入口点以正确初始化

然后我只是加载了dll

	dll = LoadLibraryA(DLL_NAME);
	if(!dll){
		fprintf(stderr, "couldn't load %s, error code = %ld\n", DLL_NAME, GetLastError());
		return -1;
	}

现在 dll 驻留在内存中,我需要

- put a 0xc3 at the first byte of DllMain so calling the dll entry point won't encrypt my files
- manually call the dll entry point so the Dll gets initialized
- call the key generation function to get the key

修补 DllMain

#define DLL_TO_DLLMAIN	0x2600
...
	DllMain MyDllMain = (DllMain)((PBYTE)dll + DLL_TO_DLLMAIN);
	PatchDllMain(MyDllMain);
void PatchDllMain(PVOID addr){
	DWORD whatever;

	if(!VirtualProtect(addr, 1, PAGE_EXECUTE_READWRITE, &whatever)){
		fprintf(stderr, "VirtualProtect failed with code = %ld\n", GetLastError());
		return;
	}

	// TODO : make the source match this line (deleted ptr var)
	*(PBYTE)addr =  0xc3;
}

调用 dll 入口点

#define DLL_TO_ENTRY	0x1C6AC
...

	DllEntry EntryPoint = (DllEntry)((PBYTE)dll + DLL_TO_ENTRY);
	EntryPoint(dll, DLL_PROCESS_ATTACH, NULL);

提取加密密钥

	key_gen my_key_gen = (key_gen)((PBYTE)dll + KEY_GENERATION_OFFSET);
	printf("key = %s\n", my_key_gen());

结果

where_key

哎呀???为什么我收到访问冲突而不是加密密钥

这部分让我有点失望,每一步都按预期完成,我无法理解问题出在哪里

我尝试了一个不同的 dll,它所做的只是弹出一条消息MessageBoxW,并面临相同的结果

更令人困惑的是,正在调用 dll 代码,并且访问冲突发生在随机内部函数内部ntdllRtlAllocateHeap对于勒索软件,以及调用另一个函数的其他Nt*函数MessageBoxW

当我尝试不同的东西来调试它时,偶然发现了这个解决方案。对于使用的 dllMessageBox在调用 Dll 函数之前加载user32.dll,这对我来说毫无意义,因为

A. that dll was already in memory before I loaded my test dll
B. loading the test dll should cause any dll dependency to load as well

所以我去了勒索软件的 dll,找出了正在调用的函数RtlAllocateHeap,它是GetAdaptersInfo,查找它所在的 dll(Iphlpapi.dll),在勒索软件之前加载它,然后……

再次这对我来说毫无意义,因为那个 dll 已经在内存中,我想这是我将来可能会理解的东西

总结

现在一切正常,花花公子,直到你在另一台计算机上运行它,你发现它call rsi不再真正位于 274 字节RtlActivateActivationContextUnsafeFast,或者根本不存在(被call指令的其他变体取代)。所以我尝试探索使解决方案尽可能通用的可能性

我原来的字节是这样的

00007FF88EA6DCEA            | 4D:8BC6          | mov r8,r14
00007FF88EA6DCED            | 8BD3             | mov edx,ebx
00007FF88EA6DCEF            | 48:8BCF          | mov rcx,rdi
00007FF88EA6DCF2            | FFD6             | call rsi

这些是来自另一个 ntdll 的字节

00007FF88EA6DCEA            | 4D:8BC7          | mov r8,r15
00007FF88EA6DCED            | 8BD6             | mov edx,esi
00007FF88EA6DCEF            | 48:8BCE          | mov rcx,r14
00007FF88EA6DCEF            | 48:8BC4          | mov rax,r12
00007FF88EA6DCF2            | FFD6             | call rsi

我注意到大多数字节在不同版本之间保留,其他字节仅在一两个半字节中有所不同(使用不同源寄存器的相同指令),并且有些指令存在于一个版本中但不存在于另一个版本中,所以我制作了NibbleSigScan哪个是一个简单的程序,允许您使用通配符半字节,在前面的示例中,您可以搜索模式0x4d, 0x8b, 0xc?, 0x8b, 0xd?,它将匹配两个版本中的前 2 条指令

检查其他版本后,我找到了 5 个模式并为它们写了签名。我已经在大约 30 个版本的 ntdll 上尝试了签名(向那些向我发送他们的人大喊大叫!),他们对所有这些都进行了工作,除了我在 Windows 7 上的实验室中的一个,但我认为没有意义也支持那个版本

现在这应该适用于大多数ntdll 版本,但仍然建议在 vm 上运行第二个解决方案,以防有一个不同的版本我没有为其签名(可能存在其他版本),但是第一种方法会起作用像魅力一样,因为它独立于 ntdll

第二种方法:Dll Load notifications

根据LdrRegisterDllNotification让您注册一个在动态链接发生之​​前执行的 Dll 回调,即我们的回调函数将在每次加载/卸载 dll 时调用,在调用 Dll 条目之前,在回调函数返回之前不会发生,这非常适合这种情况,因为它可以让我们在 dll 的条目被执行之前对它做任何我们想做的事情,这正是我所需要的

现在的计划是:

  • 注册一个 dll 回调
  • 加载勒索软件 dll
  • 当控件交给回调函数时,将aret放在DllMain的开头,这将使它一被调用就返回,同时让入口点被执行,从而导致dll正确初始化
  • 调用负责生成加密密钥的函数

注册一个 dll 回调

#define DLL_NAME		"r101.dll"
#define WIDE_DLL_NAME		L""DLL_NAME
...

    // Get a handle to ntdll
    HANDLE HNtdll = GetModuleHandleA("ntdll.dll");
    if(!HNtdll){
        fprintf(stderr, "couldn't Get a handle to ntdll.dll :(\n");
        return -1;
    }

    // get the address of LdrRegisterDllNotification
    LdrRegisterDllNotification f = (LdrRegisterDllNotification)GetProcAddress(HNtdll, "LdrRegisterDllNotification");
    if(!f){
        fprintf(stderr, "LdrRegisterDllNotification not found :(\n");
        return -1;
    }

    // register a dll callback
    PVOID cookie = NULL;
    if(f(0, LdrDllNotification, (PVOID)WIDE_DLL_NAME, &cookie) != STATUS_SUCCESS){
    	fprintf(stderr, "LdrRegisterDllNotification failed with error code = %ld\n", GetLastError());
    	return -1;
    }

首先我们得到一个句柄Ntdll.dll,检查LdrRegisterDllNotification上述dll中的存在并解析它的地址,然后注册一个通知回调,它将调用LdrDllNotification我们稍后将介绍的函数,回调函数获取一个指向我们试图修补的 dll 名称作为参数

加载勒索软件 dll

然后我们只需使用LoadLibraryA它加载 dll 将导致我们的 dll 回调函数被调用,其中包含有关加载的 dll 的通用信息,以及指向我们传递给它的 dll 名称的指针

  HMODULE Htry = LoadLibraryA(DLL_NAME);
  if(!Htry){
    fprintf(stderr, "couldn't find %s :(\n", DLL_NAME);
    return -1;
  }

修补 DllMain

我们回调的签名应采用以下形式

VOID CALLBACK LdrDllNotification(
  _In_     ULONG                       NotificationReason,
  _In_     PCLDR_DLL_NOTIFICATION_DATA NotificationData,
  _In_opt_ PVOID                       Context
);

在加载 dll 时,NotificationReason其值为LDR_DLL_NOTIFICATION_REASON_LOADED, NotificationData是一个_LDR_DLL_LOADED_NOTIFICATION_DATA包含以下信息的结构

typedef struct _LDR_DLL_LOADED_NOTIFICATION_DATA {
    ULONG Flags;                    //Reserved.
    PCUNICODE_STRING FullDllName;   //The full path name of the DLL module.
    PCUNICODE_STRING BaseDllName;   //The base file name of the DLL module.
    PVOID DllBase;                  //A pointer to the base address for the DLL in memory.
    ULONG SizeOfImage;              //The size of the DLL image, in bytes.
}

所以每次调用回调函数时,它都会获取 Dllname、DllPath、加载 dll 的地址、大小和Context指针,这是我们控制的参数,在这种情况下,它是我们要修补的 dll 的名称

现在我们只需计算 DllMain 的地址,然后我就可以对其进行修补

  • 打开 dll 是您选择的反汇编程序(我的是 ida)
  • 将程序变基为 0
  • 转到 dllMain 并检查它的偏移量
    ,我们最终得到偏移量值0x2600
记一次从简单的勒索软件中动态提取加密密钥插图1

这意味着如果您知道 dll 在内存中的加载位置,如果您添加0x2600到该地址,您将获得DllMain. 现在如前所述,每次加载 dll 时,我们的 callbcks 函数都会获取此信息,这是回调函数的样子

#define DLL_MAIN_OFFSET 0x2600
...
VOID CALLBACK LdrDllNotification(
  _In_     ULONG                       NotificationReason,
  _In_     PCLDR_DLL_NOTIFICATION_DATA NotificationData,
  _In_opt_ PVOID                       Context
){

    PBYTE ptr;
    DWORD OldProtection;

    wchar_t *target_dll = (wchar_t *)Context;

    // only intercept the dlls being loaded
    if(NotificationReason != LDR_DLL_NOTIFICATION_REASON_LOADED)
        return;

    // check the dll name against our target one
    if(wcscmp(target_dll, NotificationData -> Loaded.BaseDllName -> Buffer))
	    return;

    ptr = NotificationData -> Loaded.DllBase;
    if(!VirtualProtect(ptr, NotificationData -> Loaded.SizeOfImage, PAGE_EXECUTE_READWRITE, &OldProtection)){
        fprintf(stderr, "VirtualProtect failed with code = %ld\n", GetLastError());
        return;
    }

   // make DllMain return as soon as it's called
   // so our files don't get encrypted
   *(ptr + DLL_MAIN_OFFSET) = 0xc3;
}

此函数检查加载的 dll 的调用,将它们的名称与我们要修补的 dll 进行比较,使 DllMain 的第一个字节可写以便我们可以对其进行编辑,然后将 a0xc3写入其中,这将使 DllMain 尽快返回它被称为

调用密钥生成函数

生成密钥的函数是在内部调用的第二个函数StartRansomware()。和之前一样,我们得到偏移量0x20e0

该函数不接受任何参数,并返回char指向加密密钥的指针。现在我们要做的就是声明一个上述类型的函数指针,让它指向我们的函数,调用它并打印密钥

现在,如果您还记得我们之前用来调用回调函数的代码

  HMODULE Htry = LoadLibraryA(DLL_NAME);
  if(!Htry){
    fprintf(stderr, "couldn't find %s :(\n", DLL_NAME);
    return -1;
  }

这会将dll地址放入Htry,知道函数的偏移量我们可以这样做

#define KEY_GENERATION_OFFSET 0x20e0

...
  char* (*key_gen)(void) = (char *(*)(void))((PBYTE)Htry + KEY_GENERATION_OFFSET);
  printf("key = %s\n", key_gen());

这就是我们得到的结果

记一次从简单的勒索软件中动态提取加密密钥插图2

另一条路线

不必修补DllMain以立即返回,然后手动调用DllEntry以初始化 dll。在处理遵循检查ul_reason_for_call参数标准的 dll 的情况下,可以修补 Dll 入口点以调用 DllMain 的值,而不是DLL_PROCESS_ATTACH在不加密文件的情况下初始化 dll,但手头没有加密文件