打造史上最小可执行 ELF 文件(45 字节,可打印字符串)

前言

本文从减少可执行文件大小的角度分析了 ELF 文件,期间通过经典的 Hello World 实例逐步演示如何通过各种常用工具来分析 ELF 文件,并逐步精简代码。

为了能够尽量减少可执行文件的大小,我们必须了解可执行文件的格式,以及链接生成可执行文件时的后台细节(即最终到底有哪些内容被链接到了目标代码中)。通过选择合适的可执行文件格式并剔除对可执行文件的最终运行没有影响的内容,就可以实现目标代码的裁减。因此,通过探索减少可执行文件大小的方法,就相当于实践性地去探索了可执行文件的格式以及链接过程的细节。

当然,算法的优化和编程语言的选择可能对目标文件的大小有很大的影响,在本文最后我们会跟参考资料 [1] 的作者那样去探求一个打印 Hello World 的可执行文件能够小到什么样的地步。

可执行文件格式的选取

可执行文件格式的选择要满足的一个基本条件是:目标系统支持该可执行文件格式,资料 [2] 分析和比较了 UNIX 平台下的三种可执行文件格式,这三种格式实际上代表着可执行文件的一个发展过程:

  • a.out 文件格式非常紧凑,只包含了程序运行所必须的信息(文本、数据、 BSS),而且每个 section 的顺序是固定的。

  • coff 文件格式虽然引入了一个节区表以支持更多节区信息,从而提高了可扩展性,但是这种文件格式的重定位在链接时就已经完成,因此不支持动态链接(不过扩展的 coff 支持)。

  • elf 文件格式不仅动态链接,而且有很好的扩展性。它可以描述可重定位文件、可执行文件和可共享文件(动态链接库)三类文件。

下面来看看 ELF 文件的结构图:

  1. 文件头部(ELF Header)
  2. 程序头部表(Program Header Table)
  3. 节区1(Section1)
  4. 节区2(Section2)
  5. 节区3(Section3)
  6. ...
  7. 节区头部(Section Header Table)

无论是文件头部、程序头部表、节区头部表还是各个节区,都是通过特定的结构体 (struct)描述的,这些结构在elf.h文件中定义。文件头部用于描述整个文件的类型、大小、运行平台、程序入口、程序头部表和节区头部表等信息。例如,我们可以通过文件头部查看该ELF` 文件的类型。

  1. $ cat hello.c #典型的hello, world程序
  2. #include <stdio.h>
  3. int main(void)
  4. {
  5. printf("hello, world!\n");
  6. return 0;
  7. }
  8. $ gcc -c hello.c #编译,产生可重定向的目标代码
  9. $ readelf -h hello.o | grep Type #通过readelf查看文件头部找出该类型
  10. Type: REL (Relocatable file)
  11. $ gcc -o hello hello.o #生成可执行文件
  12. $ readelf -h hello | grep Type
  13. Type: EXEC (Executable file)
  14. $ gcc -fpic -shared -Wl,-soname,libhello.so.0 -o libhello.so.0.0 hello.o #生成共享库
  15. $ readelf -h libhello.so.0.0 | grep Type
  16. Type: DYN (Shared object file)

那节区头部表(将简称节区表)和程序头部表有什么用呢?实际上前者只对可重定向文件有用,而后者只对可执行文件和可共享文件有用。

节区表是用来描述各节区的,包括各节区的名字、大小、类型、虚拟内存中的位置、相对文件头的位置等,这样所有节区都通过节区表给描述了,这样连接器就可以根据文件头部表和节区表的描述信息对各种输入的可重定位文件进行合适的链接,包括节区的合并与重组、符号的重定位(确认符号在虚拟内存中的地址)等,把各个可重定向输入文件链接成一个可执行文件(或者是可共享文件)。如果可执行文件中使用了动态连接库,那么将包含一些用于动态符号链接的节区。我们可以通过 readelf -S (或 objdump -h)查看节区表信息。

  1. $ readelf -S hello #可执行文件、可共享库、可重定位文件默认都生成有节区表
  2. ...
  3. Section Headers:
  4. [Nr] Name Type Addr Off Size ES Flg Lk Inf Al
  5. [ 0] NULL 00000000 000000 000000 00 0 0 0
  6. [ 1] .interp PROGBITS 08048114 000114 000013 00 A 0 0 1
  7. [ 2] .note.ABI-tag NOTE 08048128 000128 000020 00 A 0 0 4
  8. [ 3] .hash HASH 08048148 000148 000028 04 A 5 0 4
  9. ...
  10. [ 7] .gnu.version VERSYM 0804822a 00022a 00000a 02 A 5 0 2
  11. ...
  12. [11] .init PROGBITS 08048274 000274 000030 00 AX 0 0 4
  13. ...
  14. [13] .text PROGBITS 080482f0 0002f0 000148 00 AX 0 0 16
  15. [14] .fini PROGBITS 08048438 000438 00001c 00 AX 0 0 4
  16. ...

三种类型文件的节区(各个常见节区的作用请参考资料 [11])可能不一样,但是有几个节区,例如 .text.data.bss 是必须的,特别是 .text,因为这个节区包含了代码。如果一个程序使用了动态链接库(引用了动态连接库中的某个函数),那么需要 .interp 节区以便告知系统使用什么动态连接器程序来进行动态符号链接,进行某些符号地址的重定位。通常,.rel.text 节区只有可重定向文件有,用于链接时对代码区进行重定向,而 .hash.plt.got 等节区则只有可执行文件(或可共享库)有,这些节区对程序的运行特别重要。还有一些节区,可能仅仅是用于注释,比如 .comment,这些对程序的运行似乎没有影响,是可有可无的,不过有些节区虽然对程序的运行没有用处,但是却可以用来辅助对程序进行调试或者对程序运行效率有影响。

虽然三类文件都必须包含某些节区,但是节区表对可重定位文件来说才是必须的,而程序的执行却不需要节区表,只需要程序头部表以便知道如何加载和执行文件。不过如果需要对可执行文件或者动态连接库进行调试,那么节区表却是必要的,否则调试器将不知道如何工作。下面来介绍程序头部表,它可通过 readelf -l(或 objdump -p)查看。

  1. $ readelf -l hello.o #对于可重定向文件,gcc没有产生程序头部,因为它对可重定向文件没用
  2. There are no program headers in this file.
  3. $ readelf -l hello #而可执行文件和可共享文件都有程序头部
  4. ...
  5. Program Headers:
  6. Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
  7. PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
  8. INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
  9. [Requesting program interpreter: /lib/ld-linux.so.2]
  10. LOAD 0x000000 0x08048000 0x08048000 0x00470 0x00470 R E 0x1000
  11. LOAD 0x000470 0x08049470 0x08049470 0x0010c 0x00110 RW 0x1000
  12. DYNAMIC 0x000484 0x08049484 0x08049484 0x000d0 0x000d0 RW 0x4
  13. NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
  14. GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
  15. Section to Segment mapping:
  16. Segment Sections...
  17. 00
  18. 01 .interp
  19. 02 .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
  20. 03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
  21. 04 .dynamic
  22. 05 .note.ABI-tag
  23. 06
  24. $ readelf -l libhello.so.0.0 #节区和上面类似,这里省略

从上面可看出程序头部表描述了一些段(Segment),这些段对应着一个或者多个节区,上面的 readelf -l 很好地显示了各个段与节区的映射。这些段描述了段的名字、类型、大小、第一个字节在文件中的位置、将占用的虚拟内存大小、在虚拟内存中的位置等。这样系统程序解释器将知道如何把可执行文件加载到内存中以及进行动态链接等动作。

该可执行文件包含 7 个段,PHDR 指程序头部,INTERP 正好对应 .interp 节区,两个 LOAD 段包含程序的代码和数据部分,分别包含有 .text.data.bss 节区,DYNAMIC 段包含 .daynamic,这个节区可能包含动态连接库的搜索路径、可重定位表的地址等信息,它们用于动态连接器。 NOTEGNU_STACK 段貌似作用不大,只是保存了一些辅助信息。因此,对于一个不使用动态连接库的程序来说,可能只包含 LOAD 段,如果一个程序没有数据,那么只有一个 LOAD 段就可以了。

总结一下,Linux 虽然支持很多种可执行文件格式,但是目前 ELF 较通用,所以选择 ELF 作为我们的讨论对象。通过上面对 ELF 文件分析发现一个可执行的文件可能包含一些对它的运行没用的信息,比如节区表、一些用于调试、注释的节区。如果能够删除这些信息就可以减少可执行文件的大小,而且不会影响可执行文件的正常运行。

链接优化

从上面的讨论中已经接触了动态连接库。 ELF 中引入动态连接库后极大地方便了公共函数的共享,节约了磁盘和内存空间,因为不再需要把那些公共函数的代码链接到可执行文件,这将减少了可执行文件的大小。

与此同时,静态链接可能会引入一些对代码的运行可能并非必须的内容。你可以从《GCC 编译的背后(第二部分:汇编和链接)》 了解到 GCC 链接的细节。从那篇 Blog 中似乎可以得出这样的结论:仅仅从是否影响一个 C 语言程序运行的角度上说,GCC 默认链接到可执行文件的几个可重定位文件 (crt1.orti.ocrtbegin.ocrtend.ocrtn.o)并不是必须的,不过值得注意的是,如果没有链接那些文件但在程序末尾使用了 return 语句,main 函数将无法返回,因此需要替换为 _exit 调用;另外,既然程序在进入 main 之前有一个入口,那么 main 入口就不是必须的。因此,如果不采用默认链接也可以减少可执行文件的大小。

可执行文件“减肥”实例(从6442到708字节)

这里主要是根据上面两点来介绍如何减少一个可执行文件的大小。以 Hello World 为例。

首先来看看默认编译产生的 Hello World 的可执行文件大小。

系统默认编译

代码同上,下面是一组演示,

  1. $ uname -r #先查看内核版本和gcc版本,以便和你的结果比较
  2. 2.6.22-14-generic
  3. $ gcc --version
  4. gcc (GCC) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)
  5. ...
  6. $ gcc -o hello hello.c #默认编译
  7. $ wc -c hello #产生一个大小为6442字节的可执行文件
  8. 6442 hello

不采用默认编译

可以考虑编辑时就把 return 0 替换成 _exit(0) 并包含定义该函数的 unistd.h 头文件。下面是从《GCC 编译的背后(第二部分:汇编和链接)》总结出的 Makefile 文件。

  1. #file: Makefile
  2. #functin: for not linking a program as the gcc do by default
  3. #author: falcon<zhangjinw@gmail.com>
  4. #update: 2008-02-23
  5. MAIN = hello
  6. SOURCE =
  7. OBJS = hello.o
  8. TARGET = hello
  9. CC = gcc-3.4 -m32
  10. LD = ld -m elf_i386
  11. CFLAGSs += -S
  12. CFLAGSc += -c
  13. LDFLAGS += -dynamic-linker /lib/ld-linux.so.2 -L /usr/lib/ -L /lib -lc
  14. RM = rm -f
  15. SEDc = sed -i -e '/\#include[ "<]*unistd.h[ ">]*/d;' \
  16. -i -e '1i \#include <unistd.h>' \
  17. -i -e 's/return 0;/_exit(0);/'
  18. SEDs = sed -i -e 's/main/_start/g'
  19. all: $(TARGET)
  20. $(TARGET):
  21. @$(SEDc) $(MAIN).c
  22. @$(CC) $(CFLAGSs) $(MAIN).c
  23. @$(SEDs) $(MAIN).s
  24. @$(CC) $(CFLAGSc) $(MAIN).s $(SOURCE)
  25. @$(LD) $(LDFLAGS) -o $@ $(OBJS)
  26. clean:
  27. @$(RM) $(MAIN).s $(OBJS) $(TARGET)

把上面的代码复制到一个Makefile文件中,并利用它来编译hello.c。

  1. $ make #编译
  2. $ ./hello #这个也是可以正常工作的
  3. Hello World
  4. $ wc -c hello #但是大小减少了4382个字节,减少了将近 70%
  5. 2060 hello
  6. $ echo "6442-2060" | bc
  7. 4382
  8. $ echo "(6442-2060)/6442" | bc -l
  9. .68022353306426575597

对于一个比较小的程序,能够减少将近 70% “没用的”代码。

删除对程序运行没有影响的节区

使用上述 Makefile 来编译程序,不链接那些对程序运行没有多大影响的文件,实际上也相当于删除了一些“没用”的节区,可以通过下列演示看出这个实质。

  1. $ make clean
  2. $ make
  3. $ readelf -l hello | grep "0[0-9]\ \ "
  4. 00
  5. 01 .interp
  6. 02 .interp .hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.plt .plt .text .rodata
  7. 03 .dynamic .got.plt
  8. 04 .dynamic
  9. 05
  10. $ make clean
  11. $ gcc -o hello hello.c
  12. $ readelf -l hello | grep "0[0-9]\ \ "
  13. 00
  14. 01 .interp
  15. 02 .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r
  16. .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
  17. 03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
  18. 04 .dynamic
  19. 05 .note.ABI-tag
  20. 06

通过比较发现使用自定义的 Makefile 文件,少了这么多节区: .bss .ctors .data .dtors .eh_frame .fini .gnu.hash .got .init .jcr .note.ABI-tag .rel.dyn
再看看还有哪些节区可以删除呢?通过之前的分析发现有些节区是必须的,那 .hash?.gnu.version? 呢,通过 strip -R(或 objcop -R)删除这些节区试试。

  1. $ wc -c hello #查看大小,以便比较
  2. 2060
  3. $ time ./hello #我们比较一下一些节区对执行时间可能存在的影响
  4. Hello World
  5. real 0m0.001s
  6. user 0m0.000s
  7. sys 0m0.000s
  8. $ strip -R .hash hello #删除.hash节区
  9. $ wc -c hello
  10. 1448 hello
  11. $ echo "2060-1448" | bc #减少了612字节
  12. 612
  13. $ time ./hello #发现执行时间长了一些(实际上也可能是进程调度的问题)
  14. Hello World
  15. real 0m0.006s
  16. user 0m0.000s
  17. sys 0m0.000s
  18. $ strip -R .gnu.version hello #删除.gnu.version还是可以工作
  19. $ wc -c hello
  20. 1396 hello
  21. $ echo "1448-1396" | bc #又减少了52字节
  22. 52
  23. $ time ./hello
  24. Hello World
  25. real 0m0.130s
  26. user 0m0.004s
  27. sys 0m0.000s
  28. $ strip -R .gnu.version_r hello #删除.gnu.version_r就不工作了
  29. $ time ./hello
  30. ./hello: error while loading shared libraries: ./hello: unsupported version 0 of Verneed record

通过删除各个节区可以查看哪些节区对程序来说是必须的,不过有些节区虽然并不影响程序的运行却可能会影响程序的执行效率,这个可以上面的运行时间看出个大概。
通过删除两个“没用”的节区,我们又减少了 52+612,即 664 字节。

删除可执行文件的节区表

用普通的工具没有办法删除节区表,但是参考资料[1]的作者已经写了这样一个工具。你可以从这里下载到那个工具,它是该作者写的一序列工具 ELFkickers 中的一个。

下载并编译(:1.0 之前的版本才支持 32 位和正常编译,新版本在代码中明确限定了数据结构为 Elf64):

  1. $ git clone https://github.com/BR903/ELFkickers
  2. $ cd ELFkickers/sstrip/
  3. $ git checkout f0622afa # 检出 1.0 版
  4. $ make

然后复制到 /usr/bin 下,下面用它来删除节区表。

  1. $ sstrip hello #删除ELF可执行文件的节区表
  2. $ ./hello #还是可以正常运行,说明节区表对可执行文件的运行没有任何影响
  3. Hello World
  4. $ wc -c hello #大小只剩下708个字节了
  5. 708 hello
  6. $ echo "1396-708" | bc #又减少了688个字节。
  7. 688

通过删除节区表又把可执行文件减少了 688 字节。现在回头看看相对于 gcc 默认产生的可执行文件,通过删除一些节区和节区表到底减少了多少字节?减幅达到了多少?

  1. $ echo "6442-708" | bc #
  2. 5734
  3. $ echo "(6442-708)/6442" | bc -l
  4. .89009624340266997826

减少了 5734 多字节,减幅将近 90%,这说明:对于一个简短的 hello.c 程序而言,gcc 引入了将近 90% 的对程序运行没有影响的数据。虽然通过删除节区和节区表,使得最终的文件只有 708 字节,但是打印一个 Hello World 真的需要这么多字节么?事实上未必,因为:

  • 打印一段 Hello World 字符串,我们无须调用 printf,也就无须包含动态连接库,因此 .interp.dynamic 等节区又可以去掉。为什么?我们可以直接使用系统调用 `(sys_write)来打印字符串。
  • 另外,我们无须把 Hello World 字符串存放到可执行文件中?而是让用户把它当作参数输入。

下面,继续进行可执行文件的“减肥”。

用汇编语言来重写”Hello World”(76字节)

采用默认编译

先来看看 gcc 默认产生的汇编代码情况。通过 gcc-S 选项可得到汇编代码。

  1. $ cat hello.c #这个是使用_exit和printf函数的版本
  2. #include <stdio.h> /* printf */
  3. #include <unistd.h> /* _exit */
  4. int main()
  5. {
  6. printf("Hello World\n");
  7. _exit(0);
  8. }
  9. $ gcc -S hello.c #生成汇编
  10. $ cat hello.s #这里是汇编代码
  11. .file "hello.c"
  12. .section .rodata
  13. .LC0:
  14. .string "Hello World"
  15. .text
  16. .globl main
  17. .type main, @function
  18. main:
  19. leal 4(%esp), %ecx
  20. andl $-16, %esp
  21. pushl -4(%ecx)
  22. pushl %ebp
  23. movl %esp, %ebp
  24. pushl %ecx
  25. subl $4, %esp
  26. movl $.LC0, (%esp)
  27. call puts
  28. movl $0, (%esp)
  29. call _exit
  30. .size main, .-main
  31. .ident "GCC: (GNU) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)"
  32. .section .note.GNU-stack,"",@progbits
  33. $ gcc -o hello hello.s #看看默认产生的代码大小
  34. $ wc -c hello
  35. 6523 hello

删除掉汇编代码中无关紧要内容

现在对汇编代码 hello.s 进行简单的处理得到,

  1. .LC0:
  2. .string "Hello World"
  3. .text
  4. .globl main
  5. .type main, @function
  6. main:
  7. leal 4(%esp), %ecx
  8. andl $-16, %esp
  9. pushl -4(%ecx)
  10. pushl %ebp
  11. movl %esp, %ebp
  12. pushl %ecx
  13. subl $4, %esp
  14. movl $.LC0, (%esp)
  15. call puts
  16. movl $0, (%esp)
  17. call _exit

再编译看看,

  1. $ gcc -o hello.o hello.s
  2. $ wc -c hello
  3. 6443 hello
  4. $ echo "6523-6443" | bc #仅仅减少了80个字节
  5. 80

不默认编译并删除掉无关节区和节区表

如果不采用默认编译呢并且删除掉对程序运行没有影响的节区和节区表呢?

  1. $ sed -i -e "s/main/_start/g" hello.s #因为没有初始化,所以得直接进入代码,替换main为_start
  2. $ as --32 -o hello.o hello.s
  3. $ ld -melf_i386 -o hello hello.o --dynamic-linker /lib/ld-linux.so.2 -L /usr/lib -lc
  4. $ ./hello
  5. hello world!
  6. $ wc -c hello
  7. 1812 hello
  8. $ echo "6443-1812" | bc -l #和之前的实验类似,也减少了4k左右
  9. 4631
  10. $ readelf -l hello | grep "\ [0-9][0-9]\ "
  11. 00
  12. 01 .interp
  13. 02 .interp .hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.plt .plt .text
  14. 03 .dynamic .got.plt
  15. 04 .dynamic
  16. $ strip -R .hash hello
  17. $ strip -R .gnu.version hello
  18. $ wc -c hello
  19. 1200 hello
  20. $ sstrip hello
  21. $ wc -c hello #这个结果比之前的708(在删除所有垃圾信息以后)个字节少了708-676,即32个字节
  22. 676 hello
  23. $ ./hello
  24. Hello World

容易发现这 32 字节可能跟节区 .rodata 有关系,因为刚才在链接完以后查看节区信息时,并没有 .rodata 节区。

用系统调用取代库函数

前面提到,实际上还可以不用动态连接库中的 printf 函数,也不用直接调用 _exit,而是在汇编里头使用系统调用,这样就可以去掉和动态连接库关联的内容。如果想了解如何在汇编中使用系统调用,请参考资料 [9]。使用系统调用重写以后得到如下代码,

  1. .LC0:
  2. .string "Hello World\xa\x0"
  3. .text
  4. .global _start
  5. _start:
  6. xorl %eax, %eax
  7. movb $4, %al #eax = 4, sys_write(fd, addr, len)
  8. xorl %ebx, %ebx
  9. incl %ebx #ebx = 1, standard output
  10. movl $.LC0, %ecx #ecx = $.LC0, the address of string
  11. xorl %edx, %edx
  12. movb $13, %dl #edx = 13, the length of .string
  13. int $0x80
  14. xorl %eax, %eax
  15. movl %eax, %ebx #ebx = 0
  16. incl %eax #eax = 1, sys_exit
  17. int $0x80

现在编译就不再需要动态链接器 ld-linux.so 了,也不再需要链接任何库。

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 -o hello hello.o
  3. $ readelf -l hello
  4. Elf file type is EXEC (Executable file)
  5. Entry point 0x8048062
  6. There are 1 program headers, starting at offset 52
  7. Program Headers:
  8. Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
  9. LOAD 0x000000 0x08048000 0x08048000 0x0007b 0x0007b R E 0x1000
  10. Section to Segment mapping:
  11. Segment Sections...
  12. 00 .text
  13. $ sstrip hello
  14. $ ./hello #完全可以正常工作
  15. Hello World
  16. $ wc -c hello
  17. 123 hello
  18. $ echo "676-123" | bc #相对于之前,已经只需要123个字节了,又减少了553个字节
  19. 553

可以看到效果很明显,只剩下一个 LOAD 段,它对应 .text 节区。

把字符串作为参数输入

不过是否还有办法呢?把 Hello World 作为参数输入,而不是硬编码在文件中。所以如果处理参数的代码少于 Hello World 字符串的长度,那么就可以达到减少目标文件大小的目的。

先来看一个能够打印程序参数的汇编语言程序,它来自参考资料[9]

  1. .text
  2. .globl _start
  3. _start:
  4. popl %ecx # argc
  5. vnext:
  6. popl %ecx # argv
  7. test %ecx, %ecx # 空指针表明结束
  8. jz exit
  9. movl %ecx, %ebx
  10. xorl %edx, %edx
  11. strlen:
  12. movb (%ebx), %al
  13. inc %edx
  14. inc %ebx
  15. test %al, %al
  16. jnz strlen
  17. movb $10, -1(%ebx)
  18. movl $4, %eax # 系统调用号(sys_write)
  19. movl $1, %ebx # 文件描述符(stdout)
  20. int $0x80
  21. jmp vnext
  22. exit:
  23. movl $1,%eax # 系统调用号(sys_exit)
  24. xorl %ebx, %ebx # 退出代码
  25. int $0x80
  26. ret

编译看看效果,

  1. $ as --32 -o args.o args.s
  2. $ ld -melf_i386 -o args args.o
  3. $ ./args "Hello World" #能够打印输入的字符串,不错
  4. ./args
  5. Hello World
  6. $ sstrip args
  7. $ wc -c args #处理以后只剩下130字节
  8. 130 args

可以看到,这个程序可以接收用户输入的参数并打印出来,不过得到的可执行文件为 130 字节,比之前的 123 个字节还多了 7 个字节,看看还有改进么?分析上面的代码后,发现,原来的代码有些地方可能进行优化,优化后得到如下代码。

  1. .global _start
  2. _start:
  3. popl %ecx #弹出argc
  4. vnext:
  5. popl %ecx #弹出argv[0]的地址
  6. test %ecx, %ecx #空指针表明结束
  7. jz exit
  8. movl %ecx, %ebx #复制字符串地址到ebx寄存器
  9. xorl %edx, %edx #把字符串长度清零
  10. strlen: #求输入字符串的长度
  11. movb (%ebx), %al #复制字符到al,以便判断是否为字符串结束符\0
  12. inc %edx #edx存放每个当前字符串的长度
  13. inc %ebx #ebx存放每个当前字符的地址
  14. test %al, %al #判断字符串是否结束,即是否遇到\0
  15. jnz strlen
  16. movb $10, -1(%ebx) #在字符串末尾插入一个换行符\0xa
  17. xorl %eax, %eax
  18. movb $4, %al #eax = 4, sys_write(fd, addr, len)
  19. xorl %ebx, %ebx
  20. incl %ebx #ebx = 1, standard output
  21. int $0x80
  22. jmp vnext
  23. exit:
  24. xorl %eax, %eax
  25. movl %eax, %ebx #ebx = 0
  26. incl %eax #eax = 1, sys_exit
  27. int $0x80

再测试(记得先重新汇编、链接并删除没用的节区和节区表)。

  1. $ wc -c hello
  2. 124 hello

现在只有 124 个字节,不过还是比 123 个字节多一个,还有什么优化的办法么?

先来看看目前 hello 的功能,感觉不太符合要求,因为只需要打印 Hello World,所以不必处理所有的参数,仅仅需要接收并打印一个参数就可以。这样的话,把 jmp vnext(2 字节)这个循环去掉,然后在第一个 pop %ecx 语句之前加一个 pop %ecx(1 字节)语句就可以。

  1. .global _start
  2. _start:
  3. popl %ecx
  4. popl %ecx #弹出argc[0]的地址
  5. popl %ecx #弹出argv[1]的地址
  6. test %ecx, %ecx
  7. jz exit
  8. movl %ecx, %ebx
  9. xorl %edx, %edx
  10. strlen:
  11. movb (%ebx), %al
  12. inc %edx
  13. inc %ebx
  14. test %al, %al
  15. jnz strlen
  16. movb $10, -1(%ebx)
  17. xorl %eax, %eax
  18. movb $4, %al
  19. xorl %ebx, %ebx
  20. incl %ebx
  21. int $0x80
  22. exit:
  23. xorl %eax, %eax
  24. movl %eax, %ebx
  25. incl %eax
  26. int $0x80

现在刚好 123 字节,和原来那个代码大小一样,不过仔细分析,还是有减少代码的余地:因为在这个代码中,用了一段额外的代码计算字符串的长度,实际上如果仅仅需要打印 Hello World,那么字符串的长度是固定的,即 12 。所以这段代码可去掉,与此同时测试字符串是否为空也就没有必要(不过可能影响代码健壮性!),当然,为了能够在打印字符串后就换行,在串的末尾需要加一个回车($10)并且设置字符串的长度为 12+1,即 13,

  1. .global _start
  2. _start:
  3. popl %ecx
  4. popl %ecx
  5. popl %ecx
  6. movb $10,12(%ecx) #在Hello World的结尾加一个换行符
  7. xorl %edx, %edx
  8. movb $13, %dl
  9. xorl %eax, %eax
  10. movb $4, %al
  11. xorl %ebx, %ebx
  12. incl %ebx
  13. int $0x80
  14. xorl %eax, %eax
  15. movl %eax, %ebx
  16. incl %eax
  17. int $0x80

再看看效果,

  1. $ wc -c hello
  2. 111 hello

寄存器赋值重用

现在只剩下 111 字节,比刚才少了 12 字节。貌似到了极限?还有措施么?

还有,仔细分析发现:系统调用 sys_exitsys_write 都用到了 eaxebx 寄存器,它们之间刚好有那么一点巧合:

  • sys_exit 调用时,eax 需要设置为 1,ebx 需要设置为 0 。
  • sys_write 调用时,ebx 刚好是 1 。

因此,如果在 sys_exit 调用之前,先把 ebx 复制到 eax 中,再对 ebx 减一,则可减少两个字节。

不过,因为标准输入、标准输出和标准错误都指向终端,如果往标准输入写入一些东西,它还是会输出到标准输出上,所以在上述代码中如果在 sys_write 之前 ebx 设置为 0,那么也可正常往屏幕上打印 Hello World,这样的话,sys_exit 调用前就没必要修改 ebx,而仅需把 eax 设置为 1,这样就可减少 3 个字节。

  1. .global _start
  2. _start:
  3. popl %ecx
  4. popl %ecx
  5. popl %ecx
  6. movb $10,12(%ecx)
  7. xorl %edx, %edx
  8. movb $13, %dl
  9. xorl %eax, %eax
  10. movb $4, %al
  11. xorl %ebx, %ebx
  12. int $0x80
  13. xorl %eax, %eax
  14. incl %eax
  15. int $0x80

看看效果,

  1. $ wc -c hello
  2. 108 hello

现在看一下纯粹的指令还有多少?

  1. $ readelf -h hello | grep Size
  2. Size of this header: 52 (bytes)
  3. Size of program headers: 32 (bytes)
  4. Size of section headers: 0 (bytes)
  5. $ echo "108-52-32" | bc
  6. 24

通过文件名传递参数

对于标准的 main 函数的两个参数,文件名实际上作为第二个参数(数组)的第一个元素传入,如果仅仅是为了打印一个字符串,那么可以打印文件名本身。例如,要打印 Hello World,可以把文件名命名为 Hello World 即可。

这样地话,代码中就可以删除掉一条 popl 指令,减少 1 个字节,变成 107 个字节。

  1. .global _start
  2. _start:
  3. popl %ecx
  4. popl %ecx
  5. movb $10,12(%ecx)
  6. xorl %edx, %edx
  7. movb $13, %dl
  8. xorl %eax, %eax
  9. movb $4, %al
  10. xorl %ebx, %ebx
  11. int $0x80
  12. xorl %eax, %eax
  13. incl %eax
  14. int $0x80

看看效果,

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 -o hello hello.o
  3. $ sstrip hello
  4. $ wc -c hello
  5. 107
  6. $ mv hello "Hello World"
  7. $ export PATH=./:$PATH
  8. $ Hello\ World
  9. Hello World

删除非必要指令

在测试中发现,edxeaxebx 的高位即使不初始化,也常为 0,如果不考虑健壮性(仅这里实验用,实际使用中必须考虑健壮性),几条 xorl 指令可以移除掉。

另外,如果只是为了演示打印字符串,完全可以不用打印换行符,这样下来,代码可以综合优化成如下几条指令:

  1. .global _start
  2. _start:
  3. popl %ecx # argc
  4. popl %ecx # argv[0]
  5. movb $5, %dl # 设置字符串长度
  6. movb $4, %al # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
  7. int $0x80
  8. movb $1, %al
  9. int $0x80

看看效果:

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 -o hello hello.o
  3. $ sstrip hello
  4. $ wc -c hello
  5. 96

合并代码段、程序头和文件头(52字节)

把代码段移入文件头

纯粹的指令只有 96-84=12 个字节了,还有办法再减少目标文件的大小么?如果看了参考资料 [1],看样子你又要蠢蠢欲动了:这 12 个字节是否可以插入到文件头部或程序头部?如果可以那是否意味着还可减少可执行文件的大小呢?现在来比较一下这三部分的十六进制内容。

  1. $ hexdump -C hello -n 52 #文件头(52bytes)
  2. 00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
  3. 00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00 |........T...4...|
  4. 00000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
  5. 00000030 00 00 00 00 |....|
  6. 00000034
  7. $ hexdump -C hello -s 52 -n 32 #程序头(32bytes)
  8. 00000034 01 00 00 00 00 00 00 00 00 80 04 08 00 80 04 08 |................|
  9. 00000044 6c 00 00 00 6c 00 00 00 05 00 00 00 00 10 00 00 |l...l...........|
  10. 00000054
  11. $ hexdump -C hello -s 84 #实际代码部分(12bytes)
  12. 00000054 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |YY..........|
  13. 00000060

从上面结果发现 ELF 文件头部和程序头部还有好些空洞(0),是否可以把指令字节分散放入到那些空洞里或者是直接覆盖掉那些系统并不关心的内容?抑或是把代码压缩以后放入可执行文件中,并在其中实现一个解压缩算法?还可以是通过一些代码覆盖率测试工具(gcovprof)对你的代码进行优化?

在继续介绍之前,先来看一个 dd 工具,可以用来直接“编辑” ELF 文件,例如,

直接往指定位置写入 0xff

  1. $ hexdump -C hello -n 16 # 写入前,elf文件前16个字节
  2. 00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
  3. 00000010
  4. $ echo -ne "\xff" | dd of=hello bs=1 count=1 seek=15 conv=notrunc # 把最后一个字节0覆盖掉
  5. 1+0 records in
  6. 1+0 records out
  7. 1 byte (1 B) copied, 3.7349e-05 s, 26.8 kB/s
  8. $ hexdump -C hello -n 16 # 写入后果然被覆盖
  9. 00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 ff |.ELF............|
  10. 00000010
  • seek=15 表示指定写入位置为第 15 个(从第 0 个开始)
  • conv=notrunc 选项表示要保留写入位置之后的内容,默认情况下会截断。
  • bs=1 表示一次读/写 1 个
  • count=1 表示总共写 1 次

覆盖多个连续的值:

把第 12,13,14,15 连续 4 个字节全部赋值为 0xff

  1. $ echo -ne "\xff\xff\xff\xff" | dd of=hello bs=1 count=4 seek=12 conv=notrunc
  2. $ hexdump -C hello -n 16
  3. 00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 ff ff ff ff |.ELF............|
  4. 00000010

下面,通过往文件头指定位置写入 0xff 确认哪些部分对于可执行文件的执行是否有影响?这里是逐步测试后发现依然能够执行的情况:

  1. $ hexdump -C hello
  2. 00000000 7f 45 4c 46 ff ff ff ff ff ff ff ff ff ff ff ff |.ELF............|
  3. 00000010 02 00 03 00 ff ff ff ff 54 80 04 08 34 00 00 00 |........T...4...|
  4. 00000020 ff ff ff ff ff ff ff ff 34 00 20 00 01 00 ff ff |........4. .....|
  5. 00000030 ff ff ff ff 01 00 00 00 00 00 00 00 00 80 04 08 |................|
  6. 00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00 |....`...`.......|
  7. 00000050 00 10 00 00 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |....YY..........|
  8. 00000060

可以发现,文件头部分,有 30 个字节即使被篡改后,该可执行文件依然可以正常执行。这意味着,这 30 字节是可以写入其他代码指令字节的。而我们的实际代码指令只剩下 12 个,完全可以直接移到前 12 个 0xff 的位置,即从第 4 个到第 15 个。

而代码部分的起始位置,通过 readelf -h 命令可以看到:

  1. $ readelf -h hello | grep "Entry"
  2. Entry point address: 0x8048054

上面地址的最后两位 0x54=84 就是代码在文件中的偏移,也就是刚好从程序头之后开始的,也就是用文件头(52)+程序头(32)个字节开始的 12 字节覆盖到第 4 个字节开始的 12 字节内容即可。

上面的 dd 命令从 echo 命令获得输入,下面需要通过可执行文件本身获得输入,先把代码部分移过去:

  1. $ dd if=hello of=hello bs=1 skip=84 count=12 seek=4 conv=notrunc
  2. 12+0 records in
  3. 12+0 records out
  4. 12 bytes (12 B) copied, 4.9552e-05 s, 242 kB/s
  5. $ hexdump -C hello
  6. 00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
  7. 00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00 |........T...4...|
  8. 00000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
  9. 00000030 00 00 00 00 01 00 00 00 00 00 00 00 00 80 04 08 |................|
  10. 00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00 |....`...`.......|
  11. 00000050 00 10 00 00 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |....YY..........|
  12. 00000060

接着把代码部分截掉:

  1. $ dd if=hello of=hello bs=1 count=1 skip=84 seek=84
  2. 0+0 records in
  3. 0+0 records out
  4. 0 bytes (0 B) copied, 1.702e-05 s, 0.0 kB/s
  5. $ hexdump -C hello
  6. 00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
  7. 00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00 |........T...4...|
  8. 00000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
  9. 00000030 00 00 00 00 01 00 00 00 00 00 00 00 00 80 04 08 |................|
  10. 00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00 |....`...`.......|
  11. 00000050 00 10 00 00 |....|
  12. 00000054

这个时候还不能执行,因为代码在文件中的位置被移动了,相应地,文件头中的 Entry point address,即文件入口地址也需要被修改为 0x8048004

即需要把 0x54 所在的第 24 个字节修改为 0x04

  1. $ echo -ne "\x04" | dd of=hello bs=1 count=1 seek=24 conv=notrunc
  2. 1+0 records in
  3. 1+0 records out
  4. 1 byte (1 B) copied, 3.7044e-05 s, 27.0 kB/s
  5. $ hexdump -C hello
  6. 00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
  7. 00000010 02 00 03 00 01 00 00 00 04 80 04 08 34 00 00 00 |............4...|
  8. 00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00 |........4. ...(.|
  9. 00000030 05 00 02 00 01 00 00 00 00 00 00 00 00 80 04 08 |................|
  10. 00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00 |....`...`.......|
  11. 00000050 00 10 00 00

修改后就可以执行了。

把程序头移入文件头

程序头部分经过测试发现基本上都不能修改并且需要是连续的,程序头有 32 个字节,而文件头中连续的 0xff 可以被篡改的只有从第 46 个开始的 6 个了,另外,程序头刚好是 01 00 开头,而第 44,45 个刚好为 01 00,这样地话,这两个字节文件头可以跟程序头共享,这样地话,程序头就可以往文件头里头移动 8 个字节了。

  1. $ dd if=hello of=hello bs=1 skip=52 seek=44 count=32 conv=notrunc

再把最后 8 个没用的字节删除掉,保留 84-8=76 个字节:

  1. $ dd if=hello of=hello bs=1 skip=76 seek=76
  2. $ hexdump -C hello
  3. 00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
  4. 00000010 02 00 03 00 01 00 00 00 04 80 04 08 34 00 00 00 |............4...|
  5. 00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
  6. 00000030 00 00 00 00 00 80 04 08 00 80 04 08 60 00 00 00 |............`...|
  7. 00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
  8. 0000004c

另外,还需要把文件头中程序头的位置信息改为 44,即第 28 个字节,原来是 0x34,即 52 的位置。

  1. $ echo "obase=16;ibase=10;44" | bc # 先把44转换是16进制的0x2C
  2. 2C
  3. $ echo -ne "\x2C" | dd of=hello bs=1 count=1 seek=28 conv=notrunc # 修改文件头
  4. 1+0 records in
  5. 1+0 records out
  6. 1 byte (1 B) copied, 3.871e-05 s, 25.8 kB/s
  7. $ hexdump -C hello
  8. 00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
  9. 00000010 02 00 03 00 01 00 00 00 04 80 04 08 2c 00 00 00 |............,...|
  10. 00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
  11. 00000030 00 00 00 00 00 80 04 08 00 80 04 08 60 00 00 00 |............`...|
  12. 00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
  13. 0000004c

修改后即可执行了,目前只剩下 76 个字节:

  1. $ wc -c hello
  2. 76

在非连续的空间插入代码

另外,还有 12 个字节可以放代码,见 0xff 的地方:

  1. $ hexdump -C hello
  2. 00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
  3. 00000010 02 00 03 00 ff ff ff ff 04 80 04 08 2c 00 00 00 |............,...|
  4. 00000020 ff ff ff ff ff ff ff ff 34 00 20 00 01 00 00 00 |........4. .....|
  5. 00000030 00 00 00 00 00 80 04 08 00 80 04 08 60 00 00 00 |............`...|
  6. 00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
  7. 0000004c

不过因为空间不是连续的,需要用到跳转指令作为跳板利用不同的空间。

例如,如果要利用后面的 0xff 的空间,可以把第 14,15 位置的 cd 80 指令替换为一条跳转指令,比如跳转到第 20 个字节的位置,从跳转指令之后的 16 到 20 刚好 4 个字节。

然后可以参考 X86 指令编码表(也可以写成汇编生成可执行文件后用 hexdump 查看),可以把 jmp 指令编码为: 0xeb 0x04

  1. $ echo -ne "\xeb\x04" | dd of=hello bs=1 count=2 seek=14 conv=notrunc

然后把原来位置的 cd 80 移动到第 20 个字节开始的位置:

  1. $ echo -ne "\xcd\x80" | dd of=hello bs=1 count=2 seek=20 conv=notrunc

依然可以执行,类似地可以利用更多非连续的空间。

把程序头完全合入文件头

在阅读参考资料 [1]后,发现有更多深层次的探讨,通过分析 Linux 系统对 ELF 文件头部和程序头部的解析,可以更进一步合并程序头和文件头。

该资料能够把最简的 ELF 文件(简单返回一个数值)压缩到 45 个字节,真地是非常极端的努力,思路可以充分借鉴。在充分理解原文的基础上,我们进行更细致地梳理。

首先对 ELF 文件头部和程序头部做更彻底的理解,并具体到每一个字节的含义以及在 Linux 系统下的实际解析情况。

先来看看 readelf -a 的结果:

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 -o hello hello.o
  3. $ sstrip hello
  4. $ readelf -a hello
  5. ELF Header:
  6. Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  7. Class: ELF32
  8. Data: 2's complement, little endian
  9. Version: 1 (current)
  10. OS/ABI: UNIX - System V
  11. ABI Version: 0
  12. Type: EXEC (Executable file)
  13. Machine: Intel 80386
  14. Version: 0x1
  15. Entry point address: 0x8048054
  16. Start of program headers: 52 (bytes into file)
  17. Start of section headers: 0 (bytes into file)
  18. Flags: 0x0
  19. Size of this header: 52 (bytes)
  20. Size of program headers: 32 (bytes)
  21. Number of program headers: 1
  22. Size of section headers: 0 (bytes)
  23. Number of section headers: 0
  24. Section header string table index: 0
  25. There are no sections in this file.
  26. There are no sections to group in this file.
  27. Program Headers:
  28. Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
  29. LOAD 0x000000 0x08048000 0x08048000 0x00060 0x00060 R E 0x1000

然后结合 /usr/include/linux/elf.h 分别做详细注解。

首先是 52 字节的 Elf 文件头的结构体 elf32_hdr

变量类型 变量名 字节 说明 类型
unsigned char e_ident[EI_NIDENT] 16 .ELF 前四个标识文件类型 必须
Elf32_Half e_type 2 指定为可执行文件 必须
Elf32_Half e_machine 2 指示目标机类型,例如:Intel 386 必须
Elf32_Word e_version 4 当前只有一个版本存在,被忽略了 可篡改
Elf32_Addr e_entry 4 代码入口=加载地址(p_vaddr+.text偏移) 可调整
Elf32_Off e_phoff 4 程序头 Phdr 的偏移地址,用于加载代码 必须
Elf32_Off e_shoff 4 所有节区相关信息对文件执行无效 可篡改
Elf32_Word e_flags 4 Intel 架构未使用 可篡改
Elf32_Half e_ehsize 2 文件头大小,Linux 没做校验 可篡改
Elf32_Half e_phentsize 2 程序头入口大小,新内核有用 必须
Elf32_Half e_phnum 2 程序头入口个数 必须
Elf32_Half e_shentsize 2 所有节区相关信息对文件执行无效 可篡改
Elf32_Half e_shnum 2 所有节区相关信息对文件执行无效 可篡改
Elf32_Half e_shstrndx 2 所有节区相关信息对文件执行无效 可篡改

其次是 32 字节的程序头(Phdr)的结构体 elf32_phdr

变量类型 变量名 字节 说明 类型
Elf32_Word p_type 4 标记为可加载段 必须
Elf32_Off p_offset 4 相对程序头的偏移地址 必须
Elf32_Addr p_vaddr 4 加载地址, 0x0~0x80000000,页对齐 可调整
Elf32_Addr p_paddr 4 物理地址,暂时没用 可篡改
Elf32_Word p_filesz 4 加载的文件大小,>=real size 可调整
Elf32_Word p_memsz 4 加载所需内存大小,>= p_filesz 可调整
Elf32_Word p_flags 4 权限:read(4),exec(1), 其中一个暗指另外一个 可调整
Elf32_Word p_align 4 PIC(共享库需要),对执行文件无效 可篡改

接着,咱们把 Elf 中的文件头和程序头部分可调整可篡改的字节(52 + 32 = 84个)全部用特别的字体标记出来。

  1. $ hexdump -C hello -n 84

00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00

00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00

00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00

00000030 05 00 02 00|01 00 00 00 00 00 00 00 00 80 04 08

00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00

00000050 00 10 00 00

00000054

上述 | 线之前为文件头,之后为程序头,之前的 000000xx 为偏移地址。

如果要把程序头彻底合并进文件头。从上述信息综合来看,文件头有 4 处必须保留,结合资料 [1],经过对比发现,如果把第 4 行开始的程序头往上平移 3 行,也就是:

00000000 ========= 01 01 01 00 00 00 00 00 00 00 00 00

00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00

00000020 84 00 00 00

00000030 ========= 01 00 00 00 00 00 00 00 00 80 04 08

00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00

00000050 00 10 00 00

00000054

把可直接合并的先合并进去,效果如下:

(文件头)

00000000 ========= 01 00 00 00 00 00 00 00 00 80 04 08 (^^ p_vaddr)

00000010 02 00 03 00 60 00 00 00 54 80 04 08 34 00 00 00

00000020 =================== ^^ e_entry ^^ e_phoff

(程序头)

00000030 ========= 01 00 00 00 00 00 00 00 00 80 04 08 (^^ p_vaddr)

00000040 02 00 03 00 60 00 00 00 60 00 00 00 05 00 00 00

00000050 ========= ^^ p_filesz ^^ p_memsz ^^p_flags

00000054

接着需要设法处理好可调整的 6 处,可以逐个解决,从易到难。

  • 首先,合并 e_phoffp_flags

在合并程序头以后,程序头的偏移地址需要修改为 4,即文件的第 4 个字节开始,也就是说 e_phoff 需要修改为 04。

而恰好,p_flagsread(4)exec(1) 可以只选其一,所以,只保留 read(4) 即可,刚好也为 04。

合并后效果如下:

(文件头)

00000000 ========= 01 00 00 00 00 00 00 00 00 80 04 08 (^^ p_vaddr)

00000010 02 00 03 00 60 00 00 00 54 80 04 08 04 00 00 00

00000020 =================== ^^ e_entry

(程序头)

00000030 ========= 01 00 00 00 00 00 00 00 00 80 04 08 (^^ p_vaddr)

00000040 02 00 03 00 60 00 00 00 60 00 00 00 04 00 00 00

00000050 ========= ^^ p_filesz ^^ p_memsz

00000054

  • 接下来,合并 e_entryp_filesz, p_memszp_vaddr

从早前的分析情况来看,这 4 个变量基本都依赖 p_vaddr,也就是程序的加载地址,大体的依赖关系如下:

  1. e_entry = p_vaddr + text offset = p_vaddr + 84 = p_vaddr + 0x54
  2. p_memsz = e_entry
  3. p_memsz >= p_filesz,可以简单取 p_filesz = p_memsz
  4. p_vaddr = page alignment

所以,首先需要确定 p_vaddr,通过测试,发现p_vaddr 最低必须有 64k,也就是 0x00010000,对应到 hexdumplittle endian 导出结果,则为 00 00 01 00

需要注意的是,为了尽量少了分配内存,我们选择了一个最小的p_vaddr,如果申请的内存太大,系统将无法分配。

接着,计算出另外 3 个变量:

  1. e_entry = 0x00010000 + 0x54 = 0x00010054 54 00 01 00
  2. p_memsz = 54 00 01 00
  3. p_filesz = 54 00 01 00

完全合并后,修改如下:

(文件头)

00000000 ========= 01 00 00 00 00 00 00 00 00 00 01 00

00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00

00000020 ========

好了,直接把内容烧入:

  1. $ echo -ne "\x01\x00\x00\x00\x00\x00\x00\x00" \
  2. "\x00\x00\x01\x00\x02\x00\x03\x00" \
  3. "\x54\x00\x01\x00\x54\x00\x01\x00\x04" |\
  4. tr -d ' ' |\
  5. dd of=hello bs=1 count=25 seek=4 conv=notrunc

截掉代码(52 + 32 + 12 = 96)之后的所有内容,查看效果如下:

  1. $ dd if=hello of=hello bs=1 count=1 skip=96 seek=96
  2. $ hexdump -C hello -n 96
  3. 00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00 |.ELF............|
  4. 00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00 |....T...T.......|
  5. 00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00 |........4. ...(.|
  6. 00000030 05 00 02 00 01 00 00 00 00 00 00 00 00 80 04 08 |................|
  7. 00000040 00 80 04 08 60 00 00 00 60 00 00 00 05 00 00 00 |....`...`.......|
  8. 00000050 00 10 00 00 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |....YY..........|
  9. 00000060

最后的工作是查看文件头中剩下的可篡改的内容,并把代码部分合并进去,程序头已经合入,不再显示。

00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00

00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00

00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00

00000030 05 00 02 00

00000040

00000050 ============= 59 59 b2 05 b0 04 cd 80 b0 01 cd 80

00000060

我们的指令有 12 字节,可篡改的部分有 14 个字节,理论上一定放得下,不过因为把程序头搬进去以后,这 14 个字节并不是连续,刚好可以用上我们之前的跳转指令处理办法来解决。

并且,加入 2 个字节的跳转指令,刚好是 14 个字节,恰好把代码也完全包含进了文件头。

在预留好跳转指令位置的前提下,我们把代码部分先合并进去:

00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00

00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00

00000020 59 59 b2 05 b0 04 00 00 34 00 20 00 01 00 cd 80

00000030 b0 01 cd 80

接下来设计跳转指令,跳转指令需要从所在位置跳到第一个 cd 80 所在的位置,相距 6 个字节,根据 jmp 短跳转的编码规范,可以设计为 0xeb 0x06,填完后效果如下:

00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00

00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00

00000020 59 59 b2 05 b0 04 eb 06 34 00 20 00 01 00 cd 80

00000030 b0 01 cd 80

dd 命令写入,分两段写入:

  1. $ echo -ne "\x59\x59\xb2\x05\xb0\x04\xeb\x06" | \
  2. dd of=hello bs=1 count=8 seek=32 conv=notrunc
  3. $ echo -ne "\xcd\x80\xb0\x01\xcd\x80" | \
  4. dd of=hello bs=1 count=6 seek=46 conv=notrunc

代码合入以后,需要修改文件头中的代码的偏移地址,即 e_entry,也就是要把原来的偏移 84 (0x54) 修改为现在的偏移,即 0x20。

  1. $ echo -ne "\x20" | dd of=hello bs=1 count=1 seek=24 conv=notrunc

修改完以后恰好把合并进的程序头 p_memsz,也就是分配给文件的内存改小了,p_filesz也得相应改小。

  1. $ echo -ne "\x20" | dd of=hello bs=1 count=1 seek=20 conv=notrunc

程序头和代码都已经合入,最后,把 52 字节之后的内容全部删掉:

  1. $ dd if=hello of=hello bs=1 count=1 skip=52 seek=52
  2. $ hexdump -C hello
  3. 00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00 |.ELF............|
  4. 00000010 02 00 03 00 20 00 01 00 20 00 01 00 04 00 00 00 |....T...T.......|
  5. 00000020 59 59 b2 05 b0 04 eb 06 34 00 20 00 01 00 cd 80 |YY......4. .....|
  6. 00000030 b0 01 cd 80
  7. $ export PATH=./:$PATH
  8. $ hello
  9. hello

代码程序头部分合并进文件头的汇总情况:

00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00

00000010 02 00 03 00 20 00 01 00 20 00 01 00 04 00 00 00

00000020 59 59 b2 05 b0 04 eb 06 34 00 20 00 01 00 cd 80

00000030 b0 01 cd 80

最后,我们的成绩是:

  1. $ wc -c hello
  2. 52

史上最小的可打印 Hello World(注:要完全打印得把代码中的5该为13,并且把文件名该为该字符串) 的 Elf 文件是 52 个字节。打破了资料 [1] 作者创造的纪录:

  1. $ cd ELFkickers/tiny/
  2. $ wc -c hello
  3. 59 hello

需要特别提到的是,该作者创造的最小可执行 Elf 是 45 个字节。

但是由于那个程序只能返回一个数值,代码更简短,刚好可以直接嵌入到文件头中间,而文件末尾的 7 个 0 字节由于 Linux 加载时会自动填充,所以可以删掉,所以最终的文件大小是 52 - 7 即 45 个字节。

其大体可实现如下:

  1. .global _start
  2. _start:
  3. mov $42, %bl # 设置返回值为 42
  4. xor %eax, %eax # eax = 0
  5. inc %eax # eax = eax+1, 设置系统调用号, sys_exit()
  6. int $0x80

保存为 ret.s,编译和执行效果如下:

  1. $ as --32 -o ret.o ret.s
  2. $ ld -melf_i386 -o ret ret.o
  3. $ ./ret
  4. 42

代码字节数可这么查看:

  1. $ ld -melf_i386 --oformat=binary -o ret.bin ret.o
  2. $ hexdump -C ret.bin
  3. 0000000 b3 2a 31 c0 40 cd 80
  4. 0000007

这里只有 7 条指令,刚好可以嵌入,而最后的 6 个字节因为可篡改为 0,并且内核可自动填充 0,所以干脆可以连续删掉最后 7 个字节的 0:

00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00

00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00

00000020 b3 2a 31 c0 40 cd 80 00 34 00 20 00 01 00 00 00

00000030 00 00 00 00

可以直接用已经合并好程序头的 hello 来做实验,这里一并截掉最后的 7 个 0 字节:

  1. $ cp hello ret
  2. $ echo -ne "\xb3\x2a\x31\xc0\x40\xcd\x80" |\
  3. dd of=ret bs=1 count=8 seek=32 conv=notrunc
  4. $ dd if=ret of=hello bs=1 count=1 skip=45 seek=45
  5. $ hexdump -C hello
  6. 00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00 |.ELF............|
  7. 00000010 02 00 03 00 20 00 01 00 20 00 01 00 04 00 00 00 |.... ... .......|
  8. 00000020 b3 2a 31 c0 40 cd 80 06 34 00 20 00 01 |.*1.@...4. ..|
  9. 0000002d
  10. $ wc -c ret
  11. 45 ret
  12. $ ./ret
  13. $ echo $?
  14. 42

如果想快速构建该 Elf 文件,可以直接使用下述 Shell 代码:

  1. #!/bin/bash
  2. #
  3. # generate_ret_elf.sh -- Generate a 45 bytes Elf file
  4. #
  5. # $ bash generate_ret_elf.sh
  6. # $ chmod a+x ret.elf
  7. # $ ./ret.elf
  8. # $ echo $?
  9. # 42
  10. #
  11. ret="\x7f\x45\x4c\x46\x01\x00\x00\x00"
  12. ret=${ret}"\x00\x00\x00\x00\x00\x00\x01\x00"
  13. ret=${ret}"\x02\x00\x03\x00\x20\x00\x01\x00"
  14. ret=${ret}"\x20\x00\x01\x00\x04\x00\x00\x00"
  15. ret=${ret}"\xb3\x2a\x31\xc0\x40\xcd\x80\x06"
  16. ret=${ret}"\x34\x00\x20\x00\x01"
  17. echo -ne $ret > ret.elf

又或者是直接参照资料 [1]tiny.asm 就行了,其代码如下:

  1. ; ret.asm
  2. BITS 32
  3. org 0x00010000
  4. db 0x7F, "ELF" ; e_ident
  5. dd 1 ; p_type
  6. dd 0 ; p_offset
  7. dd $$ ; p_vaddr
  8. dw 2 ; e_type ; p_paddr
  9. dw 3 ; e_machine
  10. dd _start ; e_version ; p_filesz
  11. dd _start ; e_entry ; p_memsz
  12. dd 4 ; e_phoff ; p_flags
  13. _start:
  14. mov bl, 42 ; e_shoff ; p_align
  15. xor eax, eax
  16. inc eax ; e_flags
  17. int 0x80
  18. db 0
  19. dw 0x34 ; e_ehsize
  20. dw 0x20 ; e_phentsize
  21. db 1 ; e_phnum
  22. ; e_shentsize
  23. ; e_shnum
  24. ; e_shstrndx
  25. filesize equ $ - $$

编译和运行效果如下:

  1. $ nasm -f bin -o ret ret.asm
  2. $ chmod +x ret
  3. $ ./ret ; echo $?
  4. 42
  5. $ wc -c ret
  6. 45 ret

下面也给一下本文精简后的 hellonasm 版本:

  1. ; hello.asm
  2. BITS 32
  3. org 0x00010000
  4. db 0x7F, "ELF" ; e_ident
  5. dd 1 ; p_type
  6. dd 0 ; p_offset
  7. dd $$ ; p_vaddr
  8. dw 2 ; e_type ; p_paddr
  9. dw 3 ; e_machine
  10. dd _start ; e_version ; p_filesz
  11. dd _start ; e_entry ; p_memsz
  12. dd 4 ; e_phoff ; p_flags
  13. _start:
  14. pop ecx ; argc ; e_shoff ; p_align
  15. pop ecx ; argv[0]
  16. mov dl, 5 ; str len ; e_flags
  17. mov al, 4 ; sys_write(fd, addr, len) : ebx, ecx, edx
  18. jmp _next ; jump to next part of the code
  19. dw 0x34 ; e_ehsize
  20. dw 0x20 ; e_phentsize
  21. dw 1 ; e_phnum
  22. _next: int 0x80 ; syscall ; e_shentsize
  23. mov al, 1 ; eax=1,sys_exit ; e_shnum
  24. int 0x80 ; syscall ; e_shstrndx
  25. filesize equ $ - $$

编译和用法如下:

  1. $ nasm -f bin -o hello hello.asm
  2. $ chmod a+x hello
  3. $ export PATH=./:$PATH
  4. $ hello
  5. hello
  6. $ wc -c hello
  7. 52

经过一番努力,AT&T 的完整 binary 版本如下:

  1. # hello.s
  2. #
  3. # as --32 -o hello.o hello.s
  4. # ld -melf_i386 --oformat=binary -o hello hello.o
  5. #
  6. .file "hello.s"
  7. .global _start, _load
  8. .equ LOAD_ADDR, 0x00010000 # Page aligned load addr, here 64k
  9. .equ E_ENTRY, LOAD_ADDR + (_start - _load)
  10. .equ P_MEM_SZ, E_ENTRY
  11. .equ P_FILE_SZ, P_MEM_SZ
  12. _load:
  13. .byte 0x7F
  14. .ascii "ELF" # e_ident, Magic Number
  15. .long 1 # p_type, loadable seg
  16. .long 0 # p_offset
  17. .long LOAD_ADDR # p_vaddr
  18. .word 2 # e_type, exec # p_paddr
  19. .word 3 # e_machine, Intel 386 target
  20. .long P_FILE_SZ # e_version # p_filesz
  21. .long E_ENTRY # e_entry # p_memsz
  22. .long 4 # e_phoff # p_flags, read(exec)
  23. .text
  24. _start:
  25. popl %ecx # argc # e_shoff # p_align
  26. popl %ecx # argv[0]
  27. mov $5, %dl # str len # e_flags
  28. mov $4, %al # sys_write(fd, addr, len) : ebx, ecx, edx
  29. jmp next # jump to next part of the code
  30. .word 0x34 # e_ehsize = 52
  31. .word 0x20 # e_phentsize = 32
  32. .word 1 # e_phnum = 1
  33. .text
  34. _next: int $0x80 # syscall # e_shentsize
  35. mov $1, %al # eax=1,sys_exit # e_shnum
  36. int $0x80 # syscall # e_shstrndx

编译和运行效果如下:

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 --oformat=binary -o hello hello.o
  3. $ export PATH=./:$PATH
  4. $ hello
  5. hello
  6. $ wc -c hello
  7. 52 hello

:编译时务必要加 --oformat=binary 参数,以便直接基于源文件构建一个二进制的 Elf 文件,否则会被 ld 默认编译,自动填充其他内容。

汇编语言极限精简之道(45字节)

经过上述努力,我们已经完全把程序头和代码都融入了 52 字节的 Elf 文件头,还可以再进一步吗?

基于资料一,如果再要努力,只能设法把 Elf 末尾的 7 个 0 字节删除,但是由于代码已经把 Elf 末尾的 7 字节 0 字符都填满了,所以要想在这一块努力,只能继续压缩代码。

继续研究下代码先:

  1. .global _start
  2. _start:
  3. popl %ecx # argc
  4. popl %ecx # argv[0]
  5. movb $5, %dl # 设置字符串长度
  6. movb $4, %al # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
  7. int $0x80
  8. movb $1, %al
  9. int $0x80

查看对应的编码:

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 -o hello hello.o --oformat=binary
  3. $ hexdump -C hello
  4. 00000000 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |YY..........|
  5. 0000000c

每条指令对应的编码映射如下:

指令 编码 说明
popl %ecx 59 argc
popl %ecx 59 argv[0]
movb $5, %dl b2 05 设置字符串长度
movb $4, %al b0 04 eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
int $0x80 cd 80 触发系统调用
movb $1, %al b0 01 eax = 1, sys_exit
int $0x80 cd 80 触发系统调用

可以观察到:

  • popl 的指令编码最简洁。
  • int $0x80 重复了两次,而且每条都占用了 2 字节
  • movb 每条都占用了 2 字节
  • eax 有两次赋值,每次占用了 2 字节
  • popl %ecx 取出的 argc 并未使用

根据之前通过参数传递字符串的想法,咱们是否可以考虑通过参数来设置变量呢?

理论上,传入多个参数,通过 pop 弹出来赋予 eax, ecx 即可,但是实际上,由于从参数栈里头 pop 出来的参数是参数的地址,并不是参数本身,所以该方法行不通。

不过由于第一个参数取出的是数字,并且是参数个数,而且目前的那条 popl %ecx 取出的 argc 并没有使用,那么刚好可以用来设置 eax,替换后如下:

  1. .global _start
  2. _start:
  3. popl %eax # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
  4. popl %ecx # argv[0], 字符串
  5. movb $5, %dl # 设置字符串长度
  6. int $0x80
  7. movb $1, %al # eax = 1, sys_exit
  8. int $0x80

这里需要传入 4 个参数,即让栈弹出的第一个值,也就是参数个数赋予 eax,也就是:hello 5 4 1

难道我们只能把该代码优化到 10 个字节?

巧合地是,当偶然改成这样的情况下,该代码还能正常返回。

  1. .global _start
  2. _start:
  3. popl %eax # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
  4. popl %ecx # argv[0], 字符串
  5. movb $5, %dl # 设置字符串长度
  6. int $0x80
  7. loop _start # 触发系统退出

:上面我们使用了 loop 指令而不是 jmp 指令,因为 jmp _start 产生的代码更长,而 loop _start 指令只有两个字节。

这里相当于删除了 movb $1, %al,最后我们获得了 8 个字节。但是这里为什么能够工作呢?

经过分析 arch/x86/ia32/ia32entry.S,我们发现当系统调用号无效时(超过系统调用入口个数),内核为了健壮考虑,必须要处理这类异常,并通过 ia32_badsys 让系统调用正常返回。

这个可以这样验证:

  1. .global _start
  2. _start:
  3. popl %eax # argc, eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
  4. popl %ecx # argv[0], 文件名
  5. mov $5, %dl # argv[1],字符串长度
  6. int $0x80
  7. mov $0xffffffda, %eax # 设置一个非法调用号用于退出
  8. int $0x80

那最后的结果是,我们产生了一个可以正常打印字符串,大小只有 45 字节的 Elf 文件,最终的结果如下:

  1. # hello.s
  2. #
  3. # $ as --32 -o hello.o hello.s
  4. # $ ld -melf_i386 --oformat=binary -o hello hello.o
  5. # $ export PATH=./:$PATH
  6. # $ hello 0 0 0
  7. # hello
  8. #
  9. .file "hello.s"
  10. .global _start, _load
  11. .equ LOAD_ADDR, 0x00010000 # Page aligned load addr, here 64k
  12. .equ E_ENTRY, LOAD_ADDR + (_start - _load)
  13. .equ P_MEM_SZ, E_ENTRY
  14. .equ P_FILE_SZ, P_MEM_SZ
  15. _load:
  16. .byte 0x7F
  17. .ascii "ELF" # e_ident, Magic Number
  18. .long 1 # p_type, loadable seg
  19. .long 0 # p_offset
  20. .long LOAD_ADDR # p_vaddr
  21. .word 2 # e_type, exec # p_paddr
  22. .word 3 # e_machine, Intel 386 target
  23. .long P_FILE_SZ # e_version # p_filesz
  24. .long E_ENTRY # e_entry # p_memsz
  25. .long 4 # e_phoff # p_flags, read(exec)
  26. .text
  27. _start:
  28. popl %eax # argc # e_shoff # p_align
  29. # 4 args, eax = 4, sys_write(fd, addr, len) : ebx, ecx, edx
  30. # set 2nd eax = random addr to trigger bad syscall for exit
  31. popl %ecx # argv[0]
  32. mov $5, %dl # str len # e_flags
  33. int $0x80
  34. loop _start # loop to popup a random addr as a bad syscall number
  35. .word 0x34 # e_ehsize = 52
  36. .word 0x20 # e_phentsize = 32
  37. .byte 1 # e_phnum = 1, remove trailing 7 bytes with 0 value
  38. # e_shentsize
  39. # e_shnum
  40. # e_shstrndx

效果如下:

  1. $ as --32 -o hello.o hello.s
  2. $ ld -melf_i386 -o hello hello.o --oformat=binary
  3. $ export PATH=./:$PATH
  4. $ hello 0 0 0
  5. hello
  6. $ wc -c hello
  7. 45 hello

到这里,我们获得了史上最小的可以打印字符串的 Elf 文件,是的,只有 45 个字节。

小结

到这里,关于可执行文件的讨论暂且结束,最后来一段小小的总结,那就是我们设法去减少可执行文件大小的意义?

实际上,通过这样一个讨论深入到了很多技术的细节,包括可执行文件的格式、目标代码链接的过程、 Linux 下汇编语言开发等。与此同时,可执行文件大小的减少本身对嵌入式系统非常有用,如果删除那些对程序运行没有影响的节区和节区表将减少目标系统的大小,适应嵌入式系统资源受限的需求。除此之外,动态连接库中的很多函数可能不会被使用到,因此也可以通过某种方式剔除 [8][10]

或许,你还会发现更多有趣的意义,欢迎给我发送邮件,一起讨论。

参考资料