如何拆解、修改并重新组装Linux可执行文件?

68

有没有办法实现这个?我尝试使用了objdump,但是它无法产生任何汇编输出,并且也不被我知道的任何汇编器接受。我想能够更改可执行文件中的指令,然后进行测试。


3
并非所有您遇到的可执行文件都是如此。 - mgiuca
@JamesAnderson,我目前正在查看一份针对商业编译器编写的“开源”代码,但在gcc下无法干净地编译。 - avakar
在其他网站上:http://reverseengineering.stackexchange.com/questions/185/how-do-i-add-functionality-to-an-existing-binary-executable | http://askubuntu.com/questions/617441/how-can-edit-a-executable-file-linux - Ciro Santilli OurBigBook.com
您可以使用radare2,通过在-w标志下以写模式打开它。https://github.com/radare/radare2/blob/master/doc/intro.md - rmn
8个回答

33

我认为没有可靠的方法可以实现这一点。机器代码格式非常复杂,比汇编文件更加复杂。无法从已编译的二进制文件(例如ELF格式)生成一个源汇编程序,该程序编译后会得到相同或类似的二进制文件。要了解差异,请比较GCC直接编译成汇编语言(gcc -S)与可执行文件上的objdump输出(objdump -D)。

我能想到两个主要的问题。首先,机器代码本身与汇编代码并不是一一对应的,因为存在指针偏移等问题。

例如,考虑输出 Hello World 的C代码:

int main()
{
    printf("Hello, world!\n");
    return 0;
}
这将编译为x86汇编代码:
.LC0:
    .string "hello"
    .text
<snip>
    movl    $.LC0, %eax
    movl    %eax, (%esp)
    call    printf

.LCO是一个命名常量,printf是共享库符号表中的一个符号。与objdump的输出进行比较:


80483cd:       b8 b0 84 04 08          mov    $0x80484b0,%eax
80483d2:       89 04 24                mov    %eax,(%esp)
80483d5:       e8 1a ff ff ff          call   80482f4 <printf@plt>
首先,常量.LC0现在只是内存中某个随机偏移量——要创建一个包含此常量的汇编源文件并将其放置在正确位置上会很困难,因为汇编器和链接器可以自由选择这些常量的位置。
其次,我不是完全确定这一点(这取决于诸如位置无关代码之类的东西),但我认为printf的引用实际上根本没有编码在那段代码中指针地址上,而是ELF头包含一个查找表,在运行时动态替换其地址。因此,反汇编的代码与源汇编代码并不完全对应。
总之,源汇编具有符号,而编译后的机器代码具有难以逆转的地址。
第二个主要问题是,汇编源文件无法包含原始ELF文件头中存在的所有信息,例如要动态连接的库以及原始编译器放置在那里的其他元数据。要重构这个将会很困难。
像我说的,可能有特殊工具可以操作所有这些信息,但是简单地生成可以重新汇编成可执行文件的汇编代码是不太可能的。
如果您只想修改可执行文件的一小部分,我建议使用比重新编译整个应用程序更微妙的方法。使用objdump获取您感兴趣的函数的汇编代码。手动将其转换为“源汇编语法”(在这里,我希望有一个真正以输入相同语法生成反汇编的工具),并按需进行修改。完成后,重新编译那些函数,并使用objdump查找您修改过的程序的机器代码。然后,使用十六进制编辑器手动粘贴新的机器代码到原始程序的相应部分上,确保新代码与旧代码的字节数完全相同(否则所有偏移量都将出错)。如果新代码较短,则可以使用NOP指令来填充。如果它比原代码长,则您可能会遇到麻烦,必须创建新函数并调用它们。

除非有意地剥离符号,否则许多符号将被保留。尝试使用 nm /bin/ls。 - Praxeolitic
4
我认为你有些夸大了代码查找常量的一些困难。 主要问题在于,没有汇编语法能够独特地表示相同指令的不同长度编码。 Agner Fog的objconv反汇编器将反汇编为NASM、YASM、MASM或GNU as语法。该输出可以重新汇编成类似的二进制文件,但代码对齐/偏移的任何假设可能已经改变。例如,过程链接表(PLT)需要使用jmp rel32编码,以便在运行时填充正确的偏移量。 - Peter Cordes
1
更新:GAS有前缀,即使指令不需要,也可以请求显式的disp32:https://dev59.com/n6fja4cB1Zd3GeqPt1Cf。此外,NASM通常可以使用`mov eax,[rdi + strict dword 0]`来强制使用disp32。问题在于,反汇编器在反汇编长于所需指令时不会放入这些覆盖。 - Peter Cordes
1
Peter Cordes,当然汇编语法可以唯一地表示“相同”指令的不同编码,以及具有相同结果的不同指令。我设计了这样一个语法,旨在进行逆向工程。请参阅我的回答。 - Albert van der Horst
@Peter Cordes 关于缺失的覆盖,这只是意味着反汇编器不适合,而不是不存在。请参见我的回答。如果您想要一个具有对可执行文件特定细节的神奇知识的工具,那显然是不可能的。如果一个工具具有提取可执行文件名称的脚本功能(即使它们是霍夫曼编码的,就像我的一个例子一样),并且在区分同一函数的所有可能表达式方面是精确的,那么天空就是极限。Groetjes Albert - Albert van der Horst
在此处,您可以找到一个示例,演示如何修改可执行文件中的一个小部分,正如响应中所讨论的那样:https://gist.github.com/simonemainardi/e63c03d6cefe65d0e2541a67e82955f9 - simonemainardi

27

我用 hexdump 和文本编辑器来完成这个任务。你必须对机器码和存储它的文件格式非常熟悉,并且对于“反汇编、修改,然后重新汇编”有灵活的理解。

如果你只需要进行“局部修改”(重写字节,但不添加或删除字节),那么相对来说会比较容易。

你真的不想替换任何现有的指令,因为这样你就必须手动调整机器码中受影响的绝对地址或相对偏移量,以适应跳转/分支/加载/存储相对于程序计数器的情况,无论是在反汇编中清晰可见的硬编码立即值,还是在动态计算的只能通过在使用之前更改寄存器中的地址或偏移量的指令来修改的值。

你应该始终能够避免删除字节。添加字节可能对于更复杂的修改是必要的,而且会更加困难。

步骤 0(准备工作)

在你使用`objdump -D`或者其他你通常使用的方法来真正理解并找到需要修改的位置之后,你需要注意以下几点来帮助你定位正确的字节进行修改:
1. 需要修改的字节的“地址”(相对于文件开头的偏移量)。 2. 当前字节的原始值(`objdump`的`--show-raw-insn`选项在这里非常有帮助)。
你还需要检查你的系统是否支持`hexdump -R`命令。如果不支持,在接下来的步骤中,请使用`xxd`命令或类似的工具代替`hexdump`(请参考你使用的工具的文档,我只是在这个答案中解释了`hexdump`,因为这是我熟悉的工具)。
第一步: 使用`hexdump -Cv`命令转储二进制文件的原始十六进制表示。
第二步:
打开经过hexdump处理的文件,并找到您要更改的地址处的字节。
关于hexdump -Cv输出的快速崩溃课程:
1. 最左边的列是字节的地址(相对于二进制文件的起始位置,就像objdump提供的一样)。 2. 最右边的列(由|字符包围)只是字节的“可读”表示 - 每个字节对应的ASCII字符写在那里,对于所有不映射到ASCII可打印字符的字节,用.代替。 3. 重要的内容在中间 - 每个字节由两个十六进制数字用空格分隔,每行16个字节。
注意:与objdump -D不同,后者给出每个指令的地址并根据其文档编码显示指令的原始十六进制,hexdump -Cv按照文件中出现的顺序精确地转储每个字节。这可能会在首次使用时有些困惑,特别是在指令字节由于字节序差异而以相反顺序出现的机器上,这也可能在您期望在特定地址处有特定字节时产生迷惑。
第三步
修改需要更改的字节 - 你需要找出原始机器指令的编码(不是汇编助记符),并手动写入正确的字节。
注意:你不需要更改最右列的可读表示。当你“un-dump”它时,hexdump会忽略它。
第四步
使用hexdump -R命令“un-dump”修改后的hexdump文件。
第五步(检查)
使用objdump命令对你新的unhexdump文件进行反汇编,并验证你更改的反汇编是否正确。将其与原始文件的objdump进行比较。
真的,不要跳过这一步。当我手动编辑机器代码时,我经常犯错误,这就是我发现大部分错误的方法。
示例
这是一个真实的实例,是我最近修改ARMv8(小端)二进制文件时的工作示例。(我知道,问题标记为x86,但我手头没有x86的示例,而且基本原理是相同的,只是指令不同。)
在我的情况下,我需要禁用一个特定的“你不应该这样做”的辅助检查:在我的示例二进制文件中,在objdump --show-raw-insn -d输出中,我关心的那一行看起来像这样(为了上下文,给出前后一个指令):
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <error@plt>
     f48:   f94013f7    ldr x23, [sp, #32]

如您所见,我们的程序通过跳转到一个错误函数(终止程序)来“友好地”退出。这是不可接受的。因此,我们将把该指令转换为一个无操作指令。我们正在寻找地址/文件偏移量为0xf44处的字节0x97fffeeb
以下是包含该偏移量的hexdump -Cv行。
00000f40  e3 03 15 aa eb fe ff 97  f7 13 40 f9 e8 02 40 39  |..........@...@9|

请注意相关字节实际上是反转的(小端编码在体系结构中适用于机器指令以及其他任何内容),以及这与字节偏移的关系可能有些不直观:
00000f40  -- -- -- -- eb fe ff 97  -- -- -- -- -- -- -- --  |..........@...@9|
                      ^
                      This is offset f44, holding the least significant byte
                      So the *instruction as a whole* is at the expected offset,
                      just the bytes are flipped around. Of course, whether the
                      order matches or not will vary with the architecture.

无论如何,我从其他的反汇编中了解到0xd503201f反汇编为nop,所以它似乎是我无操作指令的一个很好的候选。我相应地修改了hexdump文件中的那一行。
00000f40  e3 03 15 aa 1f 20 03 d5  f7 13 40 f9 e8 02 40 39  |..........@...@9|

使用hexdump -R将其转换回二进制,使用objdump --show-raw-insn -d对新的二进制进行反汇编,并验证更改是否正确。
     f40:   aa1503e3    mov x3, x21
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

然后我运行了二进制文件,并得到了我想要的行为 - 相关的检查不再导致程序中止。
机器码修改成功。
!!! 警告 !!!
或者我成功了吗?你有没有注意到我在这个例子中漏掉了什么?
我相信你注意到了 - 因为你在询问如何手动修改程序的机器码,你应该知道你在做什么。但为了让任何想要学习的读者受益,我会详细说明:
我只改变了错误情况分支中的最后一条指令!跳转到退出程序的函数。但正如你所看到的,寄存器x3在上面的mov指令中被修改了!实际上,在调用error之前的前导部分中,总共有四个(4)个寄存器被修改,而一个寄存器则没有被修改。以下是该分支的完整机器码,从跳过if块的条件跳转开始,到如果条件if不被执行时跳转的位置结束:
     f2c:   350000e8    cbnz    w8, f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <error@plt>
     f48:   f94013f7    ldr x23, [sp, #32]

所有分支之后的代码都是由编译器生成的,假设程序状态是“在条件跳转之前的状态”!但是,通过将最终跳转到“错误”函数的代码设置为无操作,我创建了一条代码路径,我们以“不一致/不正确的程序状态”到达该代码!
在我的情况下,这实际上似乎没有引起任何问题。所以我很幸运。非常幸运:只有在我已经运行了修改后的二进制文件之后(顺便说一下,这是一个“安全关键的二进制文件”:它具有“setuid”、“setgid”和更改“SELinux上下文”的能力!)我才意识到我忘记了实际上追踪代码路径,以确定这些寄存器更改是否影响了后来的代码路径!
那可能是灾难性的-这些寄存器中的任何一个都可能在后续代码中使用,并假设它包含了现在被覆盖的先前值!而我是那种人,人们知道我对代码非常仔细地思考,并且作为一个对计算机安全始终保持谨慎的人。
如果我调用的函数的参数从寄存器溢出到堆栈上(例如在x86上非常常见),那该怎么办?如果在指令集中有多个条件指令在条件跳转之前(例如在旧版ARM上很常见),那我在做这个看似最简单的改变之后,将会处于更加不稳定的状态!
所以这是我提醒大家的警告:手动修改二进制文件就是在你和机器以及操作系统之间剥夺了所有安全性。我们在自动捕捉程序错误方面所取得的所有进展,都消失了。
那么,我们应该如何更正确地解决这个问题呢?请继续阅读。
删除代码
为了有效地/逻辑上“删除”多条指令,你可以将要“删除”的第一条指令替换为一个无条件跳转到“删除”指令末尾的第一条指令。对于这个ARMv8二进制文件,看起来是这样的:
     f2c:   14000007    b   f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <error@plt>
     f48:   f94013f7    ldr x23, [sp, #32]

基本上,你可以“杀死”代码(将其变成“死代码”)。顺便说一句:你也可以用类似的方法替换嵌入在二进制文件中的文字字符串:只要你想用一个更小的字符串来替换它,你几乎总是可以通过覆盖字符串(包括终止的空字节,如果它是一个“C-字符串”)以及必要时覆盖使用它的机器码中的字符串的硬编码大小来实现。
你还可以用无操作指令替换所有不需要的指令。换句话说,我们可以将不需要的代码转换为所谓的“无操作滑板”。
     f2c:   d503201f    nop
     f30:   d503201f    nop
     f34:   d503201f    nop
     f38:   d503201f    nop
     f3c:   d503201f    nop
     f40:   d503201f    nop
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

我会期望这只是相对于跳过它们而言浪费了CPU周期,但它更简单,因此更安全,因为你不必手动计算如何编码跳转指令,包括计算在其中使用的偏移/地址 - 你不必为一个无操作滑槽思考太多。
明确一点,错误很容易发生:当我手动编码那个无条件分支指令时,我犯了两次错误。而且这并不总是我们的错:第一次是因为我使用的文档已经过时/错误,并且说编码中有一个位被忽略了,而实际上并没有,所以我在第一次尝试时将其设置为零。
添加代码
理论上,你可以使用这种技术来添加机器指令,但这更复杂,而且我从未遇到过这种情况,所以目前我没有一个实际的示例。
从机器代码的角度来看,这个问题有点简单:在你想要添加代码的位置选择一条指令,并将其转换为跳转指令,跳转到你需要添加的新代码(不要忘记将你替换的指令添加到新代码中,除非你的添加逻辑不需要,然后在添加结束时跳回到你想要返回的指令)。基本上,你是在“拼接”新代码。
但是你必须找到一个实际放置新代码的位置,这是困难的部分。
如果你非常幸运,你可以将新的机器代码追加到文件的末尾,它会“正常工作”:新代码将与其他代码一起加载到相同的预期机器指令中,加载到你的地址空间中,该地址空间位于一个正确标记为可执行的内存页中。
根据我的经验,hexdump -R 不仅忽略最右边的列,还忽略最左边的列 - 所以你可以简单地为所有手动添加的行放置零地址,它会正常工作。
如果你运气不太好,在添加代码后,你可能需要在同一个文件中调整一些头部值:如果你的操作系统加载程序期望二进制文件包含描述可执行部分大小的元数据(由于历史原因通常称为“文本”部分),你就需要找到并调整它。在过去,二进制文件只是原始的机器代码,而现在机器代码被包裹在一堆元数据中(例如Linux上的ELF和其他一些格式)。
如果你还有一点运气,你可能会在文件中找到一些“死区”,这些死区会作为二进制文件的一部分正确加载到与文件中已有代码相同的相对偏移位置(并且这个死区可以容纳你的代码,并且如果你的CPU要求CPU指令的字对齐,它也是正确对齐的)。然后你可以覆盖它。
如果你真的很倒霉,你不能只是追加代码,也没有可以填充机器代码的空白区域。在这一点上,你基本上必须对可执行格式非常熟悉,并希望你能在这些限制内找到一些人类可行的手动操作方法,而且在合理的时间内完成,并且有合理的机会不出错。

1
@Abhishek,谢谢你。我已经在准备(步骤0)部分添加了一点额外的提示,帮助检查hexdump -R是否可用,如果不行,则可以使用替代工具xxd来替换所有命令中的hexdump. - mtraceur
X0、X1、X2、X3是调用者保存/易失寄存器,因此编译器在调用error后不能假设这些寄存器的任何内容(当然,假设error没有在X0中返回值)。如果编译器需要保存X0、X1等寄存器的内容,它将发出代码从堆栈中加载/恢复这些寄存器和/或使用被调用者保存的寄存器。因此,在您的特定示例中,修补这些寄存器移动是不必要的。 - Léo Lam
@léo-lam 可能是这样,但这是一个冒险的假设。如果特定的机器代码没有遵循你所考虑的ABI调用约定怎么办?许多编译器有一种方法可以更改特定函数的调用约定,或者有一种内联原始机器代码的方法,该方法可以自由违反它。如果已知该函数及其所有调用者都被优化以匹配,则优化可以自由更改有关如何使用寄存器的任何内容,这对于像此类命令行程序中的叶子函数是可能的。 - mtraceur
@léo-lam 我同意你的观点,在看到你的第一条评论后,我确实有意提及调用约定。然而,现在我们来谈谈更大的可教授时刻:在调用 error@plt 后的机器代码中,完全可以忽略对调用者保存寄存器的影响。因为对 error 的调用是 在一个 if 块内的最后一件事情,并且使用了编译时常量非零错误代码,这意味着该分支在此 Linux 系统上被定为永远不会返回,编译器有权知道这一点并利用这一知识。 - mtraceur
1
所以我会编辑这个答案来提到调用约定,以及如何了解它们并让它们塑造我们的期望可以节省一些工作,但我想先仔细考虑如何将其融入答案,同时仍然引起像这样的风险的注意。 - mtraceur
显示剩余5条评论

9
@mgiuca从技术角度正确地回答了这个问题。实际上,将可执行程序反汇编为易于重新编译的汇编源代码并不是一项容易的任务。
为了增加讨论的内容,有一些可能值得探索的技术/工具,虽然它们在技术上很复杂。
  1. 静态/动态插装。这种技术涉及分析可执行文件格式,为特定目的插入/删除/替换特定的汇编指令,修复可执行文件中所有变量/函数的引用,并生成一个新的修改后的可执行文件。我知道的一些工具有:PINHijackerPEBILDynamoRIO。请注意,将这些工具配置到与它们设计的目的不同的目的可能会很棘手,并需要了解可执行文件格式和指令集。
  2. 完整的可执行文件反编译。这种技术试图从可执行文件中重构出完整的汇编源代码。你可能想看一下在线反汇编器,它试图完成这项工作。但你仍然会失去有关不同源模块以及可能的函数/变量名称的信息。
  3. 可重定向反编译。这种技术试图从可执行文件中提取更多信息,查看编译器指纹(即已知编译器生成的代码模式)和其他确定性内容。主要目标是从可执行文件中重构高级源代码,如C源代码。这有时可以恢复有关函数/变量名称的信息。请注意,使用-g编译源代码通常会提供更好的结果。你可能想试试可重定向反编译器
大部分来自漏洞评估和执行分析研究领域。这些是复杂的技术,通常工具不能立即使用。然而,在试图反向工程某些软件时,它们提供了宝贵的帮助。

7
改变二进制程序集中的代码通常有三种方法:
  • 如果只是一些微不足道的事情,比如一个常量,那么你可以使用十六进制编辑器更改其位置。假设你能找到它。
  • 如果需要修改代码,请利用LD_PRELOAD来覆盖程序中的某个函数。但如果该函数不在函数表中,则无法实现。
  • 将要修复的函数的代码修改为直接跳转到通过LD_PRELOAD加载的函数,然后再跳回到相同的位置(这是上述两种方法的组合)
当然,如果程序集进行任何形式的自我完整性检查,只有第二种方法才能奏效。
注:如果不明显的话,对二进制程序集进行操作是非常高级的开发人员工作,如果您没有特定的问题,那么在这里询问可能会很困难。

2
我的“汇编器/反汇编器”是我知道唯一围绕一个原则设计的系统,即无论反汇编结果如何,它都必须重新组装成完全相同的二进制文件。

https://github.com/albertvanderhorst/ciasdis

给出了两个 ELF 可执行文件的示例,附有它们的反汇编和重新组装过程。最初的设计旨在能够修改引导系统,包括代码、解释代码、数据和图形字符,具有从实模式到保护模式的平滑转换等优点(它成功地实现了这一点)。这些示例还演示了如何从可执行文件中提取文本,并用于标签。Debian 软件包适用于 Intel Pentium,但也提供了 Dec Alpha、6809、8086 等插件。
反汇编的质量取决于你投入多少精力。例如,如果你甚至没有提供它是一个 ELF 文件的信息,那么反汇编结果将只是单个字节,重新组装则很容易。在示例中,我使用了一个脚本来提取标签,生成了一个真正可用的可逆向工程程序,可以进行修改。你可以插入或删除某些内容,自动生成的符号标签将被重新计算。使用提供的工具,标签会在所有跳转结束的地方生成,然后用于这些跳转。这意味着在大多数情况下,你可以插入一条指令并重新组装修改后的源代码。
对于二进制 blob,不做任何假设,但当然,Intel 反汇编对于 Dec Alpha 二进制文件几乎没有用处。

2

miasm

https://github.com/cea-sec/miasm

这似乎是最有前途的具体解决方案。根据项目描述,该库可以:

  • 使用Elfesteem打开/修改/生成PE / ELF 32 / 64 LE / BE
  • 汇编/反汇编X86 / ARM / MIPS / SH4 / MSP430

因此,它基本上应该:

  • 将ELF解析为内部表示(反汇编)
  • 修改您想要的内容
  • 生成新的ELF(汇编)

我认为它不会生成文本反汇编表示,您可能需要遍历Python数据结构。

TODO找到使用库完成所有这些操作的最小示例。一个很好的起点似乎是example/disasm/full.py,它解析给定的ELF文件。关键的顶层结构是Container,它使用Container.from_stream读取ELF文件。TODO如何重新组装?这篇文章似乎可以做到:http://www.miasm.re/blog/2016/03/24/re150_rebuild.html

这个问题问是否有其他类似的库:https://reverseengineering.stackexchange.com/questions/1843/what-are-the-available-libraries-to-statically-modify-elf-executables

相关问题:

我认为这个问题不能自动化解决

我认为一般问题并不能完全自动化解决,一般解决方案基本上等同于“如何逆向工程”二进制文件。

为了以有意义的方式插入或删除字节,我们必须确保所有可能的跳转都跳转到相同的位置。

在正式术语中,我们需要提取二进制文件的控制流程图。

然而,对于间接分支(例如:https://en.wikipedia.org/wiki/Indirect_branch),很难确定该图形,参见:间接跳转目的地计算

0

你可能会对以下内容感兴趣:

  • 二进制仪器化 - 更改现有代码

如果感兴趣,请查看:Pin、Valgrind(或正在进行此类项目的NaCl - Google的本地客户端,也许是QEmu)。


0

您可以在 ptrace 的监督下(也就是像 gdb 这样的调试器)运行可执行文件,以此方式控制执行过程,而不需要修改实际文件。当然,这需要像查找要影响的特定指令在可执行文件中的位置等通常的编辑技能。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接