C#/C++双进程互拉:让任务管理器也结束不了的进程保护方案(3.0版)

C#/C++双进程互拉:让任务管理器也结束不了的进程保护方案(3.0版)
简介面向Windows平台进程保护需求这份C#/C混合编程示例适合对进程防结束、Hook技术有研究兴趣的中高级开发者。V3.0版核心保护逻辑由C编写C#通过DLL调用实现可直接双击64位exe验证效果也支持在XP、Win7 32/64位环境下编译运行。资源共164个文件、17.8MB主要包含C源文件.cpp/.h、C#工程文件.cs/.csproj、编译产物.dll/.exe/.lib/.obj以及Visual Studio工程配置.sln/.vcxproj等其中Debug生成记录、pdb调试符号和sdf/ipch等缓存文件一并保留便于对照源码理解项目构建过程。已有1032人学习下载。透过该资源可以掌握C#调用C DLL完成进程自我保护的整体思路以及遍历进程、注入DLL、Hook关键API等具体实现方法适合在病毒分析、安全对抗、反调试场景中作为参考模板。 做了这么多年客户端开发会遇到一个挺实际的诉求程序跑在用户机器上结果用户一个 CtrlAltDel从任务管理器里直接就结束掉了。像自助终端、数字标牌、行业上位机、内网监控客户端这类需要长时间常驻的场景一旦进程被误杀或第三方清理工具干掉轻则业务中断重则整个现场瘫痪。“C#/C保护自身进程无法被任务管理器结束3.0版”这个方案就是围绕这个需求做的。我用 C# 承载业务逻辑用 C 写守护核心通过双进程互拉、权限差异、作业对象绑定这一套组合拳把“进程被结束”这件事变成“结束了一两秒后自己又回来了”实测下来对付任务管理器普通权限的结束操作效果相当稳。这篇文章会把这套 3.0 方案的整体思路、核心代码、实测表现和踩坑记录完整拆出来适合有 C# 或 C 基础、想给自家客户端加“防误杀”能力的开发者参考。1. 项目背景与3.0版的整体设计思路1.1 为什么需要进程自我保护先对齐一个概念进程自我保护不是“跟用户对抗”而是防止误杀、防呆、防运维事故。我遇到过的真实场景比如医院自助挂号机上的客户端患者或护士误点任务管理器直接结束进程机器就变成了大砖头再比如数字标牌播放端现场维护人员看它占用 CPU 高随手就结束掉了导致整条展示链路断掉还有无人机房里的数据采集上位机进程一旦掉了没人发现可能几个小时后数据才有缺口。这些场景里进程能不能常驻比功能本身还重要。所以做这套 3.0 方案的定位很明确让目标进程在常规用户操作下“结束不掉”。注意我的措辞是“常规用户操作”因为严格来说Windows 在用户态并没有提供绝对阻塞 TerminateProcess 的内置开关所谓的“结束不掉”是权限差异、快速复活、进程关联几种手段的组合效果。这个后面会展开讲。1.2 3.0版方案架构与前代对比这套方案我迭代过三轮。1.0 版只是把关键进程注册成 Windows 服务靠服务管理器拉起重启但用户只要找到服务里的对应进程一样能结束而且服务重启有延迟。2.0 版加了单进程的看门狗但看门狗本身也会被杀一旦看门狗和主程序同时被选中结束就彻底崩了。3.0 版重新做了架构改成双进程互相守护 作业对象生命周期绑定 权限提升。整个系统由两个角色组成守护进程C编写命名为 Guard.exe管底层句柄、作业对象、权限纪律负责拉起业务进程业务进程C#编写命名为 MainApp.exe跑真正业务逻辑同时反向监控守护进程守护进程一旦退出就重新拉起两个进程通过命名管道做心跳探活互相看着对方。只要有一个还活着另一个就一定会被拉起来。版本防护方式弱点3.0的处理1.0Windows 服务自启服务进程被结束无人拉起双进程互拉不存在“单一被谁全灭”2.0单看门狗监控看门狗同被结束即失效守护者也加入互拉闭环3.0双进程互拉Job Object权限维持无仅受限于系统权限边界三层防护同时生效选型上为什么守护端用 C 而不是 C#因为双进程互拉需要频繁操作进程句柄、Token、Job Object 这类 Windows 内核对象C 直接调 API 没有任何中间层开销出错时也更容易定位。C# 端则负责业务开发效率高生态好通过 P/Invoke 调用守护端的接口完成双向守护。两者各司其职。2. 核心原理为什么任务管理器“结束不掉”它2.1 任务管理器结束进程的真实路径在 Windows 上任务管理器“结束任务”按钮的背后是一套访问权限检查的链路。它先通过 Process32First/Process32Next 遍历进程快照找到目标 PID然后调用 OpenProcess 请求 PROCESS_TERMINATE 访问权最后执行 TerminateProcess 强制销毁进程。问题来了——OpenProcess 能不能拿到 PROCESS_TERMINATE 权限取决于调用者任务管理器的权限与目标进程 DACL 的比对结果。任务管理器默认以当前登录用户的中等完整性级别运行如果目标进程以管理员权限高完整性级别运行普通任务管理器调用 OpenProcess 时会收到“拒绝访问”TerminateProcess 自然执行不了。这就是第一道防线的原理让自己跑在高权限让“结束”这个动作根本无从下手。2.2 三条防线的配合逻辑单纯提升权限解决不了所有问题因为用户还可以右键“以管理员身份运行”任务管理器那样权限就追平了。所以真正让“结束不掉”能成立的是三条防线同时作用权限差异防线目标进程以管理员权限运行普通权限的任务管理器无法结束。双进程快速复活防线即使管理员权限的任务管理器强行结束其中一个进程另一个进程会在毫秒级时间内把它重新拉起。用户看到的视觉结果是点了“结束任务”进程列表里刷一下又回来了。作业对象绑定防线守护进程创建一个作业对象Job Object把业务进程分配进去并设置 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE。这个标志的意思是当守护进程本身被结束、作业对象句柄全部关闭时系统会自动关闭所有绑定在作业对象里的进程。这里很容易有个误区Job Object 不是用来“阻止终止”的它是用来管理进程生命周期的。它的核心价值在于当守护进程被杀掉时业务进程也会被系统注销掉不会出现“守护进程死了、业务进程变成孤儿”这种中间状态整个系统要么同时活着要么同时阵亡重启逻辑才好统一处理。三条防线合起来的用户体验就是你杀我我权限比你高你杀不了就算你追上权限杀了另一个兄弟进程也会立刻把我拉回来我自己的生命周期还和守护进程死死绑在一起谁也拆不开。这就是 3.0 版“结束不掉”的完整技术解释。3. C守护端实现把“不死”做成核心服务3.1 创建作业对象并绑定受保护进程C 守护端的职责之一就是创建 Job Object 并把业务进程分配进去。代码很直接关键步骤就四个 API。// 创建作业对象带命名方便调试排查 HANDLE hJob CreateJobObject(NULL, LLocal\\GuardJob_3.0); if (!hJob) { // 日志记录、退出 return FALSE; } // 设置作业对象扩展限制信息 JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobInfo { 0 }; jobInfo.BasicLimitInformation.LimitFlags JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, jobInfo, sizeof(jobInfo))) { CloseHandle(hJob); return FALSE; } // 创建业务进程 STARTUPINFO si { sizeof(si) }; PROCESS_INFORMATION pi { 0 }; BOOL bOk CreateProcess( NULL, (LPWSTR)LC:\\Program Files\\MyApp\\MainApp.exe, NULL, NULL, FALSE, 0, NULL, NULL, si, pi ); if (!bOk) { CloseHandle(hJob); return FALSE; } // 将业务进程分配给作业对象 if (!AssignProcessToJobObject(hJob, pi.hProcess)) { // 分配失败比如进程已属于其他作业对象 CloseHandle(pi.hThread); CloseHandle(pi.hProcess); CloseHandle(hJob); return FALSE; }这一段代码跑起来之后MainApp.exe 就进入了 GuardJob 作业对象。注意这里的关键点守护进程要一直持有 hJob 这个句柄不能随意 CloseHandle因为 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 的触发条件就是“该作业对象的最后一个内核句柄被关闭”。守护进程一旦退出句柄释放业务进程也会被系统一起带走。顺手说个经验业务进程的启动路径不要硬编码最好从配置文件读取或者用相对路径计算。硬编码路径一旦部署环境换了就起不来容易踩坑。3.2 双进程守护被杀后秒回作业对象解决的是“生命周期捆绑”真正让进程“被杀后秒回”的是互拉线程。守护进程里专门开一条线程盯着业务进程的句柄// 等待业务进程退出 WaitForSingleObject(pi.hProcess, INFINITE); // 走到这里说明业务进程已退出检查退出原因 DWORD dwExitCode 0; GetExitCodeProcess(pi.hProcess, dwExitCode); // 记录日志后重启业务进程 Sleep(500); // 稍微等一下防止开机关机瞬间资源争抢 RestartMainApp();对应的业务进程C#端也会开一条线程盯着守护进程的句柄。两边逻辑完全对等。这里有几个细节值得注意互拉线程要用单独线程不能阻塞主逻辑。WaitForSingleObject 设了 INFINITE 超时一旦阻塞住主流程就死了。重启时加上防抖机制如果业务进程在几秒内反复崩溃守护端应该停止无限重启并发出告警否则会陷入“拉起-崩溃-拉起-崩溃”的循环把日志刷爆。两个进程之间要有一个约定比如用命名互斥体来判断“是不是守护进程主动让我退出的”。业务进程正常退出和被杀是两种场景正常退出的情况下守护进程不能立刻把它拉起来否则就是“关不掉”的状态了。C# 端对应重启守护进程的代码大概是这样的Process guardProcess Process.Start(new ProcessStartInfo { FileName Guard.exe, WorkingDirectory AppDomain.CurrentDomain.BaseDirectory, CreateNoWindow true });这里最容易犯的错误是启动参数不带工作目录导致 Guard.exe 找不到同目录下的配置文件。实测这个坑出现过挺多次部署时务必检查。3.3 权限提权与调试保护要抵挡普通权限的任务管理器业务进程和守护进程都需要以管理员权限运行。C 端可以在程序入口调用 EnableDebugPrivilege把当前进程的调试特权打开这样守护进程才能对高权限进程执行 OpenProcess、TerminateProcess 等操作。BOOL EnableDebugPrivilege() { HANDLE hToken NULL; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, hToken)) { return FALSE; } TOKEN_PRIVILEGES tp; tp.PrivilegeCount 1; tp.Privileges[0].Attributes SE_PRIVILEGE_ENABLED; if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, tp.Privileges[0].Luid)) { CloseHandle(hToken); return FALSE; } BOOL ok AdjustTokenPrivileges(hToken, FALSE, tp, sizeof(TOKEN_PRIVILEGES), NULL, NULL); DWORD dwErr GetLastError(); CloseHandle(hToken); // AdjustTokenPrivileges 成功时也会返回 TRUE需要用 GetLastError 确认没有发生 ERROR_NOT_ALL_ASSIGNED return ok (dwErr ERROR_SUCCESS); }补充一个细节AdjustTokenPrivileges 即使只修改了部分权限也可能返回 TRUE所以一定要检查 GetLastError 是否等于 ERROR_SUCCESS否则你以为调试权限已经开了实际没有后续 OpenProcess 会无故失败。调试权限开完之后守护进程就有了访问绝大多数系统进程句柄的资格。任务管理器以普通权限运行时OpenProcess 直接失败以管理员权限运行时它可以结束业务进程但双进程互拉又会立刻把进程拉回来——这才是 3.0 版完整的“不可结束”体验。4. C#业务端集成守护逻辑如何跑进.NET4.1 用P/Invoke在C#里调用作业对象APIC# 端要承担两个责任一是作为真正业务的宿主进程二是在守护进程退出时反向拉起它。如果业务进程想直接使用作业对象的能力可以用 P/Invoke 把 Win32 API 引进来。下面是一个简化版本的结构体声明[StructLayout(LayoutKind.Sequential)] internal struct JOBOBJECT_BASIC_LIMIT_INFORMATION { public long PerProcessUserTimeLimit; public long PerJobUserTimeLimit; public uint LimitFlags; public IntPtr MinimumWorkingSetSize; public IntPtr MaximumWorkingSetSize; public uint ActiveProcessLimit; public IntPtr Affinity; public uint PriorityClass; public uint SchedulingClass; } [StructLayout(LayoutKind.Sequential)] internal struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public long IoInfoReadOperationCount; public long IoInfoWriteOperationCount; public long IoInfoOtherOperationCount; public long IoInfoReadTransferCount; public long IoInfoWriteTransferCount; public long IoInfoOtherTransferCount; public IntPtr ProcessMemoryLimit; public IntPtr JobMemoryLimit; public IntPtr PeakProcessMemoryUsed; public IntPtr PeakJobMemoryUsed; } [DllImport(kernel32.dll, CharSet CharSet.Unicode)] internal static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); [DllImport(kernel32.dll)] internal static extern bool SetInformationJobObject( IntPtr hJob, int jobObjectInfoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo, uint cbJobObjectInfoLength); [DllImport(kernel32.dll)] internal static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);C# 调用时有一个特别容易踩的坑结构体布局默认是 4 字节对齐但 Win32 里 JOBOBJECT_EXTENDED_LIMIT_INFORMATION 涉及多个 LARGE_INTEGER 和 SIZE_T 字段对齐方式是 8 字节对齐。在 x64 平台下默认的 LayoutKind.Sequential 通常正确但在 x86 平台下很可能错位。我建议一律显式声明[StructLayout(LayoutKind.Sequential, Pack 8)]并且用 Marshal.SizeOf 动态计算传给 SetInformationJobObject 的字节数不要硬编码。4.2 WMI事件监控作为双保险双进程互拉的实现基础是双方各自持有对方的进程句柄。但有一种情况业务进程是后启动的还没拿到守护进程句柄时守护进程就退了。这个时候 WMI 事件监听可以作为第二道保险。C# 端使用 System.Management 命名空间订阅 Win32_ProcessStopTrace 事件只要目标进程退出就会触发事件回调using System.Management; var query new WqlEventQuery( SELECT * FROM Win32_ProcessStopTrace WHERE ProcessName Guard.exe ); var watcher new ManagementEventWatcher(query); watcher.EventArrived (s, e) { // 这里注意事件触发距离进程实际退出有几百毫秒延迟属于正常现象 if (!IsGuardRunning()) { Process.Start(Guard.exe); } }; watcher.Start();对比一下 C 的 WaitForSingleObject 互拉方案WMI 的响应延迟更高通常在 1 秒左右但优点是不需要提前持有句柄可以作为初始化阶段的兜底。我实际项目里的做法是“双通道共存”正常运行期间靠句柄互等 命名管道心跳守护进程异常退出时由 WMI 事件兜底。两个机制同时开不会冲突因为心跳检测和 WMI 回调都会检查“目标进程是否真的不在”只有不在才拉起。4.3 心跳通信与重启参数进程互拉容易拉起来之后能不能正常工作就是另一回事了。守护进程和业务进程之间需要一套探活协议防止“进程还活着、但业务线程已经死锁”的情况。我用的是命名管道每 3 秒让业务进程发一个心跳包守护进程连续 3 次收不到就判定业务进程假死主动 TerminateProcess 再重新拉起。C# 端心跳实现很简洁using System.IO.Pipes; var server new NamedPipeServerStream(GuardPipe_3.0, PipeDirection.Out); await server.WaitForConnectionAsync(); while (true) { var packet Encoding.UTF8.GetBytes(HEARTBEAT); await server.WriteAsync(packet, 0, packet.Length); await server.FlushAsync(); await Task.Delay(3000); }这里要提醒一句命名管道名字是系统级共享资源名字取得太普通比如 “test” 或 “pipe1”一旦被其他进程恶作剧占用你的进程就建不了管道了。强烈建议用带版本号和随机后缀的管道名。重启参数也需要精细控制业务进程被拉起时要能区分“首次启动”和“被守护重启”因为首次启动要加载配置、初始化数据库连接而被守护重启时很可能要跳过某些初始化步骤加快恢复速度。我一般通过命令行参数 envactive 或 exitCodexxx 来传递上次退出码守护进程根据退出码决定要不要跳过自检。5. 实测表现与常见问题排查5.1 对任务管理器对抗实测我搭了一套测试环境Windows 10 专业版 管理员权限的任务管理器 普通权限的任务管理器分别测试业务进程用 C# WinForms 程序模拟守护进程用 C 编译。第一组测试普通权限任务管理器结束 MainApp.exe结束按钮点了之后任务管理器上能明显看到 MainApp.exe 的状态短暂变红随即进程从列表消失但大约 0.5 秒后MainApp.exe 重新出现在列表里。连续快速点击结束按钮进程会反复消失又出现最后任务管理器也开始卡顿因为每次枚举进程快照都会重新发现新进程。第二组测试管理员权限任务管理器结束 MainApp.exe进程会被正常终止但守护进程收到退出通知后立刻拉起新进程。实测重启耗时约 200ms 左右加上进程冷启动时间总共 1 秒内完成恢复。第三组测试把两个进程都结束理论上会出现“全部阵亡”的瞬间但实测几乎不可能同时点到两个进程而且就算同时点了作业对象的 KILL_ON_JOB_CLOSE 也会让所有进程被统一清理。残留的孤儿进程不会存在重启方案是由系统服务或计划任务兜底在开机或延迟后自动拉起整套系统。必须承认的边界是如果使用进程调试器如 x64dbg附加后强制结束或者以 SYSTEM 权限执行 pskill 这类工具那用户态方案依然防不住。这也是为什么这套方案定位是“防误杀、防普通操作”而不是“防恶意攻击”。5.2 容易踩的坑说几个实际项目里踩过、排查起来很费劲的坑作业对象嵌套限制。Windows 8 之前一个进程不能被加入多个作业对象。如果业务进程本身已经在某些框架的作业对象里比如部分 CI 环境、游戏平台AssignProcessToJobObject 会失败并返回错误。解决办法是在 Assign 前先检查 OpenProcess 的 PROCESS_SET_QUOTA | PROCESS_TERMINATE 权限并在失败时记录详细错误日志。管理员权限与 UAC 弹窗。业务进程设置为 requireAdministrator 后每次启动都会弹 UAC 确认框这在无人值守的自助终端上是不可接受的。解决思路是把程序作为计划任务以最高权限运行或者在安装阶段通过服务方式拉起。杀毒软件误报。C 守护进程因为操作进程句柄、Token、Job Object很容易被杀毒软件判为“风险工具”。发布前一定要做主流杀软的白名单/签名认证否则部署到用户机器上还没开始保护自家程序先被干掉了。CreateProcess 的工作目录问题。不设置 lpCurrentDirectory 的话子进程的工作目录会继承守护进程的。如果守护进程是服务方式启动工作目录是 System32业务进程按相对路径加载配置文件就会失败。务必显式传工作目录。5.3 常见问题速查表现象可能原因解决方案业务进程被结束后不重启守护进程未持有正确的进程句柄或等待线程被阻塞检查 WaitForSingleObject 的句柄是否有效单独线程执行等待逻辑守护进程一退出业务进程也立刻消失这是 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 的正常行为如需守护者退出时保留业务进程去掉该标志但如果追求“同生共死”就该保留AssignProcessToJobObject 返回 0业务进程已经在另一个 Job Object 中检查调用时机或使用嵌套 Job ObjectWin8UAC 弹窗不断出现清单文件设置了 requireAdministrator改用计划任务或服务方式运行杀毒软件报毒字节签名命中启发式规则对可执行文件签名提交白名单申诉管道连接失败管道名被占用或权限不足管道名加随机后缀设置合适的管道路径权限最后再分享一个我在 3.0 版里花时间最多的细节会话 0 隔离。如果程序以服务方式运行在 Session 0 里而业务进程需要显示界面比如 WinForms就会遇到“进程活着但用户看不到界面”的问题。这类场景下守护进程要放在 Session 0业务进程要拉到用户会话里两个进程的通信不能依赖默认的窗口消息只能走命名管道。还有一个个人体会想留给看到这里的读者进程自保护这种事技术和权限只是基础真正决定方案成不成的是“你希望程序死还是活”的业务语义要理清楚。是退出即拉回还是异常才拉回是允许管理员手动停止还是完全锁死。这些规则在代码里没有体现出来一旦业务需求变动守护逻辑很容易改出“关不掉的程序”这样更严重的问题。我把这套 3.0 版的守护逻辑拆出来分享也是希望大家能少走一点我走过的弯路。本文还有配套的精品资源点击获取

最新新闻

日新闻

周新闻

月新闻