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

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

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

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

    可执行文件格式的选取

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

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

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

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

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

    无论是文件头部、程序头部表、节区头部表还是各个节区,都是通过特定的结构体 (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 入口就不是必须的。因此,如果不采用默认链接也可以减少可执行文件的大小。

    这里主要是根据上面两点来介绍如何减少一个可执行文件的大小。以 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 字节。

    删除可执行文件的节区表

    用普通的工具没有办法删除节区表,但是参考资料的作者已经写了这样一个工具。你可以从这里下载到那个工具,它是该作者写的一序列工具 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. 1200 hello
    19. $ sstrip hello
    20. $ wc -c hello #这个结果比之前的708(在删除所有垃圾信息以后)个字节少了708-676,即32个字节
    21. 676 hello
    22. $ ./hello
    23. Hello World

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

    用系统调用取代库函数

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

    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. xorl %edx, %edx
    11. movb $13, %dl #edx = 13, the length of .string
    12. int $0x80
    13. xorl %eax, %eax
    14. movl %eax, %ebx #ebx = 0
    15. incl %eax #eax = 1, sys_exit
    16. 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 字符串的长度,那么就可以达到减少目标文件大小的目的。

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

    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. $ 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. 00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
    6. 0000004c

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

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

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

    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

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

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

    1. $ hexdump -C hello -n 84

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

    如果要把程序头彻底合并进文件头。从上述信息综合来看,文件头有 4 处必须保留,结合资料 ,经过对比发现,如果把第 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。

    合并后效果如下:

    (文件头)

    (程序头)

    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 个变量:

    (文件头)

    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 个字节,恰好把代码也完全包含进了文件头。

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

    接下来设计跳转指令,跳转指令需要从所在位置跳到第一个 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

    又或者是直接参照资料 的 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 默认编译,自动填充其他内容。

    经过上述努力,我们已经完全把程序头和代码都融入了 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 的指令编码最简洁。
    • 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. $ 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

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

    小结

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

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

    参考资料