Windows进程重父化技术解析:从ProcMon异常到内核调试实战

本文深入探讨Windows进程重父化技术,通过Process Monitor异常堆栈分析,结合WinDbg内核调试与ETW事件追踪,揭示安全产品检测盲点与进程创建机制的内在原理。

Windows进程重父化技术解析:从ProcMon异常到内核调试实战

Process Monitor与错误的堆栈跟踪

最近我在研究Windows Terminal的运行机制时,使用SysInternals的Process Monitor(ProcMon)记录了其执行过程。ProcMon能够记录进程的执行、文件系统、注册表和网络操作。在分析记录时,我发现了异常现象:

根据ProcMon显示,explorer.exe启动了终端进程。这看似合理,因为explorer.exe通常是许多用户应用程序的父进程。但仔细查看调用堆栈后发现了一些问题:第8和第9帧没有符号信息,甚至没有显示模块名称。许多人可能会认为这是堆中运行的动态内存shellcode。我们可以使用调试器或Process Hacker(现称System Informer)来调查这种可能性。

Process Hacker的输出显示,这些堆栈帧指向的内存范围根本没有被映射。这意味着要么这是一个特别隐蔽的shellcode(我可能需要重新检查系统是否遭受了国家级攻击),要么有其他解释。

深入内核调试

为了找到根本原因,我转向了几乎总是可靠的调试器:WinDbg。我们将使用内核调试器来跟踪Windows Terminal的进程创建,并观察ProcMon操作的数据,这应该能揭示真正发生了什么。

首先,启动ProcMon记录会话,这会加载其内核驱动并注册进程通知例程。许多终端检测与响应(EDR)系统和系统监控工具使用此回调来获取进程创建和终止的通知。为了跟随ProcMon的步骤,我们将在此回调上设置断点并观察发生的情况。

进程创建回调列表保存在一个未导出的内核符号PspCreateProcessNotifyRoutine中。虽然回调本身保存在公共符号中不可用的数据结构中,但我们可以使用硬编码偏移来解析它。我编写了一个简单的单行脚本来打印所有注册的回调:

1
2
dx ((__int64(*)[64])&nt!PspCreateProcessNotifyRoutine)->Where(p => p)->Select(p =>
(void(*)())(*(((__int64*)(p & ~0xf)) + 1)))

运行此脚本,我们可以轻松找到ProcMon的进程回调:

1
2
3
4
[0] : 0xfffff80673f78900 : cng!CngCreateProcessNotifyRoutine+0x0
[1] : 0xfffff80674b29f50 : WdFilter+0x49f50
...
[8] : 0xfffff80681b36400 : PROCMON24+0x6400

下一步是在此回调上设置断点,恢复机器执行,并从开始菜单运行Windows Terminal:

1
bp 0xfffff80681b36400; g

断点命中后,我们需要解析函数参数。在现代系统上,ProcMon使用PsSetCreateProcessNotifyRoutineEx2注册其进程通知例程。这很重要,因为不同版本的进程通知例程接收的参数略有不同。在这种情况下,例程的类型为PCREATE_PROCESS_NOTIFY_ROUTINE_EX

1
2
3
4
5
void PcreateProcessNotifyRoutineEx (
  [_Inout_]           PEPROCESS Process,
  [in]                HANDLE ProcessId,
  [in, out, optional] PPS_CREATE_NOTIFY_INFO CreateInfo
)

为了正确格式化输入参数,我们需要使用调试器数据模型。由于PS_CREATE_NOTIFY_INFO未包含在公共符号中,我们可以从公共ntddk.h头文件中复制结构定义(稍作调整)到单独的头文件中,并通过Synthetic Types在调试器中使用它。

创建头文件c:\temp\ntddk_structs.h

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
typedef struct _PS_CREATE_NOTIFY_INFO {
    ULONG64 Size;
    union {
        _In_ ULONG Flags;
        struct {
            _In_ ULONG FileOpenNameAvailable : 1;
            _In_ ULONG IsSubsystemProcess : 1;
            _In_ ULONG Reserved : 30;
        };
    };
    HANDLE ParentProcessId;
    _CLIENT_ID CreatingThreadId;
    _FILE_OBJECT *FileObject;
    _UNICODE_STRING *ImageFileName;
    _UNICODE_STRING *CommandLine;
    ULONG CreationStatus;
} PS_CREATE_NOTIFY_INFO, *PPS_CREATE_NOTIFY_INFO;

将其加载到调试器中:

1
dx Debugger.Utility.Analysis.SyntheticTypes.ReadHeader("c:\\temp\\ntddk_structs.h", "nt")

然后格式化输入参数:

1
dx @$procNotifyInput = new { Process = (nt!_EPROCESS*)@rcx, ProcessId = @rdx, CreateInfo = Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_PS_CREATE_NOTIFY_INFO", @r8) }

通过检查CreateInfo,我们可以获取有关新进程的更多信息,尤其是谁创建了它:

1
dx @$procNotifyInput.CreateInfo

输出显示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Size             : 0x48
Flags            : 0x1
FileOpenNameAvailable : 0x1
IsSubsystemProcess : 0x0
Reserved         : 0x0
ParentProcessId  : 0x1738
CreatingThreadId [Type: _CLIENT_ID]
FileObject       : 0xffffae0f90ac7d70 : "\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.15.2713.0_x64__8wekyb3d8bbwe\WindowsTerminal.exe"
ImageFileName    : 0xffffd28d2a447578 : "\??\C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.15.2713.0_x64__8wekyb3d8bbwe\WindowsTerminal.exe"
CommandLine      : 0xffffae0f92c5b070 : ""C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.15.2713.0_x64__8wekyb3d8bbwe\WindowsTerminal.exe" "
CreationStatus   : 0x0

检查创建线程ID:

1
dx @$procNotifyInput.CreateInfo.CreatingThreadId

输出:

1
2
[+0x000] UniqueProcess    : 0x5ac
[+0x008] UniqueThread     : 0x69c

检查当前进程上下文:

1
dx @$curprocess

输出显示我们实际上在svchost.exe进程的上下文中运行,而不是explorer.exe。这意味着实际上是svchost.exe正在创建新的Terminal进程!

进程重父化机制解析

我们在这里看到的机制称为进程重父化。在创建进程时,创建者可以设置PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性并包含不同进程的句柄,该进程将被用作父进程。这种机制在系统中有各种用途,例如在与创建者进程不同的会话中创建进程。

为了拥有逻辑进程树以及技术原因,svchost.exe必须将子进程重父化到会话1中的不同父进程(如explorer.exe),以允许子进程使用控制台和UI。这种机制也可用于隐藏进程的实际来源并混淆EDR。

ProcMon通过不检查请求进程创建的进程是否与请求的父进程相同来误解它接收的数据,导致我们观察到的错误堆栈。然而,通过使用内核驱动和进程创建通知,我们可以拥有所有必要的数据来判断进程是否被重父化。

事实上,我们也可以从用户模式通过Microsoft-Windows-Kernel-Process ETW通道实现这一点。此通道默认未启用,但您可以注册为消费者并接收事件,或使用logman.exe生成跟踪并在事件查看器中查看。

事件ID 1(ProcessStart)是我们关心的。原始数据中有三个有用的字段:

  • System.Execution.ProcessID:请求创建新进程的进程(和线程)ID
  • EventData.ProcessID:新创建的子进程ID
  • EventData.ParentProcessID:被选为父进程的进程ID

如果创建进程ID与父进程ID相同(左侧),则该进程未被重父化。但如果两个PID不相同(右侧),则该进程被重父化,我们同时获得创建者进程和所选父进程的ID!

仍在处理中

至此,我们已经调查了进程重父化以及我们在ProcMon中看到的奇怪行为。当然,这仍然没有完全解释Terminal进程的创建机制、创建它的服务以及appinfo DLL。所有这些都与打包应用程序的行为和实现有关,这是一个完全不同的话题。

对于那些可能对创建机制感到好奇的人,您可以在这里找到更多信息,我可能会在未来的博客文章中添加更多细节(和调试技巧)。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计