对于每次导出都没有完全可靠的方法。
每个导出仅在可执行文件中指定一个偏移量——从逻辑上讲,它可以被任何其他引用它的代码视为代码或数据。
正如您所提到的,您可以想出启发式方法来检测几乎所有情况下的导出类型,但是很容易想出对任何给定启发式都不起作用的反例。以您提出的规则为例:
如果函数中有ret指令,则导出的条目将被视为有效的导出函数,并且有超过<min>有效指令,和 IDA 识别函数的调用约定。
误报:您可能有一个函数使用tail call optimization 并以jmp 指令而不是ret 指令结束。任何简短的功能也会失败。 IDA 有几种方式可能会混淆为不将代码视为函数。
误报:内存中可能有一个字符串,后面紧跟一个C3 或C2,如db 'BACKGAMMON0',0,0C3h——这在逻辑上可以反汇编为一个有效的11条指令函数ret 并且没有参数。
当您考虑到导出可以在逻辑上同时被视为代码 和 数据时,界限就更加模糊了:想象一下,导出时的字节序列被复制到动态分配的内存中——甚至可能在另一个进程中——稍后它会作为代码执行。
也许一个合理的建议是信任 IDA,如果 IDA 认为它是代码,则将导出视为代码。 IDA 的很大一部分功能是自动猜测数据的逻辑类型,而且它通常很擅长。正如你所展示的,有时它是错误的。但无论如何,你无法获得 100% 的准确率。你能做的最好的就是在假阴性和假阳性之间取得平衡。
证明这个问题的不可判定性:
导出是否将作为代码执行是不确定的。导出是否将被读取为数据也是不确定的。由于我们无法保证两者都是正确的,因此不可能区分看似模棱两可的情况。
证明:假设我们有一个 oracle A(P,I,E),如果程序 P(包括其所有依赖项)执行(或读取)导出 E(从在 @987654337 过程中加载的任何 DLL,则返回 1 @ 的执行)带有“输入”(外部状态)I。否则返回 0。
让我们构造一个最小程序Z(P,I,E),当且仅当A(P,I,E)返回0时,它执行(或读取)export E(加载到地址空间的DLL)。
现在考虑Z(Z,I,E)的结果:
如果Z(Z,I,E) 执行(或读取)导出E,则A(Z,I,E) 将返回1。但Z(Z,I,E) 被定义为不 访问导出E,除非A(Z,I,E)返回 0。这是一个矛盾。
如果Z(Z,I,E) 不执行(或读取)导出E,则A(Z,I,E) 将返回0。但Z(Z,I,E) 的定义使其将 访问导出E当A(Z,I,E) 返回 0 时,这是矛盾的。
因此,我们最初假设 oracle A(P,I,E) 存在被证明是错误的。
但你可以通过仪器做得更好...
根据您要解决的具体问题,您可能能够确定哪些导出是运行时有效的函数。
例如,您可以编写一个应用程序,其中debugs 是您要分析的程序,并将guard pages 放置在包含您希望挂钩的导出的每个页面上。这意味着,无论何时访问(执行/读取/写入)页面,都会引发异常,并且调试器程序将获得控制权。
调试器可以检查程序上下文以查看访问的类型以及它是否与导出有关。如果访问是尝试执行导出,它可以在将控制权返回给程序之前执行一些挂钩功能。否则,它只能将控制权返回给程序。
在任何一种情况下,PAGE_GUARD 修饰符在每次异常后都会被解除,因此您每次都需要将其放回。
不出所料,这会使您的程序的执行非常慢,因为对任何包含导出的页面的任何 R/W/X 访问都会导致代价高昂的context switch——这可能包括执行作为导出函数一部分的大多数指令,以及与它们无关的其他一些指令。
您可以对其他检测工具采取类似的方法,例如 Pin。
请注意,您可能无法通过检测获得有关每个导出的使用情况的信息。这是因为您可能需要确定使程序访问每个导出所需的输入/外部状态,以便了解它是用作代码还是用作数据(如果有的话)。
另请注意,执行和读取(甚至写入)访问都可能发生在同一个导出上。