为未公开API生成Lib并与MSBuild集成(一)
原文链接(GitHub的Markdown渲染效果更佳):为未公开API生成Lib并与MSBuild集成(一)
本文从Lib文件格式结构开始,描述Lib文件如何包含Dll的导出函数信息以及夹带Obj文件供链接,并从零构造Lib使得其包含任意未公开API导出函数信息。最后与MSBuild集成,衔接到Visual Studio C/C++项目中完成闭环。
注:本文“未公开API”均特指Windows SDK中连Lib文件也没有其导入符号的未公开API。
一、 问题背景
以我们熟悉的CreateProcessInternalW
为例,它由Kernel32.dll
导出。Windows SDK里没有它的声明,Kernel32.lib里也没有它的符号。如果想调用,一般使用动态加载或生成Stub Dll借用其Lib的方式:
1. 动态加载
按照函数定义,定义其指针类型(参数略),然后通过LoadLibaray
+GetProcAddress
之类的方式动态加载Dll和寻址:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | / / typedef BOOL (WINAPI * PFNCreateProcessInternalW)(...);
NTSTATUS Status;
HMODULE Kernel32Handle;
PFNCreateProcessInternalW pfnCreateProcessInternalW;
Status = LdrLoadDll(NULL, NULL, &(UNICODE_STRING)RTL_CONSTANT_STRING(L "Kernel32.dll" ), &Kernel32Handle);
if (NT_SUCCESS(Status))
{
Status = LdrGetProcedureAddress(Kernel32Handle,
&(ANSI_STRING)RTL_CONSTANT_STRING( "CreateProcessInternalW" ),
0 ,
&pfnCreateProcessInternalW);
if (NT_SUCCESS(Status))
{
/ / pfnCreateProcessInternalW(...)
}
/ / LdrUnloadDll(Kernel32Handle);
}
|
PS: 如果想像以上Demo代码一样在UM使用KM及未公开定义,可以关注SystemInformer (ProcessHacker)的phnt,我也积攒下了Wintexports自用。
显然,对于支持目标系统里必然存在的函数,远不及直接CreateProcessInternalW(...)
调用方便,调用未公开API的数量越多,动态加载的代码越冗杂繁复,于是考虑下面的办法。
2. 生成Stub Dll借用其Lib
编写Stub Dll,其具有除实现为空外完全相同的函数定义(参数略):
1 2 3 4 5 6 | BOOL
WINAPI
CreateProcessInternalW(...)
{
return FALSE;
}
|
注意,CreateProcessInternalW
和绝大多数Windows API一样使用stdcall调用约定,x86下Lib的导入符号名称与Dll导出函数名称不一样,前者带有调用约定的修饰而后者没有,要用模块定义文件 (.def)定义其导出:
1 2 3 | LIBRARY KERNEL32
EXPORTS
CreateProcessInternalW
|
最后编译Stub Dll,补充函数定义,取其一同生成的Lib,便能直接CreateProcessInternalW(...)
这般调用了。
一眼看去,似乎仍有改进的空间,重点在于Lib的生成:
- 首先,理论上我们只需要Dll名称和API名称(包含它的修饰名,x86的stdcall要用),空函数体以及模块定义文件 (.def)是信息冗余的;
- 其次,理论上没有任何函数实现,完全不需要在生成过程中将编译器卷进来浪费时间;
- 最次,Lib格式的本质是档案,里面可以花式夹带Dll导出符号和目标文件。可以根据需要,同时包含多个Dll的导出符号,或者一对一地为每个未公开API所在Dll生成一个Lib。
这些“改进”太微不足道,但有时候我就这么点追求。先参考一些已有的工具:
ReactOS的spec2def生成工具
它根据自定的spec文件描述函数原型生成Lib。spec比起手写空函数体加配套模块定义文件 (.def)要简单的多:
1 | @ stdcall CreateProcessInternalW(ptr wstr wstr ptr ptr long long ptr wstr ptr ptr long )
|
据此生成汇编源文件再生成Stub Dll+Lib。卷入汇编器总比卷入C编译器生成得快。
MASM32的inc2l工具
它根据汇编inc文件里的过程声明生成汇编源文件然后生成Stub Dll+Lib。
MSVC 库管理器 (Lib.exe)
它虽然可以根据模块定义文件 (.def)直接生成Lib,但其生成导入符号的名称类型总指定为IMPORT_OBJECT_NAME
,即表示导入名称与导入符号名称完全相同,不适用于x86下stdcall调用约定的API(此场景下应用IMPORT_OBJECT_NAME_UNDECORATE
,表示导入名称为导入符号名称去除调用约定的修饰后的结果)。
比起以上已有的轮子还能进一步卷的,是做到能用spec2def这样简洁的函数定义,还能不卷入汇编器/编译器这类代码生成工具,甚至还能实现一个Lib包含多个Dll的导出符号。不妨从文件结构开始,从零构建Lib。
二、 生成包含Dll导出符号的Lib
1. Lib文件结构
先祭出Microsoft Learn的祖传文档——PE Format - Archive (Library) File Format - Win32 apps | Microsoft Learn。
我已将踩过的坑对应的文档错漏提交给Microsoft Docs并得到合入(PR #1555, PR #1586, PR #1622),此外需要注意的本文会提及。与某些单纯复制或者翻译微软文档就拿来发的博客相比,算是实践出真知了。
Lib (Archive) 文件主要包含导入符号(Import Symbol),供链接器链接。这些符号可以指向外部Dll的导出,也可以存在于Lib自身夹带的Obj文件里。
整体布局比较简单。开头固定的文件签名后,一系列成员(Archive Member)依次排列,每个成员都以成员头(IMAGE_ARCHIVE_MEMBER_HEADER
)开始,后跟成员数据。
Archive Member Header (成员头)
Lib文件中所有成员都以IMAGE_ARCHIVE_MEMBER_HEADER
结构体开头,实际有用且需要的只有Name
和Size
字段,分别表示名称和大小。Name
也用以指示为上表签名后的三个特殊成员,如果名称不够16字节,剩余的用空格' '
补齐,如果超过16字节(去除末尾固定的'/'
只剩15字节),则名称字符串存放到 Longnames Member (长名称成员)里,Name
字段指示其所在偏移。Size
表示成员数据大小,不包含成员头自身大小60(IMAGE_SIZEOF_ARCHIVE_MEMBER_HDR
或sizeof(IMAGE_ARCHIVE_MEMBER_HEADER)
)字节,也不包含用于对齐的'\n'
。
1st Linker Member 与 2nd Linker Member
作用和结构都类似,记录符号的名称和所在文件偏移供链接器定位,信息上是冗余的。前者Unix Style,后者MS Style,并且后者按名称排序使得按名称查找符号更高效,MS链接器(Link.exe)使用。
Longnames Member
后续各符号的成员名如果超长,则名称形式不再是"xxxx/"
(16字节放不下),而是"/n"
(n
即为该名称字符串所在Longnames Member内容中的偏移)。
Symbol
Lib包含的各个符号实际内容。前面说过,可以是指向外部Dll的导出,也可以是夹带的Obj。
指向外部Dll的导出
成员名称为Dll名称,如"KERNEL32.dll"
,内容为Import Header+导入符号名字符串+Dll名称字符串。虽然Dll名称重复出现,但后续MS链接器生成导入表时会分别使用,这两处不同或者有误会导致生成的导入表奇怪或不正确。要注意调用约定、Dll导出名、导入符号名的关系,内容示例:
平台 |
DLL导出名 |
Import Header |
导入符号名 |
x86 |
CreateProcessInternalW |
Type=IMPORT_OBJECT_CODE <br/>Name Type=IMPORT_OBJECT_NAME_UNDECORATE |
_CreateProcessInternalW@48 |
x64 |
CreateProcessInternalW |
Type=IMPORT_OBJECT_CODE <br/>Name Type=IMPORT_OBJECT_NAME |
_CreateProcessInternalW |
此外,还要同时导出带"__imp_"前缀的符号供链接器使用,避免调用外部Dll导出时生成不必要的Jmp Stub指令。即_CreateProcessInternalW@48
与__imp__CreateProcessInternalW@48
都指向同个Import Header。
夹带Obj
成员名称为Obj在Lib中的全路径,如果在根目录,等同于Obj文件名。内容直接为Obj文件内容。如果多个Lib导入符号都在同一个夹带的Obj内,则都指向同个Symbol,链接器会在这个Symbol夹带的Obj里寻找它们。
其它注意事项
2. 用于链接Dll的Lib
按照上述基础的Lib文件结构,构造包含Dll导出符号(如_CreateProcessInternalW@48
与__imp__CreateProcessInternalW@48
)的Lib可供链接器链接,但MS链接器(Link.exe)生成的二进制导入表却缺失了对应Dll的IID(IMAGE_IMPORT_DESCRIPTOR
)。
想必从Windows SDK里最小的Dll导入Lib找线索更容易。用7-Zip打开SAS.lib里的1.txt(也就是1st Linker Member),看到其包含符号如下:
1 2 3 4 5 | 1.SAS .dll __IMPORT_DESCRIPTOR_SAS
2.SAS .dll __NULL_IMPORT_DESCRIPTOR
3.SAS .dll SAS_NULL_THUNK_DATA
4.SAS .dll _SendSAS@ 4
4.SAS .dll __imp__SendSAS@ 4
|
可见,除了包含Dll导出符号(_SendSAS@4
与__imp__SendSAS@4
),还有:
- Dll的IID符号(
__IMPORT_DESCRIPTOR_SAS
)
- Dll的空Thunk(
SAS_NULL_THUNK_DATA
)
- 导入表末尾结束的空IID(
__NULL_IMPORT_DESCRIPTOR
)
它们分别位于3个不同夹带的Obj里,MS链接器(Link.exe)构造导入表时使用它们。
在一个用于链接Dll的Lib里__NULL_IMPORT_DESCRIPTOR
只需要有1个,所以存放在单独的一个Obj会比较好。成员名无所谓,不用保留名称即可。
前二者名称分别为"__IMPORT_DESCRIPTOR_[Dll名]"
和"[Dll名]_NULL_THUNK_DATA"
(注意开头字符为'\x7F'
),可以放在同个Obj里。如果Lib包含多个Dll的导出符号,它们也要对应地有多个,成员名也是Dll的名称。所在Obj需要有__NULL_IMPORT_DESCRIPTOR
的外部引用记录,否则链接Dll时__NULL_IMPORT_DESCRIPTOR
可能未被主动引入,导致的PE映像导入表没能以空IID结尾。
与Windows SDK的Lib里的Obj相比,我们可以去除其中的调试符号,也可以简化3个Obj之间的复杂关系。
3. 代码实现
我的代码实现:
KNSoft/C4Lib - C4Lib/Source/PEImage/ObjectFile.cs中NewNullIIDObject
方法用于构造__NULL_IMPORT_DESCRIPTOR
所在Obj,NewDllImportStubObject
方法用于构造__IMPORT_DESCRIPTOR_XXX
与XXX_NULL_THUNK_DATA
所在Obj。
KNSoft/C4Lib - C4Lib/Source/PEImage/ArchiveFile.cs是Lib构造的核心实现,调用AddImport
方法(包含多个重载)为Lib文件添加Object文件或Dll导出的导入项。
KNSoft/Precomp4C - Precomp4C/Source/Tasks/DllStubTask.cs是MSBuild自定义生成任务,调用上述方法,根据输入的XML描述,输出Lib:
1 2 3 4 5 6 7 8 9 | <DllStub>
<Dll Name = "KERNEL32.dll" >
<Export Name = "CreateProcessInternalW" CallConv = "__stdcall" Arg = "ptr ptr ptr ptr ptr long long ptr ptr ptr ptr ptr" / >
< / Dll>
<Dll Name = "SECHOST.dll" >
<Export Name = "LsaLookupOpenLocalPolicy" CallConv = "__stdcall" Arg = "ptr long ptr" / >
<! - - ... - - >
< / Dll>
< / DllStub>
|
使用7-Zip查看产出Lib的1.txt,内容如:
1 2 3 4 5 6 7 8 9 10 | KNSoft __NULL_IMPORT_DESCRIPTOR
1.KERNEL32 .dll __IMPORT_DESCRIPTOR_KERNEL32
1.KERNEL32 .dll KERNEL32_NULL_THUNK_DATA
2.KERNEL32 .dll _CreateProcessInternalW@ 48
2.KERNEL32 .dll __imp__CreateProcessInternalW@ 48
1.SECHOST .dll __IMPORT_DESCRIPTOR_SECHOST
1.SECHOST .dll SECHOST_NULL_THUNK_DATA
2.SECHOST .dll _LsaLookupOpenLocalPolicy@ 12
2.SECHOST .dll __imp__LsaLookupOpenLocalPolicy@ 12
...
|
可以看到它同时包含了KERNEL32.dll
与SECHOST.dll
的导出,__NULL_IMPORT_DESCRIPTOR
只有一份,__IMPORT_DESCRIPTOR_XXX
与XXX_NULL_THUNK_DATA
归并到了一个Obj里,并自动生成了带__imp_
开头的别名符号,已可正常链接,功德+1。
要功德圆满,功能上就要形成闭环。我能想到好的方式是与MSBuild集成,这也是使用C#实现的主要因素。
也许我们天天都在使用MSBuild这个强大的生成平台,却对它了解甚少,如果稍加了解,
- 至少生成脚本里可以使用MSBuild命令代替VS的devenv,得到更即时而优雅的生成输出;
- 至少可以添加个Directory.Build.props,省去挨个项目调整配置的繁琐;
- 至少不用尴尬地将Copy命令或者Zip打包命令写在“Pre-Build Event”里;
- 至少可以为插入的外部生成步骤指定输入和输出,从MSBuild 增量生成功能中收益;
- ...
我想,它值得独占一篇。接下来的第二篇(未完待续)将以自定义代码生成任务为起点,了解MSBuild,将类似spec文件的Dll导出信息通过自定义代码生成任务编译生成Lib,进而可与Visual Studio C/C++项目无缝衔接。
<br/>
文档参考:
PE Format - Archive (Library) File Format - Win32 apps | Microsoft Learn
<br/>
如有谬误,恳请指正。本文与以上实现也将随以下项目不断更新:
<br/>
原文链接:为未公开API生成Lib并与MSBuild集成(一)
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 (CC BY-NC-SA 4.0) 进行许可。
<br/>
Ratin <ratin@knsoft.org>
国家认证 系统架构设计师
ReactOS Contributor
[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法
最后于 2023-8-28 00:00
被Ratin编辑
,原因: 勘误