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

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

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

Process Monitor与错误的堆栈追踪

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

根据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
[8] : 0xfffff80681b36400 : PROCMON24+0x6400 [Type: void (*)()]

接下来在此回调上设置断点,恢复机器执行,并从开始菜单运行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头文件中找到。我们创建头文件并加载到调试器中:

 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
2
dx @$procNotifyInput = new { Process = (nt!_EPROCESS*)@rcx, ProcessId = @rdx,
CreateInfo = Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_PS_CREATE_NOTIFY_INFO", @r8) }

通过分析CreateInfo,我们可以获取有关新进程及其创建者的更多信息:

1
2
dx @$procNotifyInput.CreateInfo
dx @$procNotifyInput.CreateInfo.CreatingThreadId

现在我们可以确定新创建的进程是Windows Terminal,并发现有关其创建者的有趣细节。两个关键字段是ParentProcessIdCreatingThreadId(后者包含UniqueProcess字段,即拥有此线程的进程ID)。

检查当前进程上下文时,我们发现与ProcMon GUI显示的不同:在驱动程序中,我们似乎运行在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 设计