作者:博士 切尔诺夫A.V. ( monsieur_cher )和博士学位。 Troshina K.N.如何使用基于现代处理器体系结构知识的最一般的假设,如何从未知体系结构的二进制映像中还原程序结构,然后还原算法等等?在本文中,我们将讨论几年前摆在我们面前的一项有趣的任务。 客户要求处理正在管理某个物理过程的设备的二进制固件。 他需要一个已编译的C程序形式的控制算法,以及需要说明其工作方式和原因的公式。 客户认为,这是确保与新系统中“旧”设备兼容的必要条件。 在本系列文章的框架中,我们省略了最终处理物理学的方式,但是我们将详细考虑恢复算法的过程。
可编程微控制器在大规模设备中的几乎普遍使用(物联网IOT或SmartHome的概念)要求注意嵌入式代码的二进制分析,或者换句话说,对设备固件的二进制分析。
设备固件的二进制分析可能具有以下目标:
- 对漏洞代码进行分析,以允许未经授权访问该设备或该设备传输或处理的数据。
- 未记录功能的代码分析,例如,导致信息泄漏。
- 代码分析可恢复与设备交互的协议和接口,以确保此设备与其他设备兼容。
上面提出的二进制代码分析任务可以视为二进制分析任务的特殊情况,以确保设备兼容性。
二进制文件格式分析
如果在“真实”操作系统中,可执行文件格式是标准化的,那么在嵌入式程序中,每个供应商都可以使用其专有解决方案。 因此,对二进制固件文件的分析必须从对二进制文件格式的分析开始。
在工作开始时,对我们的情况如下:我们收到了带有固件的某个文件,没有任何随附的文档。 没有有关固件文件格式的信息,也没有有关微控制器架构的信息。
固件文件原来是文本文件。 它包含以下形式的行:
:04013000260F970CF8 :10020000004D000B043F000B34AD010C002FFE4D30 :02023000FD0BC1 :1004000018001A0000001E0008005E000200190052
仔细研究了这几行代码后,我们意识到这是用于微控制器的完全标准的Intel HEX格式。 该文件由记录组成,每个记录都表明其类型,内存位置,数据和校验和。 就其本身而言,使用Intel Hex格式意味着该文件很可能未加密,并且是驻留在内存中的程序的映像。
尽管Intel Hex格式最多支持32位内存地址,但是我们的文件中只有16位内存地址。 因此,很容易从文本文件创建存储映像的二进制文件,其中原始测试文件中的记录已经放置在指定的地址上。 使用二进制文件分析实用程序检查这样的二进制文件更方便,并且为自己的实用程序编写比使用Intel HEX更容易。 二进制图像存储器文件确认该文件未加密,因为发现各种有意义的行分散在代码的不同位置。
但是,这并不能回答该文件是哪种体系结构的问题。

我们得到了一个文件,其中包含一些16位或8位微控制器的存储器映像。 什么样的微控制器还不清楚。 我们采用了IDA Pro,并尝试使用支持的处理器的所有可能变体来分解文件。 没事 没有一个受支持的IDA Pro处理器出现:该列表未生成或包含明显的废话。 它可能是某个受支持的IDA Pro处理器的程序,但是我们做错了。 例如,您只需要对图像文件进行其他处理。 无论如何,在这里都可以暂停工作并请求有关二进制文件的其他信息。
所有处理器都差不多。
但这对我们来说变得很有趣,即使我们不知道为它编译的处理器,我们也可以从二进制程序中了解到什么。 答案是“无”-没意思,即使我们能有所了解,总比没有好。 显然,文本字符串可以提供有关程序的信息,但我们的目标是更多-从程序的结构中了解一些内容。
各种处理器体系结构-数量众多。 计算的发展甚至产生了最不寻常的体系结构,例如三元计算机。 然而,当前存在的微处理器和微控制器,至少是大规模的,彼此非常相似。
下面我们列出了现代微处理器共有的基本原理。
指令执行一致。 处理器按顺序在内存中执行指令。 子例程有一些特殊的指令,用于有条件和无条件跳转以及调用和返回,使您可以中断从存储器中顺序选择指令并继续执行另一条指令。 但是,其余指令假定顺序执行,因此不包含下一条指令的地址。
二进制编码。 除了处理器以二进制形式处理数据外,构成可执行程序的处理器指令还以二进制格式编码,也就是说,存储指令参数的字段(例如偏移量或寄存器号)占据整数位数。 从理论上可以想象,尽管对数据和程序进行了二进制编码,但是它们仍将在其他数字系统的处理器中进行处理,但是我们并不了解这种奇特的现象。
一般而言,以下原则不是基本的体系结构原则,但实际上已被普遍实现,尤其是对于不是以汇编语言手动编写而是由高级语言编译器生成的机器代码。
过程编程。 程序分为结构单元,可以以不同的方式调用它们:过程,函数,子程序等。子程序可以调用其他子程序,将参数传递给它们,并返回执行结果。 子程序必须有一个入口点,这很重要,也就是说,所有调用给定子程序的子程序都必须进入相同的入口点地址。
通常,例程具有一个将控制权返回给调用点的出口点,但是这与每个例程都需要一个入口点相比意义不大。 此类代码通常是通过编译程序获得的。 链接时间优化器可以部分破坏此结构,以减小程序的大小,并且程序的大小对于嵌入式系统至关重要。 而且,该结构可以被代码混淆器破坏。
子例程调用的嵌套可以使用堆栈进行组织,该堆栈仍可用于将参数传递给子例程并存储局部变量,但是在当前体系结构开发级别,此信息还为时过早。
这些原理如何应用于二进制代码的初始分析?
我们基本假设处理器命令系统中存在RET指令(从子例程返回)。 该指令具有某种固定的二进制表示形式,我们将寻找它。 如果RET不是唯一的变量(如x86中的RET),其中RET有一个参数-子例程参数区域的大小,或者RET是更复杂的操作的副作用(如ARMv7),则PC值可以与其他寄存器的值同时从堆栈中获取(ldmfd sp! ,{fp,pc}),那么很可能我们的启发式搜索不会产生结果。
我们还需要立即对所研究处理器的编码指令原理进行合理假设。 现有的处理器使用几种原理来编码指令:
- 从中生成指令的字节流,并使用不同数量的字节对不同的指令进行编码。 在这一类别中,最著名的代表是x86系列,从最初的8080处理器到最现代的64位处理器。 一条x86_64处理器指令可以1到16个字节的顺序编码。 具有可变指令长度的同一系列处理器包括8051,该系列用于微控制器中。
- 16位值的流。 此外,每条指令具有固定大小-16位。
- 16位值的流,而指令的大小可变。 该系列的代表之一是PDP-11架构,其中命令本身占据前16位,并且其后可以是直接值或用于直接寻址的存储器地址。 这包括ARM体系结构中的THUMB编码。
- 一个32位值流,每个指令的固定大小为32位。 这些是大多数32位和64位RISC处理器:ARMv7,ARMv8,MIPS。
在长度可变的字节流和16位的字流之间进行选择,将有助于“肉眼观察”存储器映像。 无论处理器指令如何编码,在足够长的程序中,它们都将不可避免地被重复。 例如,在x86指令上
add
它会将eax和ebx寄存器的值相加并将结果放入eax中,并以两个字节进行编码:
01 d8.
关于ARMv7指令
add r0, r0, r1
它通过32位值e0800001对寄存器r0和r1的值相加并将结果放入r0。
在足够大的程序中,此类指令将重复执行一次以上。 如果我们感兴趣的字节序列(例如01 d8)出现在任意未对齐的地址处,我们可以假定处理器指令是由可变大小的字节流编码的。 如果仅在4的倍数的地址上找到e0800001的值,则可以假设处理器指令的大小固定。 当然,这里有一个错误,就是我们为一条指令占用了数据字节,或者由于某些指令总是被对齐而偶然发生的。 但是,这种“噪声”对足够大的程序的影响很小。
当我们从这个角度看待分析的固件时,很明显,所讨论的处理器的指令很可能是用16位值编码的。
因此,基于RET指令的编码是某个固定的16位值的假设,让我们尝试找到它。 我们在程序映像中找到最常见的16位值。 在我们的案例中,发生了以下情况:
(hex) 0b01 854 5.1
我们将在代码中最常遇到的这16位值中搜索RET指令。 立即我们将寻找与RET指令配对的CALL指令。 CALL指令至少具有一个参数-跳转地址,因此固定值是必不可少的。
我们假设在许多情况下,在一个子程序结束之后,即在RET指令之后,另一个子程序立即开始,并且该子程序由CALL指令从程序的另一点调用。 紧随RET之后到达地址的大量跃点将是CALL指令的标志之一。 当然,该规则不是通用的,因为在某些平台上,特别是在ARMv7上,从子例程完成后,通常会立即找到一个常量池。 在这种情况下,我们可以将紧随RET之后的某个合理地址范围视为RET指令的转换点。
在CALL指令的情况下,可以有很多选择来对子例程进行编码。 首先,处理器可以在单词中使用不同的字节顺序:就像在大多数现代处理器中一样,little-endian表示将低字节开头的多字节整数写入内存,而将多字节整数写入内存则以big-endian开头从高字节开始。 几乎所有现代处理器都在低字节序模式下运行,但是您不应该在一个字中丢弃其他可能的字节顺序。
其次,CALL指令可以使用跳转点的绝对寻址或相对于当前地址的寻址。 在绝对寻址的情况下,编码指令在编码指令的某些位中包含您要转到的地址。 为了确保从16位地址空间中的任何点到16位字的绝对地址的任何其他点都调用该子例程,已编码的指令是不够的,因为除了转移地址之外,操作代码的位还需要存储在其他位置。 因此,有必要连续考虑两个16位字,并尝试使用不同的方法在这些字之间分割转换地址。
例程地址的绝对编码的另一种选择是相对编码。 在编码的指令中,我们记录子程序的地址和当前点之间的差。 相对编码通常比绝对编码更可取,因为首先,具有相对转换的程序在位置上是独立的,也就是说,它可以从任何地址定位在内存中,而无需更改二进制代码。 其次,基于偏移量的编码,由于在许多情况下被调用例程与被调用例程相距不远,因此可以保留比地址空间维数少的位。 但是,如果调用的偏移量超出了可表示值的范围,则必须插入特殊说明-“跳转”。
子程序地址的相对编码可以有一些变化:首先,程序的当前点的地址可以用作当前指令的地址,也可以作为下一条指令的地址(例如x86处理器),也可以当作当前点附近的其他某些指令的地址。 例如,在ARM处理器中,参考点是当前指令+8的地址(即,不是CALL之后的指令的地址,而是下一条之后的指令的地址)。 另外,由于在我们的情况下,程序是以16位字的流的形式编写的,因此可以预期,偏移量将以字表示。 也就是说,要获取被调用例程的地址,需要将偏移量乘以2。
考虑到以上所有内容,我们获得以下枚举空间,用于以二进制代码搜索CALL / RET对。
首先,我们从代码中最常见的值列表中选取16位字作为RET指令的候选项。 接下来,我们搜索CALL指令:
- 大端和小端单词字节顺序
- 指令中例程地址的绝对和相对编码。
对于绝对编码,我们考虑连续两个16位值,即,我们对各种选项进行排序,以将存储绝对地址的位字段放置在32位字中;对于相对编码,我们同时考虑16位值和两个16位字。 接下来,我们对各种选项进行排序,以放置用于存储偏移量的位字段。 我们检查偏移量是以字节还是以16位字表示的,也就是说,是否有必要将偏移量乘以2,我们检查参考点的不同选项:当前指令的地址,下一条指令的地址。
对于上述搜索空间中的每个选项,我们计算统计信息:
- 子程序开头的多少个假定地址显然不是正确的,即它们位于什么都没有或数据(显式字符串或显式值表)所在的位置。 即使对于与CALL指令正确编码相对应的值,如果与CALL指令相对应的值偶然出现在数据中,则很有可能子程序开头的少量不正确地址也是可能的。
- 在假定的RET指令之后立即有多少个假定的例程起始地址。
- 多次使用例程的假设的起始地址有多少个。
如果我们对一对CALL / RET指令的假设是正确的,那么它应该在所描述的枚举空间中。 但也可能有误报。 好了,我们开始搜索。
而且,我们仅找到一种可能的选择!
Trying 8c0d as RET After-ret-addr-set-size: 430 Matching call opcodes for 1, ff00ff00, 1: 000b003d: total: 1275, hits: 843 (66
因此,16位字8c0d适合作为RET指令的候选。 在此指令之后,固件总共包含430个程序地址位置。 我们考虑了32位值(连续两个16位字),其地址掩码值为ff 00 ff 00,找到了一条代码为00 0b 00 3d的指令。 共有1275条此类指令,其中843条(即66%)将控制权转移到紧随RET候选者之后的点。 因此,确定了两个指令:
- RET:8c0d(16位Little-Endian)
- CALL HHLL:0bHH 3dLL(2个16位Little-Endian)
CALL指令使用绝对寻址,并且在写入跳转地址时,首先写入高字节,然后写入低字节。 实际上,这可能是两个处理器指令,每个指令都加载转换地址的一半,但是从程序分析的角度来看,这并不重要。 了解了CALL和RET指令,我们可以更准确地标记出代码和程序数据的区域,这对于进一步分析非常重要。
待续...
此外,我们将说明如何恢复条件和无条件转换以及一些算术和逻辑运算。