Windows进程间通信深入剖析(十):服务器存根结构与NDR引擎元数据解析

admin 2026-03-11 03:03:02 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入剖析WindowsRPC服务器存根结构与NDR引擎元数据。文章介绍了测试环境,详细解析了RPC_SERVER_INTERFACE、分发表及MIDL_SERVER_INFO等关键结构体,揭示了RPC运行时利用存根元数据定位函数、处理数据编组与解组的机制。内容为理解RPC底层通信原理及后续动态分析奠定了坚实基础,技术深度较高。 综合评分: 87 文章分类: 逆向分析,二进制安全,安全开发


cover_image

Windows 进程间通信深入剖析 (十):服务器存根结构与 NDR 引擎元数据解析

Sud0Ru Sud0Ru

securitainment

2026年3月10日 13:37 中国香港

| 原文链接 | 作者 | | — | — | | https://sud0ru.ghost.io/windows-inter-process-communication-a-deep-dive-beyond-the-surface-part-10/ | Sud0Ru |

欢迎来到 RPC 系列的新篇章。这是 2026 年的第一篇文章,也是全新阶段的开篇,将涉及据我所知此前从未有人讨论过的大量内容。

本阶段主要沿两条路径展开。第一条我称为 RPC 静态分析,涵盖服务器端和客户端两方面。在这一部分中,我们将讨论 MIDL 编译器自动生成的存根 (stub)。第二条路径我称为 RPC 动态分析,重点关注运行时服务器与客户端之间的底层行为。换言之,我们将深入了解 RPC 运行时、服务器和客户端在执行过程中如何彼此交互。

在今天的内容中,我们首先介绍将在后续各部分中使用的测试环境 (testbed)。随后,我们将研究由 MIDL 编译器自动生成的服务器存根中定义的各种结构体。我们先从定义层面进行分析,在后续部分中再观察它们在运行时是如何被 RPC 运行时库实际调用的。

那么,让我们开始吧。

测试环境

本阶段使用的测试环境由一个实现了基本功能的简单 RPC 服务器和客户端组成。服务器与客户端之间使用 ALRPC作为传输协议进行通信,代码编译为 32 位应用程序

需要注意的是,在使用 64 位构建或不同传输协议的情况下,部分内部 RPC 结构和行为会有所不同。不过,本系列旨在保持简洁,因此采用上述配置可以让示例更易于理解。

在今天的测试环境中,我们照例使用一个简单的 RPC 服务器和客户端。我们只关注函数本身,不涉及安全设置或其他高级参数。

服务器对外暴露两个主要函数:

int PrintINT(int x) {
    printf("Received integer: %d\n", x);
    return 42;
}

int PrintString(const char* y) {
    printf("Received String: %s\n", y);
    return 43;
}

这两个函数非常简单。第一个接收一个整数,将其打印出来,然后返回一个整数值。第二个接收一个字符串,将其打印出来,同样返回一个整数。

在客户端,只需调用这两个函数并打印返回值:

    result = PrintINT(5);
    printf("Received result from server: %d\n", result);
    char* outputString = NULL;
    result = PrintString("hello");
    printf("Received result from server: %d\n", result);

客户端和服务器的完整代码可以在这里找到。

服务器存根的生成

正如之前所述,当你将 IDL 文件提交给 MIDL 编译器时,它会生成多个文件。其中之一就是我们所说的服务器存根(server stub)。在本示例中,IDL 文件名为 example.idl,因此生成的服务器存根文件为 example_s.c,其中 s代表 _server_。

可以将服务器存根理解为 RPC 运行时与服务器实现之间的代理层。从 RPC 运行时的角度看,它通过存根与服务器进行通信;而从服务器的角度看,传入的调用就如同普通的本地函数调用一样。

如前所述,服务器通过 RpcServerRegisterIf2函数注册接口,如下所示

status = RpcServerRegisterIf2(
        Example_v1_0_s_ifspec,  // Interface to register
        NULL,                            // UUID to associate with this interface
        NULL,                            // MgrTypeUuid
        0,                               // Flags
        RPC_C_LISTEN_MAX_CALLS_DEFAULT,  // Max calls
        (unsigned)-1,                    // Max RPC size
        NULL);                           // Security callback

我们曾提到,第一个参数是接口句柄 (interface handle),定义在 MIDL 生成的头文件中。查看生成的头文件 (本例中为 example.h),可以找到如下声明:

extern RPC_IF_HANDLE Example_v1_0_s_ifspec;

RPC_IF_HANDLE本质上是一个 void*,指向描述服务器接口的结构体。extern关键字表示该结构体定义在头文件之外的其他位置。

RPC_SERVER_INTERFACE

在 example_s.c中,MIDL 将接口定义如下:

static const RPC_SERVER_INTERFACE Example___RpcServerInterface =
    {
    sizeof(RPC_SERVER_INTERFACE),
    {{0x12345678,0x1234,0x1234,{0x12,0x34,0x12,0x34,0x56,0x78,0x90,0xAB}},{1,0}},
    {{0x8A885D04,0x1CEB,0x11C9,{0x9F,0xE8,0x08,0x00,0x2B,0x10,0x48,0x60}},{2,0}},
    (RPC_DISPATCH_TABLE*)&Example_v1_0_DispatchTable,
    0,
    0,
    0,
    &Example_ServerInfo,
    0x04000000
    };

该结构体的类型为 RPC_SERVER_INTERFACE,用于描述服务器端接口,供 RPC 运行时使用。虽然这是一个未公开文档的结构体,但其定义可以在 rpcdcep.h中找到:

typedef struct _RPC_SERVER_INTERFACE
{
    unsigned int Length;
    RPC_SYNTAX_IDENTIFIER InterfaceId;
    RPC_SYNTAX_IDENTIFIER TransferSyntax;
    PRPC_DISPATCH_TABLE DispatchTable;
    unsigned int RpcProtseqEndpointCount;
    PRPC_PROTSEQ_ENDPOINT RpcProtseqEndpoint;
    RPC_MGR_EPV __RPC_FAR *DefaultManagerEpv;
    void const __RPC_FAR *InterpreterInfo;
    unsigned int Flags ;
} RPC_SERVER_INTERFACE, __RPC_FAR * PRPC_SERVER_INTERFACE;

第一个字段是结构体的大小。第二个字段是接口 UUID。第三个字段指定了传输语法 UUID(transfer syntax UUID)。

传输语法定义了 RPC 调用在客户端与服务器之间传输时数据的编码方式。换言之,它规定了将函数参数转换为字节流的规则。这种编码在 marshalling(编组) 过程中使用,该过程将内存中的数据转换为可传输的格式。

在接收端,字节流通过 unmarshalling(解组) 过程被解码,还原为原始的函数参数。

在本示例中,该 UUID 对应的是 NDR (Network Data Representation)传输语法。NDR 是一种标准化的二进制格式及其配套规则,RPC marshaller 依照这些规则在进程或机器边界间对数据进行编码和解码。

分发表与操作编号

RPC_SERVER_INTERFACE中的第四个字段是分发表 (dispatch table),它是一个指向 RPC_DISPATCH_TABLE结构体的指针:

static const RPC_DISPATCH_TABLE Example_v1_0_DispatchTable =
    {
    2,
    (RPC_DISPATCH_FUNCTION*)Example_table
    };

这同样是 RPC 运行时内部使用的结构体,没有官方文档。其定义如下:

typedef struct {
    unsigned int DispatchTableCount;
    RPC_DISPATCH_FUNCTION __RPC_FAR * DispatchTable;
    LONG_PTR                          Reserved;
} RPC_DISPATCH_TABLE, __RPC_FAR * PRPC_DISPATCH_TABLE;

简而言之,该表包含服务器定义的过程数量 (DispatchTableCount) 以及一个函数指针数组 (DispatchTable) 的指针,数组中的每个条目对应一个 RPC 方法。

查看实际的分发表:

static const RPC_DISPATCH_FUNCTION Example_table[] =
    {
    NdrServerCall2,
    NdrServerCall2,
    0
    };

可以看到,这是一个 RPC_DISPATCH_FUNCTION类型的数组,其定义如下:

typedef
void
(__RPC_STUB __RPC_FAR * RPC_DISPATCH_FUNCTION) (
    IN OUT PRPC_MESSAGE Message
    );

该类型表示一个服务器端 RPC 存根函数,负责处理单次 RPC 调用。由于我们定义了两个 RPC 方法,因此可以看到两个指向 NdrServerCall2的条目。末尾的零标志着数组的结束。

数组中的每个索引对应一个操作编号 (opnum):

  • opnum 0 → NdrServerCall2
  • opnum 1 → NdrServerCall2

你可能已经注意到,该表并不包含直接指向 PrintINT或 PrintString的指针,而是包含了 NdrServerCall2——这是 RPC 运行时使用的一个通用内部函数。

该函数是另一个名为 NdrStubCall2的函数的包装器。NdrStubCall2同时承担解组器和分发器的角色:它对传入的 RPC 消息进行 unmarshalling (解组),调用实际的服务器函数,然后将返回值 marshalling (编组) 后发送回客户端。

我们将在后续部分中更详细地讨论 NdrStubCall2

查看 NdrServerCall2的定义:

NdrServerCall2(
    PRPC_MESSAGE                pRpcMsg
    );

可以看到,它接收一个 PRPC_MESSAGE参数,代表客户端发送的 RPC 消息。在调用服务器函数之前,需要先对该消息进行解组。

简而言之,当 RPC 运行时收到来自客户端的函数调用时,会调用 NdrServerCall2,由它完成消息处理并调用相应的服务器函数。

NDR 引擎

NDR 引擎是 RPC 运行时的组成部分 (实现于 rpcrt4.dll中),负责理解和执行 Network Data Representation。

其职责包括:

  • 在客户端将参数编组 (marshalling) 为字节流
  • 在服务器端对参数进行解组 (unmarshalling)
  • 调用实际的服务器函数
  • 将返回值和 [out]参数编组后发送回客户端

NdrServerCall2是服务器端进入 NDR 引擎的主要入口点之一。

服务器存根为 NDR 引擎提供运行时所需的元数据。

这些元数据存储在 MIDL_SERVER_INFO结构体中,由 RPC_SERVER_INTERFACE结构体的 InterpreterInfo字段所引用。在我们的存根代码中,它被定义为 Example_ServerInfo

static const MIDL_SERVER_INFO Example_ServerInfo =
    {
    &Example_StubDesc,
    Example_ServerRoutineTable,
    example__MIDL_ProcFormatString.Format,
    Example_FormatStringOffsetTable,
    0,
    0,
    0,
    0};

该结构体同样没有公开文档,但其定义可以找到:

typedef struct  _MIDL_SERVER_INFO_
    {
    PMIDL_STUB_DESC                     pStubDesc;
    const SERVER_ROUTINE     *          DispatchTable;
    PFORMAT_STRING                      ProcString;
    const unsigned short *              FmtStringOffset;
    const STUB_THUNK *                  ThunkTable;
    PRPC_SYNTAX_IDENTIFIER              pTransferSyntax;
    ULONG_PTR                           nCount;
    PMIDL_SYNTAX_INFO                   pSyntaxInfo;
    } MIDL_SERVER_INFO, *PMIDL_SERVER_INFO;

第一个字段是指向 MIDL_STUB_DESC结构体的指针。与前面的结构体不同,MIDL_STUB_DESC_是_有文档记录的,详见这里。

在本示例中,它的定义如下:

static const MIDL_STUB_DESC Example_StubDesc =
    {
    (void *)& Example___RpcServerInterface,
    MIDL_user_allocate,
    MIDL_user_free,
    0,
    0,
    0,
    0,
    0,
    example__MIDL_TypeFormatString.Format,
    1, /* -error bounds_check flag */
    0x50002, /* Ndr library version */
    0,
    0x8010274, /* MIDL Version 8.1.628 */
    0,
    0,
    0,  /* notify & notify_flag routine table */
    0x1, /* MIDL flag */
    0, /* cs routines */
    0,   /* proxy/server info */
    0
    };

该结构体定义了内存分配相关的例程,并指向类型格式字符串(type format string),后者描述了各数据类型的编组和解组方式。

第一个字段回指我们之前介绍的 RPC_SERVER_INTERFACE主结构体。MIDL_user_allocate和 MIDL_user_free函数分别用于内存分配和释放。你可能还记得,我们之前创建的每个 RPC 服务器末尾都添加了这两个函数:

void* __RPC_USER midl_user_allocate(size_t size) {
    return malloc(size);
}

void __RPC_USER midl_user_free(void* ptr) {
    free(ptr);
}

example__MIDL_TypeFormatString.Format字段指向 MIDL 生成的类型格式字符串

该字段所属的结构体类型为 MIDL_TYPE_FORMAT_STRING,在本示例中定义如下:

static const example_MIDL_TYPE_FORMAT_STRING example__MIDL_TypeFormatString =
    {
        0,
        {
   NdrFcShort( 0x0 ), /* 0 */
/*  2 */
   0x11, 0x8, /* FC_RP [simple_pointer] */
/*  4 */
   0x22,  /* FC_C_CSTRING */
   0x5c,  /* FC_PAD */

   0x0
        }
    };

查看该结构体的定义:

typedef struct _example_MIDL_TYPE_FORMAT_STRING
    {
    short          Pad;
    unsigned char  Format[ TYPE_FORMAT_STRING_SIZE ];
    } example_MIDL_TYPE_FORMAT_STRING;

结构体以一个小的填充字段 (Pad) 开始,后跟 Format数组。Format数组描述了 NDR 引擎应当如何对接口中的复杂数据类型进行编组和解组。

更多细节将在后续部分中展开,届时我们会专门讨论编组与解组过程,并逐步解析这些格式字符串在运行时的实际消费方式。

回到 MIDL_SERVER_INFO结构体,第二个字段是 Example_ServerRoutineTable,这是一个函数指针数组,每个条目对应服务器实现的一个 RPC 方法:

static const SERVER_ROUTINE Example_ServerRoutineTable[] =
    {
    (SERVER_ROUTINE)PrintINT,
    (SERVER_ROUTINE)PrintString
    };

如你所见,这些指针直接指向实现 RPC 接口的服务器函数本身。

第三个字段是 example__MIDL_ProcFormatString.Format。这是 MIDL 生成的过程格式 (procedure format) 字节数组,作为 PFORMAT_STRING传递给运行时。它在结构上与类型格式容器相似——两者都存储 NDR 格式字节——但它描述的是过程和参数,而非数据类型。

该过程格式字符串包含 NDR 引擎所需的逐过程元数据,包括参数布局、句柄类型、标志位、栈偏移量,以及每个函数的编组/解组描述符。MIDL 为 PrintINT和 PrintString都生成了相应的条目,如下图所示

我们在讨论编组机制时还会详细回顾这部分内容。

最后一个字段是 Example_FormatStringOffsetTable,用于记录每个函数在过程格式字符串中的偏移量:

static const unsigned short Example_FormatStringOffsetTable[] =
    {
    0,
    36
    };

该表将每个过程编号 (opnum) 映射到其在过程格式字符串中对应描述的起始偏移位置。

这是服务器存根分析中我们要介绍的最后一个结构体。下面的图表展示了我们所讨论的全部结构体,它们共同构成了服务器存根的完整视图。

我知道这些内容初看起来可能比较复杂,各结构之间的关系也不太容易一下子理清。但当我们在后续部分进入动态分析时,所有这些内容都会串联起来,整体架构将会变得清晰许多。

下一部分见!


免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment Sud0Ru Sud0Ru《Windows 进程间通信深入剖析 (十):服务器存根结构与 NDR 引擎元数据解析》

评论:0   参与:  0