返回介绍

8.2 签名生成器

发布于 2025-03-09 23:09:33 字数 6379 浏览 0 评论 0 收藏 0

签名生成器是一个为输入文件自动生成签名的程序。签名即是用来识别病毒、编译器和库子程序的二进制标本。在反编译中使用签名的目的是撤消链接器执行的处理过程,也就是说,确定哪些子程序是库例程和编译器启动代码,并且用它们的名字 (对于前者) 代替它们或者从目标输出代码去掉它们 (对于后者)。这用于某些不共享库而是将库子程序的目标代码绑入程序二进制映像的操作系统。在二进制程序中没有储存子程序的名字或参数,因此,没有办法区别它们和用户编写的子程序,不可能从其它子程序区分出它们。而对于共享库子程序的操作系统,这些子程序并不成为二进制程序的组成部分,而是在程序中有对它们的引用,因此,子程序的名字被储存在二进制文件中 (最有可能在头部节中)。在这一节中提出的方法针对那些不共享库子程序而是在二进制程序中包括它们的操作系统。

为一组库子程序产生一个签名文件以后,调用一个接口过程根据库签名检查一个特定的子程序是否匹配库签名,以便决定要不要由反编译器/反汇编器对它进行语法分析。如果一个子程序匹配其中一个签名,那么子程序用它的名字 (即,子程序在库中的名字,比如 printf) 代替并且做上记号——不再需要对它做任何分析。这样,需要被分析的子程序数目减少了,但是更大的意义在于,由于那些子程序调用使用真正的库函数名而非无意义的任意名字,使得目标高级程序的质量(可读性) 得到相当大的改善。而且,由于对性能或者低级机器访问的要求,某些库子程序是用汇编语言编写的,这些例程大都没有相应的高级表示法,因此无法反编译而只能被反汇编;库签名识别方法的应用使我们可以不必分析这一类子程序,从而产生更好的目标代码。

在本节和下一节 (第 8.2 节和第 8.3 节) 提出的这个想法被 Michael Van Emmerik 在昆士兰技术大学工作的时候加以发展。在参考文献[Emm94]中表达了这些想法。经原作者许可,我复制了图 8-4。

8.2.1 库子程序签名

一个标准库文件是一个可重定位的目标文件,实现一个特定语言/编译器为用户提供的不同子程序。一个库子程序签名是一个二进制标本,唯一地标识该库中的一个子程序,区别于在相同库中的其它任何子程序。因为所有子程序执行不同的功能,一个包含有子程序完整二进制映像的签名能够从其它任何子程序唯一地标识该子程序。这个方法的主要问题在于签名的尺寸及创建它造成的开销。因此理想的做法是只检查子程序中最少必须的字节,所以签名要尽可能小。对于有大量子程序的库,不难知道,有些子程序的签名需要使用 n 字节唯一地标识它们,但是 n 可能大于那些小的子程序的完全尺寸;因此,对于小的子程序,剩余字节需要用一个预先确定的数值填补,以免把属于另一个库子程序的字节也拿来了。例如,如果 n 为 23,库函数 cos 只有 10 字节,而且 cos 后面跟随的是库函数 strcpy;cos 的 10 个字节组成它的签名,而剩下 13 字节用预定值填补;否则,这 13 字节将会来自 strcpy 的一部分。

对于任意一个子程序的开头 n 字节,其中有些机器指令——可能无法确定其操作数是常数还是偏移量,或者其操作数取决于模块装入什么地址——应当是可变字节,所谓可变字节,即是在库文件中和在包含这个子程序的二进制程序中这些字节的值可能是不一样的。因此,为了产生一个地址无关的签名,必须把这些可变字节用通配符代替。考虑图 8-4 中库例程 fseek() 的代码。指令 108 的 call 有一个偏移地址指向所调用的子程序。被调用的子程序不总是被链接到相同的位置,因此这个偏移地址是可变的;所以用通配符代替它。指令 110 的 mov 把一个常数或偏移操作数作为它的参数之一;因为不知道这个位置是不是恒定的 (即,一个常数做为地址),所以也用通配符。通配符值的选择取决于机器汇编器。在机器上几乎不被使用的字节,比如这个例子中的 halt 指令 (操作码 F4),或者在机器的反汇编中不被使用的字节,都是不错的通配符选择。对于在签名太小时使用的填充字节,其数值的选择要求与此相似。在这个例子中,用 00 作为通配符。

010055push bp 
01018BECmov bp, sp 
010356push si 
01048B7604mov si, [bp+4] 
010756push si 
0108E8F4F4call ****; destination wildcarded
010B59pop cx 
010C0BC0or ax, ax 
010E7405jz 0115 
0110B8F4F4mov ax, ****; operand wildcarded
0113EB4Cjmp 0161 
01150000; padding 

图 8-4: 库函数 fseek() 的部分反汇编

注意,在这个例子中,虽然在函数 fseek() 的映像中有较多的字节,但是由于指令 114 的无条件跳转,签名只截取前 21 字节。之所以这样做,是因为不知道跟随这条无条件跳转指令的字节是不是相同库子程序的组成部分。一般来说,对于库签名而言,只要遇到一个返回指令或(无) 条件跳转指令,就认为该子程序结束而填补其余不足字节。这个例子最后的签名见图 8-5。应该注意,这个方法有一定的出错小概率,因为不同的子程序从它开始处到第一个(无) 条件控制转移可能有相同的起始代码。

558BEC568B760456E8F4F4590BC07405B8F4F4EB4C0000

图 8-5: 库函数 fseek() 的签名

自动生成库子程序签名的算法见图 8-6 所示。这个算法的参数有:一个标准库文件、输出签名文件的名字、和签名的尺寸(以字节为单位)——事先设立的经验值。

因为编译器厂商为使用不同存储器模型的机器提供不同的库文件,所以需要为每个存储器模型产生不同的签名文件。理想的做法是,使用一个命名约定来确定签名库的编译器厂商和存储器模型,如此,就不必在签名文件里保存额外的头部信息。

procedure genLibrarySignature (lib:libraryFile, signLib:file, n:integer)

/* Pre: lib is a standard library file.

* signLib is the name of the output library signature file.

* n is the size of the signature in bytes.

* Post: the signLib file is created, and contains all library subroutine signatures. */

openFile (signLib).

for (all subroutines s∈lib) do

if ( shas nconsecutive bytes) then

sign[1..n] = first nbytes of s.

else /* shas only m < nbytes */

sign[1..m] = first mbytes of s.

sign[m+1..n] = paddingValue.

end if

for (all variant bytes b∈sign[1..n]) do

sign[b] = wildcardValue.

end for

write (name( s), sign[1..n]) to the file signLib.

end for

closeFile (signLib).

end procedure

图 8-6: 签名算法

库签名和反编译器的整合

给定一个子程序的入口点,语法分析器从入口点跟随所有路径反汇编指令。如果知道当前正在分析的源二进制程序是用什么样的编译器编译的,语法分析器能够检查子程序是不是来自某个库 (对于这个特定的编译器)。如果是的,因为知道调用了哪一个子程序,所以就不需要对这个代码做语法分析,而仅仅使用该子程序的名字。

由于在一个库中有大量的子程序,用线性搜索在一个文件中检查所有可能的签名会很低效率的。对此,散列是一个有用技术,如果在一个库中每个子程序的签名是唯一的,可以使用理想散列(perfect hashing),效果甚至更好而且有一个固定的尺寸。理想散列信息可以储存在库签名文件的头部,每当需要确定一个子程序是否属于该库的时候,供语法分析器使用。

8.2.2 编译器签名

为了确定对一个二进制程序使用哪一个库签名文件,需要先确定原来的用户程序是使用什么编译器来编译的。因为不同的编译器厂商在编译器启动代码中使用不同的二进制映像,所以我们可以手工调查这些映像并且储存为一个应用通配符的签名,其做法和库子程序签名一样。对于相同的编译器,不同的存储器模型会产生不同的编译器签名,而且很可能,相同编译器的不同版本有不同的签名,因此,为它们每一组合 (编译器厂商、存储器模型、编译器版本) 储存一个不同的签名。可以再次使用一命名系统来区别不同的编译器签名。

确定主程序

由装载器给予的入口点是编译器启动代码的入口——在调用该程序的主要子程序之前,至少调用一打的子程序设置它的环境;主要子程序即,任何 C 语言程序中的 main,或者某个 Modula-2 程序中的 BEGIN。对于一个用我们预先规定的编译器编译的程序,它的主入口点是通过手工调查启动代码确定的。在所有的 C 语言编译器中,在主函数被调用之前,先把要传给 main() 函数的参数入栈 (即 argv,argc,envp);因此,确定主入口点不是很难。为了便于移值性,大多数 C 语言编译器提供它们启动代码的源程序代码,因此,主入口点的检测也可以利用这个东西来做。一旦知道了如何确定主入口点,就可以在编译器签名文件中为那个特定的编译器储存这个方法。

适用于反编译器的编译器签名整合

语法分析器开始分析(由装载器给出的) 入口点上任何指令之前,先调用一个接口过程检查不同的编译器签名。这个过程确定所装入程序的开头字节是不是与一个已知的编译器签名等同的,如果相同,那么编译器厂商、编译器版本和存储器模型即可确定,并且把这些信息储存在一个全局结构中。当这一步完成之后,主入口点根据这个签名就可以确定了,而且该入口点将作为语法分析器的出发点。此后,可以针对确定的编译器厂商、编译器版本和存储器模型应用适当的库签名文件,检查该程序调用的任何子程序。

8.2.3 签名的手工生成

签名自动生成是理想的解决方案,但是,找出所有在库中能够唯一标识不同子程序的二进制标本不是那么容易。实验结果已经显示,在一个标准库文件中重复签名总数的最低比例 5.3%而最高比例 29.7% [Emm94]。大多数重复的签名是由于一些函数有不同的名字却有相同的实现,或者由于开头几个字节里过早出现无条件跳转迫使其签名截取得过于短小。

在参考文献[FZ91]中描述了一个手工生成签名的方法,而且在一个 8086 C 语言反编译系统中被使用 [FZL93]。通过手工调查,分析微软 C 语言 5.0 版使用的一个库文件中每个函数并且储存每个函数的以下信息:函数名字、该函数完整的二进制映像(包括可变字节)、以及用来确定一个任意子程序是否与之相配的匹配方法。匹配方法是一个指令序列,它确定在该函数的二进制标本中从某一个偏移地址开始有多少固定的信息字节,以及该函数都调用了什么子程序。每当无法确定某一个操作数是一个偏移量还是常数的时候,那些字节就被跳过 (即,由于它们是可变字节,所以不与二进制标本的对应字节做比较),而且当遇到一个子程序调用的时候,对子程序的偏移地址不做测试,但是要对所调用的例程做匹配;依次,把被调用例程跟它的签名标本做匹配。这样,子程序的所有路径都被跟随并且被检查签名。

签名人工生成的缺点在于生成它们需要的时间;典型地,一个库有 300 个以上的子程序,而对于面向对象语言,这个数字增加到 1300 以上。为这样的一个库手工生成签名需要花费数天,对于大的库文件则长达一周。而且,当编译器的一个新版本出来的时候,必须重新手工分析其签名,因此时间开销很大。使用一个自动签名生成器会使得为一个完全的库生成签名所花费的大量时间,减少到几秒 (少于一分钟),尽管有一定比例的重复签名这一点点缺憾。但是,如果有必要,可以通过手工检查那些有重复签名的函数并为它们生成唯一的签名。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文