在之前发的文章中,我们用了8节讲完了C语言的基本特性。
有了这部分知识铺垫以后,后续我们就可以来在Widnows下愉快的编写代码了。
本文面向的用户是想学习Widnows下编程的初学者。考虑到Windows编程有很多操作系统相关的知识,所以,在文章中讲解代码的时候,也会穿插介绍一些其他的必备知识。非初学者或者想着拿下面的代码来直接编译出个免杀马之类的话,可以关掉就可以了,并没有什么太高端的技术在里面。
希望可以帮助到对以后打算做恶意样本分析方向的朋友。会了正向写,再逆向起来就容易上手了。不然,直接给你恶意样本的源码,你都看不明白在干啥。
如果我们执行 ShellCode,那么这会比执行普通的恶意程序更难被检测出来。原因是 ShellCode 在内存中执行,不会存储在操作系统上,从而避免了静态防御检测。
1.生成ShellCode
我们可以通过 Metasploit 或者 Cobalt Strike 来生成可以直接使用的 ShellCode 代码。
查看服务端的IP地址:
使用 Metasploit 来生成 ShellCode 的示例。
|
|
生成的 ShellCode:
|
|
接下来,只需要将这段 ShellCode 替换一下后面代码中的payload
变量就可以了。
2.编写ShellCode_Loader
完整代码如下:
|
|
这段代码有几点需要注意:
(1)ShellCode的书写格式
在 main 函数上面,我们定义了两个全局变量 payload 和 len_payload,分别用来存放 ShellCode 的具体内容和 ShellCode 的长度数值。
其中,payload的以下两种写法都是可以正常使用的。
|
|
|
|
(2)变量初始化
|
|
- payload_memory:定义空指针类型变量 payload_memory。
- result:定义布尔类型变量 result。
- ThreadHandle:定义句柄类型变量 ThreadHandle。
- OldProtect:定义DWORD类型变量 OldProtect。
后面会具体介绍定义的这几个变量的用途。
句柄(HANDLE)是 Windows 操作系统中使用的一种数据类型,用于表示对资源(如文件、线程或进程)的引用。它本质上是一个指向由操作系统维护的数据结构的指针,其中包含有关所引用资源的信息。
Windows API 中广泛使用句柄来操作各种系统资源并与之交互。例如,当你使用 CreateFile() 函数打开文件时,该函数返回一个文件句柄,你可以使用该句柄读取或写入文件。同样,当你使用 CreateThread() 函数创建线程时,该函数会返回一个线程句柄,你可以使用该句柄来操作该线程,例如挂起、恢复或终止它。
需要注意的是,句柄不是实际资源本身,而是对它们的抽象引用。这意味着,例如,当你打开一个文件时,你会得到该文件的句柄,但实际的文件数据存储在磁盘上。句柄允许你操作文件并与之交互,例如读取或写入文件,但它并不是文件本身。类似地,当你创建一个线程时,你会得到该线程的句柄,但实际的线程数据和执行上下文由操作系统管理。句柄只允许你操作线程,例如设置线程的优先级或等待线程完成。
当我们不再需要某个资源时,请务必使用适当的 API 函数(例如 CloseHandle())关闭该资源的句柄,以释放系统资源并避免潜在的资源泄漏问题。
(3)程序工作流程
|
|
步骤1:为payload变量准备内存空间
|
|
这行代码使用了 VirtualAlloc 函数在当前程序进程的虚拟地址空间中分配一块内存。
VirtualAlloc 函数可以在当前调用进程的虚拟地址空间中保留、提交或保留并提交指定数量的内存页。该函数分配的内存会自动初始化为零。如果需要在另一个进程的地址空间中分配内存,需要使用 VirtualAllocEx 函数。该函数原型如下:
|
|
VirtualAlloc 函数包含4个参数:lpAddress、dwSize、flAllocationType、flProtect。
-
lpAddress(可选):指向要分配的内存区域的基地址的指针。如果此参数为NULL,则将由操作系统决定在何处分配内存。
-
dwSize:要分配的内存区域的大小,单位是字节。
-
flAllocationType:内存分配的类型。它必须包含以下的值之一或其组合:
-
MEM_COMMIT:为指定区域分配物理内存并返回指向分配区域第一个字节的指针。存储器的内容被初始化为零。
-
MEM_RESERVE:保留一定范围的进程虚拟地址空间,不分配任何物理内存。
-
MEM_RESET:将先前分配的内存区域的内容重置为零。设置此标志时,该函数将丢弃内存区域的现有内容并将所有字节设置为零。该标志只能用于已提交的页面,不能用于已保留但未提交的页面。使用 MEM_RESET 标志实际上不会释放任何内存,也不会更改内存区域的大小。它只是将所有字节设置为零,有效地将内存区域“重置”为其初始状态。如果需要释放内存,则应改用 VirtualFree 函数。
-
MEM_RESET_UNDO:撤销 MEM_RESET 的效果,将页面恢复到它们以前的内容。MEM_RESET_UNDO 只能在之前成功应用 MEM_RESET 的地址范围内调用。如果函数成功,则表示指定地址范围内的所有数据都完好无损。如果函数失败,至少地址范围内的一些数据已被零替换。
-
-
flProtect:该区域的内存保护。这可以是以下值之一:
- PAGE_EXECUTE:内存被标记为可执行。
- PAGE_EXECUTE_READ:内存被标记为可执行和可读。
- PAGE_EXECUTE_READWRITE:内存被标记为可执行、可读、可写。
- PAGE_EXECUTE_WRITECOPY:内存被标记为可执行,如果页面被修改,原始内容的副本保存在单独的页面中。
- PAGE_NOACCESS:内存不可访问。
- PAGE_READONLY:内存是只读的。
- PAGE_READWRITE:内存可读写。
- PAGE_WRITECOPY:内存可写,如果页面被修改,原始内容的副本保存在单独的页面中。
返回值:如果函数成功,返回值是页面分配区域的基地址。 如果函数失败,则返回值为 NULL。
汇总下的话就是,程序中的这行代码,通过 VirtualAlloc 实现了在进程的虚拟地址空间中保留并提交一块足够大的内存,以容纳 payload 数组,并将这块内存设置为允许读取和写入。该函数返回一个指向已分配内存块的基地址的指针,该内存块存储在 payload_memory 变量中。
步骤2:拷贝payload的具体内容到payload_memory指向的内存空间
|
|
RtlMoveMemory 是 Windows API 提供的内存复制函数。它类似于 C 中的 memcpy() 函数,但它针对性能进行了优化,并且可以处理重叠的内存区域。该函数原型如下:
|
|
- Destination:指向目标缓冲区起始地址的指针。
- Source:指向源缓冲区起始地址的指针。
- Length:要从源缓冲区复制到目标缓冲区的字节数。
返回值:无
汇总下的话就是,程序中的这行代码,通过 RtlMoveMemory 实现了将 payload 的具体内容复制到之前使用 VirtualAlloc() 分配的 payload_memory 缓冲区内存中。
步骤3:设置缓存区可执行
|
|
VirtualProtect 函数可以更改当前调用进程的虚拟地址空间中指定页面区域的访问保护。此函数通常用于将内存区域标记为可执行,以便可以执行位于该处的代码。要更改任何进程的访问保护,需要使用 VirtualProtectEx 函数。该函数原型如下:
|
|
- lpAddress:指向要更改其访问保护属性的区域的起始地址的指针。
- dwSize:要更改其访问保护属性的区域的大小,以字节为单位。
- 新的内存保护选项。这是 PAGE_EXECUTE、PAGE_EXECUTE_READ、PAGE_EXECUTE_READWRITE、PAGE_EXECUTE_WRITECOPY、PAGE_READWRITE、PAGE_READONLY、PAGE_WRITECOPY 和 PAGE_NOACCESS 的组合。保护选项确定允许对内存区域进行何种类型的访问。
- lpflOldProtect:指向一个变量的指针,该变量接收指定页面区域中第一页的先前访问保护。如果此参数为 NULL 或未指向有效变量,则函数失败。
返回值:如果函数成功,则返回值为非零。如果函数失败,则返回值为零。
汇总下的话就是,程序中的这行代码,通过 VirtualProtect 实现了对 payload_memory 指向的内存区域保护属性的修改。 将保护属性从 PAGE_READWRITE 更改为了 PAGE_EXECUTE_READ,这使得这块内存可以作为代码执行。旧的保护属性值存储在 OldProtect 变量中供以后使用。该函数会返回一个布尔值,结果存放到 result 变量中,用于指示保护属性更改是否成功。
那么问题来了。我为什么不直接在使用 VirtualAlloc 分配内存的时候为这块内存设置属性: PAGE_EXECUTE_READWRITE 呢?
当使用 VirtualAlloc 分配内存时,我可以将保护属性设置为 PAGE_EXECUTE_READWRITE,但这将允许任何有权访问内存的人修改它,包括可能试图利用你正在执行的代码的潜在攻击者。通过先将保护属性设置为 PAGE_READWRITE,然后再使用 VirtualProtect 将其更改为 PAGE_EXECUTE_READ,这将只允许在将 payload 复制到内存缓冲区期间对内存进行写访问。之后,将更改保护属性以禁止写访问,这有助于防止潜在的缓冲区溢出和其他基于内存的攻击。因此,通常建议尽可能使用最严格的保护属性,以降低代码受到攻击的风险。
步骤4:运行payload
|
|
这段代码涉及两个主要的函数:CreateThread 和 WaitForSingleObject。
CreateThread 函数原型如下:
|
|
CreateThread 函数可以创建一个在当前调用进程的虚拟地址空间中执行的线程。要创建一个在另一个进程的虚拟地址空间中运行的线程,需要使用 CreateRemoteThread 函数。
-
lpThreadAttributes(可选):指向 SECURITY_ATTRIBUTES 结构体的指针,决定返回的句柄是否可以被子进程继承。如果 lpThreadAttributes 为 NULL,则句柄不能被继承。如果 lpThreadAttributes 不为 NULL,则句柄可以由 CreateProcess 函数创建的子进程继承。SECURITY_ATTRIBUTES 结构体指定了新线程的安全描述符。如果 lpThreadAttributes 为 NULL,则线程获得默认的安全描述符。
-
dwStackSize:新线程堆栈的初始大小,以字节为单位。如果此参数为零,则新线程使用可执行文件的默认大小。
-
lpStartAddress:指向要由线程执行的应用程序定义函数的指针。这个指针代表线程的起始地址,函数的类型必须是 LPTHREAD_START_ROUTINE。
-
lpParameter(可选):指向要传递给线程函数的变量的指针。如果不需要参数,则此参数可以为 NULL。
-
dwCreationFlags:控制线程创建的标志。
-
0:线程创建后立即运行。
-
CREATE_SUSPENDED:线程以挂起状态创建,直到在 CreateThread 返回的句柄上调用 ResumeThread 函数后才会运行。
-
STACK_SIZE_PARAM_IS_A_RESERVATION:用于指示 dwStackSize 参数指定要为线程保留的堆栈空间量而不是提交大小。设置此标志后,系统会为线程保留指定数量的堆栈空间,但不会立即提交。相反,堆栈在线程使用时逐页提交。当事先不知道线程所需的堆栈空间量,或者在线程执行期间需要动态调整堆栈大小时,此标志很有用。
-
-
lpThreadId(可选):指向接收线程标识符的变量的指针。如果此参数为 NULL,则不返回线程标识符。线程标识符是指线程创建时分配给它的唯一标识符,它用于标识系统内的特定线程,并且在线程终止之前一直有效。与线程标识符不同,线程句柄是指向系统用来管理线程的线程对象的指针。它由 CreateThread() 函数返回,可用于操作线程,例如挂起、恢复、终止线程或者等待线程终止、获取其退出代码等。
返回值:如果函数成功,返回值是新线程的句柄。如果函数失败,则返回值为 NULL。
WaitForSingleObject 函数原型如下:
|
|
WaitForSingleObject 函数会一直等待,直到指定的对象处于信号状态(signaled state)或超时间隔( time-out interval)结束。它通常与同步对象一起使用,例如互斥量、信号量和事件。
在计算机编程中,信号(signal)用于通知线程或进程事件已经发生。线程或进程可以在继续执行之前等待信号的设置。信号由标志(flag)表示,标志的状态是已发出信号(signaled)或未发出信号(unsignaled)。当标志处于已发出信号(signaled)状态时,意味着事件已经发生,任何等待信号的线程或进程都可以继续进行。发出信号状态表示满足特定条件,等待线程或进程可以继续执行。
在计算机编程中,同步(synchronization)是指协调多个线程或进程以确保它们以可预测和有序的方式执行。同步对象(Synchronization Objects)用于实现这种协调,并为线程或进程提供一种相互通信的方式。
互斥量(Mutexes)、信号量(Semaphores)和事件(Events)是三种常用的同步对象类型。
互斥量(Mutexes):互斥量是一种同步对象,用于保护共享资源不被多个线程并发访问。它是一种锁定机制,可确保一次只有一个线程可以访问受保护的资源。当线程想要访问受保护的资源时,它首先获取互斥量。如果互斥锁已被另一个线程持有,则请求线程将阻塞,直到互斥锁可用为止。
信号量(Semaphores):信号量是一种同步对象,它允许有限数量的线程同时访问共享资源。它维护当前访问资源的线程数,并在计数达到预定义限制时阻止其他线程访问资源。信号量可用于限制访问资源的线程数,或同步多个线程的执行。
事件(Events):事件是一个同步对象,用于通知线程或进程之间事件的发生。事件有两种状态,已发出信号(signaled)和未发出信号(unsignaled)。当一个事件处于未发出信号(unsignaled)时,任何等待该事件的线程都会被阻塞。当事件发出信号(signaled)时,任何等待的线程都会被释放并可以继续执行它们。事件可用于实现范围广泛的同步模式,例如发出任务完成信号、通知其他线程出现错误或协调多个线程的执行。
总之,互斥量、信号量和事件是用于协调多个线程或进程执行的同步对象。它们旨在防止竞争条件并确保以可预测和有序的方式访问共享资源。
WaitForSingleObject 函数包含两个参数:
- hHandle:要等待的对象的句柄。这可以是通过调用 SetEvent 或 ReleaseMutex 等函数发出信号的同步对象的任何句柄。
- dwMilliseconds:超时间隔,以毫秒为单位。如果在此超时间隔结束之前未向对象发出信号,则该函数将返回 WAIT_TIMEOUT 值。
WaitForSingleObject 函数返回值:
- WAIT_OBJECT_0:对象已处于信号状态(signaled)。
- WAIT_ABANDONED:对象的状态已发出信号,但拥有线程在释放对象之前已终止。
- WAIT_TIMEOUT:超时间隔已过,对象的状态为未发出信号(unsignaled)。
- WAIT_FAILED:函数失败。
回到前面提到的这段代码:
|
|
程序中的这段代码,会检查之前 VirtualAlloc 函数调用的结果是否不等于零(该函数返回值不为零就表示函数成功执行)。如果结果不为零,代码将继续使用 CreateThread 函数创建一个新线程。线程从 payload_memory 变量指向的地址开始执行。
创建线程后,代码会使用 WaitForSingleObject 函数等待线程完成。 dwMilliseconds 参数的值 -1 表示该函数应无限期地等待线程完成。
总的来说,这段代码通过在单独的线程中执行来运行 payload,并等待它执行完成,然后再继续执行程序的其他功能。
3.程序测试
(1)编译程序
Windows下以Dev-C++为例编译程序。
这里用的Dev-C++不是Dev-C++ IDE,那个版本不是特别好用。建议使用orwelldevcpp版本。
从这里可以下载这个版本的Dev-C++:http://orwelldevcpp.blogspot.com/。
这个工具是直接基于GCC/G++去编译程序,只要装了Dev-C++,我们电脑上就同时安装好了GCC套件了。我们可以将其可执行路径加入到系统环境变量,方便以后在命令行中使用。
这样做有个好处,我们省去了直接在Widnows一步一步安装MinGW(gcc/g++)套件的繁琐步骤。
为什么在Windows没用Visual Studio来写呢?
因为,我感觉就这么点代码不值当的开个大的IDE环境来写。等后续代码量大了,牵扯到复杂操作了,我们演示用Visual Studio来编写程序。不过效果都差不多,不用太计较用什么,关键是理解了代码就可以。
编译步骤如下:
Linux下以g++为例编译程序。
|
|
编译:
|
|
- -o:指定输出的文件名
- -static-libstdc++:将标准C++库静态链接到可执行文件。
- -static-libgcc:将gcc库静态链接到可执行文件。
- -mwindows:将可执行文件设置为Windows GUI程序(默认是控制台程序),效果是程序启动后不再显示黑色的控制台窗口。
(2)服务端开启监听端口
|
|
(3)运行生成的可执行程序
在客户端运行我们刚刚生成的可执行文件 msf_shell.exe。
在服务端即可看到新上线的Shell。
(4)IDA Pro简单查看
用IDA Pro分析下这个程序,你会发现大差不差的,你已经能明白它的工作原理了。
特别声明:由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,我不为此承担任何责任。
作者有对此文章的修改和解释权。如欲转载或传播此文章,必须保证此文章的完整性,包括版权声明等全部内容。未经作者的允许,不得任意修改或者增减此文章内容,不得以任何方式将其用于商业目的。切勿用于非法,仅供学习参考。
参考链接:https://medium.com/@s12deff/executing-malicious-shell-code-with-c-8ad034e45044