打造史上最小可执行 ELF 文件(45 字节,可打印字符串)
- 前言
- 可执行文件格式的选取
- 链接优化
- 可执行文件“减肥”实例(从6442到708字节)
- 用汇编语言来重写 Hello World(76字节)
- 合并代码段、程序头和文件头(52字节)
- 汇编语言极限精简之道(45字节)
- 小结
- 参考资料
前言
本文从减少可执行文件大小的角度分析了 ELF
文件,期间通过经典的 Hello World
实例逐步演示如何通过各种常用工具来分析 ELF
文件,并逐步精简代码。
为了能够尽量减少可执行文件的大小,我们必须了解可执行文件的格式,以及链接生成可执行文件时的后台细节(即最终到底有哪些内容被链接到了目标代码中)。通过选择合适的可执行文件格式并剔除对可执行文件的最终运行没有影响的内容,就可以实现目标代码的裁减。因此,通过探索减少可执行文件大小的方法,就相当于实践性地去探索了可执行文件的格式以及链接过程的细节。
当然,算法的优化和编程语言的选择可能对目标文件的大小有很大的影响,在本文最后我们会跟参考资料 [1] 的作者那样去探求一个打印 Hello World
的可执行文件能够小到什么样的地步。
可执行文件格式的选取
可执行文件格式的选择要满足的一个基本条件是:目标系统支持该可执行文件格式,资料 [2] 分析和比较了 UNIX
平台下的三种可执行文件格式,这三种格式实际上代表着可执行文件的一个发展过程:
a.out 文件格式非常紧凑,只包含了程序运行所必须的信息(文本、数据、
BSS
),而且每个section
的顺序是固定的。coff 文件格式虽然引入了一个节区表以支持更多节区信息,从而提高了可扩展性,但是这种文件格式的重定位在链接时就已经完成,因此不支持动态链接(不过扩展的
coff
支持)。elf 文件格式不仅动态链接,而且有很好的扩展性。它可以描述可重定位文件、可执行文件和可共享文件(动态链接库)三类文件。
下面来看看 ELF
文件的结构图:
文件头部(ELF Header)
程序头部表(Program Header Table)
节区1(Section1)
节区2(Section2)
节区3(Section3)
...
节区头部(Section Header Table)
无论是文件头部、程序头部表、节区头部表还是各个节区,都是通过特定的结构体 (struct)描述的,这些结构在
elf.h文件中定义。文件头部用于描述整个文件的类型、大小、运行平台、程序入口、程序头部表和节区头部表等信息。例如,我们可以通过文件头部查看该
ELF` 文件的类型。
$ cat hello.c #典型的hello, world程序
#include <stdio.h>
int main(void)
{
printf("hello, world!\n");
return 0;
}
$ gcc -c hello.c #编译,产生可重定向的目标代码
$ readelf -h hello.o | grep Type #通过readelf查看文件头部找出该类型
Type: REL (Relocatable file)
$ gcc -o hello hello.o #生成可执行文件
$ readelf -h hello | grep Type
Type: EXEC (Executable file)
$ gcc -fpic -shared -Wl,-soname,libhello.so.0 -o libhello.so.0.0 hello.o #生成共享库
$ readelf -h libhello.so.0.0 | grep Type
Type: DYN (Shared object file)
那节区头部表(将简称节区表)和程序头部表有什么用呢?实际上前者只对可重定向文件有用,而后者只对可执行文件和可共享文件有用。
节区表是用来描述各节区的,包括各节区的名字、大小、类型、虚拟内存中的位置、相对文件头的位置等,这样所有节区都通过节区表给描述了,这样连接器就可以根据文件头部表和节区表的描述信息对各种输入的可重定位文件进行合适的链接,包括节区的合并与重组、符号的重定位(确认符号在虚拟内存中的地址)等,把各个可重定向输入文件链接成一个可执行文件(或者是可共享文件)。如果可执行文件中使用了动态连接库,那么将包含一些用于动态符号链接的节区。我们可以通过 readelf -S
(或 objdump -h
)查看节区表信息。
$ readelf -S hello #可执行文件、可共享库、可重定位文件默认都生成有节区表
...
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 08048114 000114 000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048128 000128 000020 00 A 0 0 4
[ 3] .hash HASH 08048148 000148 000028 04 A 5 0 4
...
[ 7] .gnu.version VERSYM 0804822a 00022a 00000a 02 A 5 0 2
...
[11] .init PROGBITS 08048274 000274 000030 00 AX 0 0 4
...
[13] .text PROGBITS 080482f0 0002f0 000148 00 AX 0 0 16
[14] .fini PROGBITS 08048438 000438 00001c 00 AX 0 0 4
...
三种类型文件的节区(各个常见节区的作用请参考资料 [11])可能不一样,但是有几个节区,例如 .text
,.data
,.bss
是必须的,特别是 .text
,因为这个节区包含了代码。如果一个程序使用了动态链接库(引用了动态连接库中的某个函数),那么需要 .interp
节区以便告知系统使用什么动态连接器程序来进行动态符号链接,进行某些符号地址的重定位。通常,.rel.text
节区只有可重定向文件有,用于链接时对代码区进行重定向,而 .hash
,.plt
,.got
等节区则只有可执行文件(或可共享库)有,这些节区对程序的运行特别重要。还有一些节区,可能仅仅是用于注释,比如 .comment
,这些对程序的运行似乎没有影响,是可有可无的,不过有些节区虽然对程序的运行没有用处,但是却可以用来辅助对程序进行调试或者对程序运行效率有影响。
虽然三类文件都必须包含某些节区,但是节区表对可重定位文件来说才是必须的,而程序的执行却不需要节区表,只需要程序头部表以便知道如何加载和执行文件。不过如果需要对可执行文件或者动态连接库进行调试,那么节区表却是必要的,否则调试器将不知道如何工作。下面来介绍程序头部表,它可通过 readelf -l
(或 objdump -p
)查看。
$ readelf -l hello.o #对于可重定向文件,gcc没有产生程序头部,因为它对可重定向文件没用
There are no program headers in this file.
$ readelf -l hello #而可执行文件和可共享文件都有程序头部
...
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x00470 0x00470 R E 0x1000
LOAD 0x000470 0x08049470 0x08049470 0x0010c 0x00110 RW 0x1000
DYNAMIC 0x000484 0x08049484 0x08049484 0x000d0 0x000d0 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
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
03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag
06
$ readelf -l libhello.so.0.0 #节区和上面类似,这里省略
从上面可看出程序头部表描述了一些段(Segment
),这些段对应着一个或者多个节区,上面的 readelf -l
很好地显示了各个段与节区的映射。这些段描述了段的名字、类型、大小、第一个字节在文件中的位置、将占用的虚拟内存大小、在虚拟内存中的位置等。这样系统程序解释器将知道如何把可执行文件加载到内存中以及进行动态链接等动作。
该可执行文件包含 7 个段,PHDR
指程序头部,INTERP
正好对应 .interp
节区,两个 LOAD
段包含程序的代码和数据部分,分别包含有 .text
和 .data
,.bss
节区,DYNAMIC
段包含 .daynamic
,这个节区可能包含动态连接库的搜索路径、可重定位表的地址等信息,它们用于动态连接器。 NOTE
和 GNU_STACK
段貌似作用不大,只是保存了一些辅助信息。因此,对于一个不使用动态连接库的程序来说,可能只包含 LOAD
段,如果一个程序没有数据,那么只有一个 LOAD
段就可以了。
总结一下,Linux 虽然支持很多种可执行文件格式,但是目前 ELF
较通用,所以选择 ELF
作为我们的讨论对象。通过上面对 ELF
文件分析发现一个可执行的文件可能包含一些对它的运行没用的信息,比如节区表、一些用于调试、注释的节区。如果能够删除这些信息就可以减少可执行文件的大小,而且不会影响可执行文件的正常运行。
链接优化
从上面的讨论中已经接触了动态连接库。 ELF
中引入动态连接库后极大地方便了公共函数的共享,节约了磁盘和内存空间,因为不再需要把那些公共函数的代码链接到可执行文件,这将减少了可执行文件的大小。
与此同时,静态链接可能会引入一些对代码的运行可能并非必须的内容。你可以从《GCC 编译的背后(第二部分:汇编和链接)》 了解到 GCC
链接的细节。从那篇 Blog 中似乎可以得出这样的结论:仅仅从是否影响一个 C 语言程序运行的角度上说,GCC
默认链接到可执行文件的几个可重定位文件 (crt1.o
,rti.o
,crtbegin.o
,crtend.o
,crtn.o
)并不是必须的,不过值得注意的是,如果没有链接那些文件但在程序末尾使用了 return
语句,main
函数将无法返回,因此需要替换为 _exit
调用;另外,既然程序在进入 main
之前有一个入口,那么 main
入口就不是必须的。因此,如果不采用默认链接也可以减少可执行文件的大小。
可执行文件“减肥”实例(从6442到708字节)
这里主要是根据上面两点来介绍如何减少一个可执行文件的大小。以 Hello World
为例。
首先来看看默认编译产生的 Hello World
的可执行文件大小。
系统默认编译
代码同上,下面是一组演示,
$ uname -r #先查看内核版本和gcc版本,以便和你的结果比较
2.6.22-14-generic
$ gcc --version
gcc (GCC) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)
...
$ gcc -o hello hello.c #默认编译
$ wc -c hello #产生一个大小为6442字节的可执行文件
6442 hello
不采用默认编译
可以考虑编辑时就把 return 0
替换成 _exit(0)
并包含定义该函数的 unistd.h
头文件。下面是从《GCC 编译的背后(第二部分:汇编和链接)》总结出的 Makefile
文件。
#file: Makefile
#functin: for not linking a program as the gcc do by default
#author: falcon<zhangjinw@gmail.com>
#update: 2008-02-23
MAIN = hello
SOURCE =
OBJS = hello.o
TARGET = hello
CC = gcc-3.4 -m32
LD = ld -m elf_i386
CFLAGSs += -S
CFLAGSc += -c
LDFLAGS += -dynamic-linker /lib/ld-linux.so.2 -L /usr/lib/ -L /lib -lc
RM = rm -f
SEDc = sed -i -e '/\#include[ "<]*unistd.h[ ">]*/d;' \
-i -e '1i \#include <unistd.h>' \
-i -e 's/return 0;/_exit(0);/'
SEDs = sed -i -e 's/main/_start/g'
all: $(TARGET)
$(TARGET):
@$(SEDc) $(MAIN).c
@$(CC) $(CFLAGSs) $(MAIN).c
@$(SEDs) $(MAIN).s
@$(CC) $(CFLAGSc) $(MAIN).s $(SOURCE)
@$(LD) $(LDFLAGS) -o $@ $(OBJS)
clean:
@$(RM) $(MAIN).s $(OBJS) $(TARGET)
把上面的代码复制到一个Makefile文件中,并利用它来编译hello.c。
$ make #编译
$ ./hello #这个也是可以正常工作的
Hello World
$ wc -c hello #但是大小减少了4382个字节,减少了将近 70%
2060 hello
$ echo "6442-2060" | bc
4382
$ echo "(6442-2060)/6442" | bc -l
.68022353306426575597
对于一个比较小的程序,能够减少将近 70% “没用的”代码。
删除对程序运行没有影响的节区
使用上述 Makefile
来编译程序,不链接那些对程序运行没有多大影响的文件,实际上也相当于删除了一些“没用”的节区,可以通过下列演示看出这个实质。
$ make clean
$ make
$ readelf -l hello | grep "0[0-9]\ \ "
00
01 .interp
02 .interp .hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.plt .plt .text .rodata
03 .dynamic .got.plt
04 .dynamic
05
$ make clean
$ gcc -o hello hello.c
$ readelf -l hello | grep "0[0-9]\ \ "
00
01 .interp
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
03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag
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
)删除这些节区试试。
$ wc -c hello #查看大小,以便比较
2060
$ time ./hello #我们比较一下一些节区对执行时间可能存在的影响
Hello World
real 0m0.001s
user 0m0.000s
sys 0m0.000s
$ strip -R .hash hello #删除.hash节区
$ wc -c hello
1448 hello
$ echo "2060-1448" | bc #减少了612字节
612
$ time ./hello #发现执行时间长了一些(实际上也可能是进程调度的问题)
Hello World
real 0m0.006s
user 0m0.000s
sys 0m0.000s
$ strip -R .gnu.version hello #删除.gnu.version还是可以工作
$ wc -c hello
1396 hello
$ echo "1448-1396" | bc #又减少了52字节
52
$ time ./hello
Hello World
real 0m0.130s
user 0m0.004s
sys 0m0.000s
$ strip -R .gnu.version_r hello #删除.gnu.version_r就不工作了
$ time ./hello
./hello: error while loading shared libraries: ./hello: unsupported version 0 of Verneed record
通过删除各个节区可以查看哪些节区对程序来说是必须的,不过有些节区虽然并不影响程序的运行却可能会影响程序的执行效率,这个可以上面的运行时间看出个大概。
通过删除两个“没用”的节区,我们又减少了 52+612
,即 664 字节。
删除可执行文件的节区表
用普通的工具没有办法删除节区表,但是参考资料[1]的作者已经写了这样一个工具。你可以从这里下载到那个工具,它是该作者写的一序列工具 ELFkickers
中的一个。
下载并编译(注:1.0 之前的版本才支持 32 位和正常编译,新版本在代码中明确限定了数据结构为 Elf64
):
$ git clone https://github.com/BR903/ELFkickers
$ cd ELFkickers/sstrip/
$ git checkout f0622afa # 检出 1.0 版
$ make
然后复制到 /usr/bin
下,下面用它来删除节区表。
$ sstrip hello #删除ELF可执行文件的节区表
$ ./hello #还是可以正常运行,说明节区表对可执行文件的运行没有任何影响
Hello World
$ wc -c hello #大小只剩下708个字节了
708 hello
$ echo "1396-708" | bc #又减少了688个字节。
688
通过删除节区表又把可执行文件减少了 688 字节。现在回头看看相对于 gcc
默认产生的可执行文件,通过删除一些节区和节区表到底减少了多少字节?减幅达到了多少?
$ echo "6442-708" | bc #
5734
$ echo "(6442-708)/6442" | bc -l
.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
选项可得到汇编代码。
$ cat hello.c #这个是使用_exit和printf函数的版本
#include <stdio.h> /* printf */
#include <unistd.h> /* _exit */
int main()
{
printf("Hello World\n");
_exit(0);
}
$ gcc -S hello.c #生成汇编
$ cat hello.s #这里是汇编代码
.file "hello.c"
.section .rodata
.LC0:
.string "Hello World"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $4, %esp
movl $.LC0, (%esp)
call puts
movl $0, (%esp)
call _exit
.size main, .-main
.ident "GCC: (GNU) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)"
.section .note.GNU-stack,"",@progbits
$ gcc -o hello hello.s #看看默认产生的代码大小
$ wc -c hello
6523 hello
删除掉汇编代码中无关紧要内容
现在对汇编代码 hello.s
进行简单的处理得到,
.LC0:
.string "Hello World"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $4, %esp
movl $.LC0, (%esp)
call puts
movl $0, (%esp)
call _exit
再编译看看,
$ gcc -o hello.o hello.s
$ wc -c hello
6443 hello
$ echo "6523-6443" | bc #仅仅减少了80个字节
80
不默认编译并删除掉无关节区和节区表
如果不采用默认编译呢并且删除掉对程序运行没有影响的节区和节区表呢?
$ sed -i -e "s/main/_start/g" hello.s #因为没有初始化,所以得直接进入代码,替换main为_start
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o --dynamic-linker /lib/ld-linux.so.2 -L /usr/lib -lc
$ ./hello
hello world!
$ wc -c hello
1812 hello
$ echo "6443-1812" | bc -l #和之前的实验类似,也减少了4k左右
4631
$ readelf -l hello | grep "\ [0-9][0-9]\ "
00
01 .interp
02 .interp .hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.plt .plt .text
03 .dynamic .got.plt
04 .dynamic
$ strip -R .hash hello
$ strip -R .gnu.version hello
$ wc -c hello
1200 hello
$ sstrip hello
$ wc -c hello #这个结果比之前的708(在删除所有垃圾信息以后)个字节少了708-676,即32个字节
676 hello
$ ./hello
Hello World
容易发现这 32 字节可能跟节区 .rodata
有关系,因为刚才在链接完以后查看节区信息时,并没有 .rodata
节区。
用系统调用取代库函数
前面提到,实际上还可以不用动态连接库中的 printf
函数,也不用直接调用 _exit
,而是在汇编里头使用系统调用,这样就可以去掉和动态连接库关联的内容。如果想了解如何在汇编中使用系统调用,请参考资料 [9]。使用系统调用重写以后得到如下代码,
.LC0:
.string "Hello World\xa\x0"
.text
.global _start
_start:
xorl %eax, %eax
movb $4, %al #eax = 4, sys_write(fd, addr, len)
xorl %ebx, %ebx
incl %ebx #ebx = 1, standard output
movl $.LC0, %ecx #ecx = $.LC0, the address of string
xorl %edx, %edx
movb $13, %dl #edx = 13, the length of .string
int $0x80
xorl %eax, %eax
movl %eax, %ebx #ebx = 0
incl %eax #eax = 1, sys_exit
int $0x80
现在编译就不再需要动态链接器 ld-linux.so
了,也不再需要链接任何库。
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o
$ readelf -l hello
Elf file type is EXEC (Executable file)
Entry point 0x8048062
There are 1 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x0007b 0x0007b R E 0x1000
Section to Segment mapping:
Segment Sections...
00 .text
$ sstrip hello
$ ./hello #完全可以正常工作
Hello World
$ wc -c hello
123 hello
$ echo "676-123" | bc #相对于之前,已经只需要123个字节了,又减少了553个字节
553
可以看到效果很明显,只剩下一个 LOAD
段,它对应 .text
节区。
把字符串作为参数输入
不过是否还有办法呢?把 Hello World
作为参数输入,而不是硬编码在文件中。所以如果处理参数的代码少于 Hello World
字符串的长度,那么就可以达到减少目标文件大小的目的。
先来看一个能够打印程序参数的汇编语言程序,它来自参考资料[9]。
.text
.globl _start
_start:
popl %ecx # argc
vnext:
popl %ecx # argv
test %ecx, %ecx # 空指针表明结束
jz exit
movl %ecx, %ebx
xorl %edx, %edx
strlen:
movb (%ebx), %al
inc %edx
inc %ebx
test %al, %al
jnz strlen
movb $10, -1(%ebx)
movl $4, %eax # 系统调用号(sys_write)
movl $1, %ebx # 文件描述符(stdout)
int $0x80
jmp vnext
exit:
movl $1,%eax # 系统调用号(sys_exit)
xorl %ebx, %ebx # 退出代码
int $0x80
ret
编译看看效果,
$ as --32 -o args.o args.s
$ ld -melf_i386 -o args args.o
$ ./args "Hello World" #能够打印输入的字符串,不错
./args
Hello World
$ sstrip args
$ wc -c args #处理以后只剩下130字节
130 args
可以看到,这个程序可以接收用户输入的参数并打印出来,不过得到的可执行文件为 130 字节,比之前的 123 个字节还多了 7 个字节,看看还有改进么?分析上面的代码后,发现,原来的代码有些地方可能进行优化,优化后得到如下代码。
.global _start
_start:
popl %ecx #弹出argc
vnext:
popl %ecx #弹出argv[0]的地址
test %ecx, %ecx #空指针表明结束
jz exit
movl %ecx, %ebx #复制字符串地址到ebx寄存器
xorl %edx, %edx #把字符串长度清零
strlen: #求输入字符串的长度
movb (%ebx), %al #复制字符到al,以便判断是否为字符串结束符\0
inc %edx #edx存放每个当前字符串的长度
inc %ebx #ebx存放每个当前字符的地址
test %al, %al #判断字符串是否结束,即是否遇到\0
jnz strlen
movb $10, -1(%ebx) #在字符串末尾插入一个换行符\0xa
xorl %eax, %eax
movb $4, %al #eax = 4, sys_write(fd, addr, len)
xorl %ebx, %ebx
incl %ebx #ebx = 1, standard output
int $0x80
jmp vnext
exit:
xorl %eax, %eax
movl %eax, %ebx #ebx = 0
incl %eax #eax = 1, sys_exit
int $0x80
再测试(记得先重新汇编、链接并删除没用的节区和节区表)。
$ wc -c hello
124 hello
现在只有 124 个字节,不过还是比 123 个字节多一个,还有什么优化的办法么?
先来看看目前 hello
的功能,感觉不太符合要求,因为只需要打印 Hello World
,所以不必处理所有的参数,仅仅需要接收并打印一个参数就可以。这样的话,把 jmp vnext
(2 字节)这个循环去掉,然后在第一个 pop %ecx
语句之前加一个 pop %ecx
(1 字节)语句就可以。
.global _start
_start:
popl %ecx
popl %ecx #弹出argc[0]的地址
popl %ecx #弹出argv[1]的地址
test %ecx, %ecx
jz exit
movl %ecx, %ebx
xorl %edx, %edx
strlen:
movb (%ebx), %al
inc %edx
inc %ebx
test %al, %al
jnz strlen
movb $10, -1(%ebx)
xorl %eax, %eax
movb $4, %al
xorl %ebx, %ebx
incl %ebx
int $0x80
exit:
xorl %eax, %eax
movl %eax, %ebx
incl %eax
int $0x80
现在刚好 123 字节,和原来那个代码大小一样,不过仔细分析,还是有减少代码的余地:因为在这个代码中,用了一段额外的代码计算字符串的长度,实际上如果仅仅需要打印 Hello World
,那么字符串的长度是固定的,即 12 。所以这段代码可去掉,与此同时测试字符串是否为空也就没有必要(不过可能影响代码健壮性!),当然,为了能够在打印字符串后就换行,在串的末尾需要加一个回车($10
)并且设置字符串的长度为 12+1
,即 13,
.global _start
_start:
popl %ecx
popl %ecx
popl %ecx
movb $10,12(%ecx) #在Hello World的结尾加一个换行符
xorl %edx, %edx
movb $13, %dl
xorl %eax, %eax
movb $4, %al
xorl %ebx, %ebx
incl %ebx
int $0x80
xorl %eax, %eax
movl %eax, %ebx
incl %eax
int $0x80
再看看效果,
$ wc -c hello
111 hello
寄存器赋值重用
现在只剩下 111 字节,比刚才少了 12 字节。貌似到了极限?还有措施么?
还有,仔细分析发现:系统调用 sys_exit
和 sys_write
都用到了 eax
和 ebx
寄存器,它们之间刚好有那么一点巧合:
- 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 个字节。
.global _start
_start:
popl %ecx
popl %ecx
popl %ecx
movb $10,12(%ecx)
xorl %edx, %edx
movb $13, %dl
xorl %eax, %eax
movb $4, %al
xorl %ebx, %ebx
int $0x80
xorl %eax, %eax
incl %eax
int $0x80
看看效果,
$ wc -c hello
108 hello
现在看一下纯粹的指令还有多少?
$ readelf -h hello | grep Size
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Size of section headers: 0 (bytes)
$ echo "108-52-32" | bc
24
通过文件名传递参数
对于标准的 main
函数的两个参数,文件名实际上作为第二个参数(数组)的第一个元素传入,如果仅仅是为了打印一个字符串,那么可以打印文件名本身。例如,要打印 Hello World
,可以把文件名命名为 Hello World
即可。
这样地话,代码中就可以删除掉一条 popl
指令,减少 1 个字节,变成 107 个字节。
.global _start
_start:
popl %ecx
popl %ecx
movb $10,12(%ecx)
xorl %edx, %edx
movb $13, %dl
xorl %eax, %eax
movb $4, %al
xorl %ebx, %ebx
int $0x80
xorl %eax, %eax
incl %eax
int $0x80
看看效果,
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o
$ sstrip hello
$ wc -c hello
107
$ mv hello "Hello World"
$ export PATH=./:$PATH
$ Hello\ World
Hello World
删除非必要指令
在测试中发现,edx
,eax
,ebx
的高位即使不初始化,也常为 0,如果不考虑健壮性(仅这里实验用,实际使用中必须考虑健壮性),几条 xorl
指令可以移除掉。
另外,如果只是为了演示打印字符串,完全可以不用打印换行符,这样下来,代码可以综合优化成如下几条指令:
.global _start
_start:
popl %ecx # argc
popl %ecx # argv[0]
movb $5, %dl # 设置字符串长度
movb $4, %al # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
int $0x80
movb $1, %al
int $0x80
看看效果:
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o
$ sstrip hello
$ wc -c hello
96
合并代码段、程序头和文件头(52字节)
把代码段移入文件头
纯粹的指令只有 96-84=12
个字节了,还有办法再减少目标文件的大小么?如果看了参考资料 [1],看样子你又要蠢蠢欲动了:这 12 个字节是否可以插入到文件头部或程序头部?如果可以那是否意味着还可减少可执行文件的大小呢?现在来比较一下这三部分的十六进制内容。
$ hexdump -C hello -n 52 #文件头(52bytes)
00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00 |........T...4...|
00000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
00000030 00 00 00 00 |....|
00000034
$ hexdump -C hello -s 52 -n 32 #程序头(32bytes)
00000034 01 00 00 00 00 00 00 00 00 80 04 08 00 80 04 08 |................|
00000044 6c 00 00 00 6c 00 00 00 05 00 00 00 00 10 00 00 |l...l...........|
00000054
$ hexdump -C hello -s 84 #实际代码部分(12bytes)
00000054 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |YY..........|
00000060
从上面结果发现 ELF
文件头部和程序头部还有好些空洞(0),是否可以把指令字节分散放入到那些空洞里或者是直接覆盖掉那些系统并不关心的内容?抑或是把代码压缩以后放入可执行文件中,并在其中实现一个解压缩算法?还可以是通过一些代码覆盖率测试工具(gcov
,prof
)对你的代码进行优化?
在继续介绍之前,先来看一个 dd
工具,可以用来直接“编辑” ELF
文件,例如,
直接往指定位置写入 0xff
:
$ hexdump -C hello -n 16 # 写入前,elf文件前16个字节
00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010
$ echo -ne "\xff" | dd of=hello bs=1 count=1 seek=15 conv=notrunc # 把最后一个字节0覆盖掉
1+0 records in
1+0 records out
1 byte (1 B) copied, 3.7349e-05 s, 26.8 kB/s
$ hexdump -C hello -n 16 # 写入后果然被覆盖
00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 ff |.ELF............|
00000010
seek=15
表示指定写入位置为第 15 个(从第 0 个开始)conv=notrunc
选项表示要保留写入位置之后的内容,默认情况下会截断。bs=1
表示一次读/写 1 个count=1
表示总共写 1 次
覆盖多个连续的值:
把第 12,13,14,15 连续 4 个字节全部赋值为 0xff
。
$ echo -ne "\xff\xff\xff\xff" | dd of=hello bs=1 count=4 seek=12 conv=notrunc
$ hexdump -C hello -n 16
00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 ff ff ff ff |.ELF............|
00000010
下面,通过往文件头指定位置写入 0xff
确认哪些部分对于可执行文件的执行是否有影响?这里是逐步测试后发现依然能够执行的情况:
$ hexdump -C hello
00000000 7f 45 4c 46 ff ff ff ff ff ff ff ff ff ff ff ff |.ELF............|
00000010 02 00 03 00 ff ff ff ff 54 80 04 08 34 00 00 00 |........T...4...|
00000020 ff ff ff ff ff ff ff ff 34 00 20 00 01 00 ff ff |........4. .....|
00000030 ff ff ff ff 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 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |....YY..........|
00000060
可以发现,文件头部分,有 30 个字节即使被篡改后,该可执行文件依然可以正常执行。这意味着,这 30 字节是可以写入其他代码指令字节的。而我们的实际代码指令只剩下 12 个,完全可以直接移到前 12 个 0xff
的位置,即从第 4 个到第 15 个。
而代码部分的起始位置,通过 readelf -h
命令可以看到:
$ readelf -h hello | grep "Entry"
Entry point address: 0x8048054
上面地址的最后两位 0x54=84
就是代码在文件中的偏移,也就是刚好从程序头之后开始的,也就是用文件头(52)+程序头(32)个字节开始的 12 字节覆盖到第 4 个字节开始的 12 字节内容即可。
上面的 dd
命令从 echo
命令获得输入,下面需要通过可执行文件本身获得输入,先把代码部分移过去:
$ dd if=hello of=hello bs=1 skip=84 count=12 seek=4 conv=notrunc
12+0 records in
12+0 records out
12 bytes (12 B) copied, 4.9552e-05 s, 242 kB/s
$ hexdump -C hello
00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00 |........T...4...|
00000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
00000030 00 00 00 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 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |....YY..........|
00000060
接着把代码部分截掉:
$ dd if=hello of=hello bs=1 count=1 skip=84 seek=84
0+0 records in
0+0 records out
0 bytes (0 B) copied, 1.702e-05 s, 0.0 kB/s
$ hexdump -C hello
00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
00000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00 |........T...4...|
00000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
00000030 00 00 00 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
这个时候还不能执行,因为代码在文件中的位置被移动了,相应地,文件头中的 Entry point address
,即文件入口地址也需要被修改为 0x8048004
。
即需要把 0x54
所在的第 24 个字节修改为 0x04
:
$ echo -ne "\x04" | dd of=hello bs=1 count=1 seek=24 conv=notrunc
1+0 records in
1+0 records out
1 byte (1 B) copied, 3.7044e-05 s, 27.0 kB/s
$ hexdump -C hello
00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
00000010 02 00 03 00 01 00 00 00 04 80 04 08 34 00 00 00 |............4...|
00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00 |........4. ...(.|
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
修改后就可以执行了。
把程序头移入文件头
程序头部分经过测试发现基本上都不能修改并且需要是连续的,程序头有 32 个字节,而文件头中连续的 0xff
可以被篡改的只有从第 46 个开始的 6 个了,另外,程序头刚好是 01 00
开头,而第 44,45 个刚好为 01 00
,这样地话,这两个字节文件头可以跟程序头共享,这样地话,程序头就可以往文件头里头移动 8 个字节了。
$ dd if=hello of=hello bs=1 skip=52 seek=44 count=32 conv=notrunc
再把最后 8 个没用的字节删除掉,保留 84-8=76
个字节:
$ dd if=hello of=hello bs=1 skip=76 seek=76
$ hexdump -C hello
00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
00000010 02 00 03 00 01 00 00 00 04 80 04 08 34 00 00 00 |............4...|
00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
00000030 00 00 00 00 00 80 04 08 00 80 04 08 60 00 00 00 |............`...|
00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
0000004c
另外,还需要把文件头中程序头的位置信息改为 44,即第 28 个字节,原来是 0x34
,即 52 的位置。
$ echo "obase=16;ibase=10;44" | bc # 先把44转换是16进制的0x2C
2C
$ echo -ne "\x2C" | dd of=hello bs=1 count=1 seek=28 conv=notrunc # 修改文件头
1+0 records in
1+0 records out
1 byte (1 B) copied, 3.871e-05 s, 25.8 kB/s
$ hexdump -C hello
00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
00000010 02 00 03 00 01 00 00 00 04 80 04 08 2c 00 00 00 |............,...|
00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00 |........4. .....|
00000030 00 00 00 00 00 80 04 08 00 80 04 08 60 00 00 00 |............`...|
00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
0000004c
修改后即可执行了,目前只剩下 76 个字节:
$ wc -c hello
76
在非连续的空间插入代码
另外,还有 12 个字节可以放代码,见 0xff
的地方:
$ hexdump -C hello
00000000 7f 45 4c 46 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |.ELFYY..........|
00000010 02 00 03 00 ff ff ff ff 04 80 04 08 2c 00 00 00 |............,...|
00000020 ff ff ff ff ff ff ff ff 34 00 20 00 01 00 00 00 |........4. .....|
00000030 00 00 00 00 00 80 04 08 00 80 04 08 60 00 00 00 |............`...|
00000040 60 00 00 00 05 00 00 00 00 10 00 00 |`...........|
0000004c
不过因为空间不是连续的,需要用到跳转指令作为跳板利用不同的空间。
例如,如果要利用后面的 0xff
的空间,可以把第 14,15 位置的 cd 80
指令替换为一条跳转指令,比如跳转到第 20 个字节的位置,从跳转指令之后的 16 到 20 刚好 4 个字节。
然后可以参考 X86 指令编码表(也可以写成汇编生成可执行文件后用 hexdump
查看),可以把 jmp
指令编码为: 0xeb 0x04
。
$ echo -ne "\xeb\x04" | dd of=hello bs=1 count=2 seek=14 conv=notrunc
然后把原来位置的 cd 80
移动到第 20 个字节开始的位置:
$ echo -ne "\xcd\x80" | dd of=hello bs=1 count=2 seek=20 conv=notrunc
依然可以执行,类似地可以利用更多非连续的空间。
把程序头完全合入文件头
在阅读参考资料 [1]后,发现有更多深层次的探讨,通过分析 Linux 系统对 ELF
文件头部和程序头部的解析,可以更进一步合并程序头和文件头。
该资料能够把最简的 ELF
文件(简单返回一个数值)压缩到 45 个字节,真地是非常极端的努力,思路可以充分借鉴。在充分理解原文的基础上,我们进行更细致地梳理。
首先对 ELF
文件头部和程序头部做更彻底的理解,并具体到每一个字节的含义以及在 Linux 系统下的实际解析情况。
先来看看 readelf -a
的结果:
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o
$ sstrip hello
$ readelf -a hello
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x8048054
Start of program headers: 52 (bytes into file)
Start of section headers: 0 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 1
Size of section headers: 0 (bytes)
Number of section headers: 0
Section header string table index: 0
There are no sections in this file.
There are no sections to group in this file.
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
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个)全部用特别的字体标记出来。
$ hexdump -C hello -n 84
00000000 7f 45 4c 46
01 01 01 00 00 00 00 00 00 00 00 0000000010 02 00 03 00
01 00 00 0054 80 04 08 34 00 00 0000000020
84 00 00 00 00 00 00 0034 00 20 00 01 0028 0000000030
05 00 02 00|01 00 00 00 00 00 00 00 00 80 04 0800000040
00 80 04 0860 00 00 00 60 00 00 00 05 00 00 0000000050
00 10 00 0000000054
上述 |
线之前为文件头,之后为程序头,之前的 000000xx
为偏移地址。
如果要把程序头彻底合并进文件头。从上述信息综合来看,文件头有 4 处必须保留,结合资料 [1],经过对比发现,如果把第 4 行开始的程序头往上平移 3 行,也就是:
00000000 =========
01 01 01 00 00 00 00 00 00 00 00 0000000010 02 00 03 00
01 00 00 0054 80 04 08 34 00 00 0000000020
84 00 00 0000000030 ========= 01 00 00 00 00 00 00 00 00 80 04 08
00000040
00 80 04 0860 00 00 00 60 00 00 00 05 00 00 0000000050
00 10 00 0000000054
把可直接合并的先合并进去,效果如下:
(文件头)
00000000 ========= 01 00 00 00 00 00 00 00 00 80 04 08 (^^ p_vaddr)
00000010 02 00 03 00
60 00 00 0054 80 04 08 34 00 00 0000000020 =================== ^^ 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_phoff
与p_flags
在合并程序头以后,程序头的偏移地址需要修改为 4,即文件的第 4 个字节开始,也就是说 e_phoff
需要修改为 04。
而恰好,p_flags
的 read(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 0054 80 04 08 04 00 00 0000000020 =================== ^^ 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_entry
,p_filesz
,p_memsz
和p_vaddr
从早前的分析情况来看,这 4 个变量基本都依赖 p_vaddr
,也就是程序的加载地址,大体的依赖关系如下:
e_entry = p_vaddr + text offset = p_vaddr + 84 = p_vaddr + 0x54
p_memsz = e_entry
p_memsz >= p_filesz,可以简单取 p_filesz = p_memsz
p_vaddr = page alignment
所以,首先需要确定 p_vaddr
,通过测试,发现p_vaddr
最低必须有 64k,也就是 0x00010000,对应到 hexdump
的 little endian
导出结果,则为 00 00 01 00
。
需要注意的是,为了尽量少了分配内存,我们选择了一个最小的p_vaddr
,如果申请的内存太大,系统将无法分配。
接着,计算出另外 3 个变量:
e_entry = 0x00010000 + 0x54 = 0x00010054 即 54 00 01 00
p_memsz = 54 00 01 00
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 ========
好了,直接把内容烧入:
$ echo -ne "\x01\x00\x00\x00\x00\x00\x00\x00" \
"\x00\x00\x01\x00\x02\x00\x03\x00" \
"\x54\x00\x01\x00\x54\x00\x01\x00\x04" |\
tr -d ' ' |\
dd of=hello bs=1 count=25 seek=4 conv=notrunc
截掉代码(52 + 32 + 12 = 96)之后的所有内容,查看效果如下:
$ dd if=hello of=hello bs=1 count=1 skip=96 seek=96
$ hexdump -C hello -n 96
00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00 |.ELF............|
00000010 02 00 03 00 54 00 01 00 54 00 01 00 04 00 00 00 |....T...T.......|
00000020 84 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00 |........4. ...(.|
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 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |....YY..........|
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 0034 00 20 00 01 0028 0000000030
05 00 02 0000000040
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 0034 00 20 00 01 00 cd 8000000030 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
命令写入,分两段写入:
$ echo -ne "\x59\x59\xb2\x05\xb0\x04\xeb\x06" | \
dd of=hello bs=1 count=8 seek=32 conv=notrunc
$ echo -ne "\xcd\x80\xb0\x01\xcd\x80" | \
dd of=hello bs=1 count=6 seek=46 conv=notrunc
代码合入以后,需要修改文件头中的代码的偏移地址,即 e_entry
,也就是要把原来的偏移 84 (0x54) 修改为现在的偏移,即 0x20。
$ echo -ne "\x20" | dd of=hello bs=1 count=1 seek=24 conv=notrunc
修改完以后恰好把合并进的程序头 p_memsz
,也就是分配给文件的内存改小了,p_filesz
也得相应改小。
$ echo -ne "\x20" | dd of=hello bs=1 count=1 seek=20 conv=notrunc
程序头和代码都已经合入,最后,把 52 字节之后的内容全部删掉:
$ dd if=hello of=hello bs=1 count=1 skip=52 seek=52
$ hexdump -C hello
00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00 |.ELF............|
00000010 02 00 03 00 20 00 01 00 20 00 01 00 04 00 00 00 |....T...T.......|
00000020 59 59 b2 05 b0 04 eb 06 34 00 20 00 01 00 cd 80 |YY......4. .....|
00000030 b0 01 cd 80
$ export PATH=./:$PATH
$ hello
hello
代码和程序头部分合并进文件头的汇总情况:
00000000 7f 45 4c 46
01 00 00 00 00 00 00 00 00 00 01 0000000010
02 00 03 00 20 00 01 00 20 00 01 00 04 00 00 0000000020
59 59 b2 05b0 04 eb 06 34 00 20 00 01 00 cd 8000000030 b0 01 cd 80
最后,我们的成绩是:
$ wc -c hello
52
史上最小的可打印 Hello World
(注:要完全打印得把代码中的5该为13,并且把文件名该为该字符串) 的 Elf
文件是 52 个字节。打破了资料 [1] 作者创造的纪录:
$ cd ELFkickers/tiny/
$ wc -c hello
59 hello
需要特别提到的是,该作者创造的最小可执行 Elf 是 45 个字节。
但是由于那个程序只能返回一个数值,代码更简短,刚好可以直接嵌入到文件头中间,而文件末尾的 7 个 0
字节由于 Linux 加载时会自动填充,所以可以删掉,所以最终的文件大小是 52 - 7 即 45 个字节。
其大体可实现如下:
.global _start
_start:
mov $42, %bl # 设置返回值为 42
xor %eax, %eax # eax = 0
inc %eax # eax = eax+1, 设置系统调用号, sys_exit()
int $0x80
保存为 ret.s,编译和执行效果如下:
$ as --32 -o ret.o ret.s
$ ld -melf_i386 -o ret ret.o
$ ./ret
42
代码字节数可这么查看:
$ ld -melf_i386 --oformat=binary -o ret.bin ret.o
$ hexdump -C ret.bin
0000000 b3 2a 31 c0 40 cd 80
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 字节:
$ cp hello ret
$ echo -ne "\xb3\x2a\x31\xc0\x40\xcd\x80" |\
dd of=ret bs=1 count=8 seek=32 conv=notrunc
$ dd if=ret of=hello bs=1 count=1 skip=45 seek=45
$ hexdump -C hello
00000000 7f 45 4c 46 01 00 00 00 00 00 00 00 00 00 01 00 |.ELF............|
00000010 02 00 03 00 20 00 01 00 20 00 01 00 04 00 00 00 |.... ... .......|
00000020 b3 2a 31 c0 40 cd 80 06 34 00 20 00 01 |.*1.@...4. ..|
0000002d
$ wc -c ret
45 ret
$ ./ret
$ echo $?
42
如果想快速构建该 Elf
文件,可以直接使用下述 Shell 代码:
#!/bin/bash
#
# generate_ret_elf.sh -- Generate a 45 bytes Elf file
#
# $ bash generate_ret_elf.sh
# $ chmod a+x ret.elf
# $ ./ret.elf
# $ echo $?
# 42
#
ret="\x7f\x45\x4c\x46\x01\x00\x00\x00"
ret=${ret}"\x00\x00\x00\x00\x00\x00\x01\x00"
ret=${ret}"\x02\x00\x03\x00\x20\x00\x01\x00"
ret=${ret}"\x20\x00\x01\x00\x04\x00\x00\x00"
ret=${ret}"\xb3\x2a\x31\xc0\x40\xcd\x80\x06"
ret=${ret}"\x34\x00\x20\x00\x01"
echo -ne $ret > ret.elf
又或者是直接参照资料 [1] 的 tiny.asm
就行了,其代码如下:
; ret.asm
BITS 32
org 0x00010000
db 0x7F, "ELF" ; e_ident
dd 1 ; p_type
dd 0 ; p_offset
dd $$ ; p_vaddr
dw 2 ; e_type ; p_paddr
dw 3 ; e_machine
dd _start ; e_version ; p_filesz
dd _start ; e_entry ; p_memsz
dd 4 ; e_phoff ; p_flags
_start:
mov bl, 42 ; e_shoff ; p_align
xor eax, eax
inc eax ; e_flags
int 0x80
db 0
dw 0x34 ; e_ehsize
dw 0x20 ; e_phentsize
db 1 ; e_phnum
; e_shentsize
; e_shnum
; e_shstrndx
filesize equ $ - $$
编译和运行效果如下:
$ nasm -f bin -o ret ret.asm
$ chmod +x ret
$ ./ret ; echo $?
42
$ wc -c ret
45 ret
下面也给一下本文精简后的 hello
的 nasm
版本:
; hello.asm
BITS 32
org 0x00010000
db 0x7F, "ELF" ; e_ident
dd 1 ; p_type
dd 0 ; p_offset
dd $$ ; p_vaddr
dw 2 ; e_type ; p_paddr
dw 3 ; e_machine
dd _start ; e_version ; p_filesz
dd _start ; e_entry ; p_memsz
dd 4 ; e_phoff ; p_flags
_start:
pop ecx ; argc ; e_shoff ; p_align
pop ecx ; argv[0]
mov dl, 5 ; str len ; e_flags
mov al, 4 ; sys_write(fd, addr, len) : ebx, ecx, edx
jmp _next ; jump to next part of the code
dw 0x34 ; e_ehsize
dw 0x20 ; e_phentsize
dw 1 ; e_phnum
_next: int 0x80 ; syscall ; e_shentsize
mov al, 1 ; eax=1,sys_exit ; e_shnum
int 0x80 ; syscall ; e_shstrndx
filesize equ $ - $$
编译和用法如下:
$ nasm -f bin -o hello hello.asm
$ chmod a+x hello
$ export PATH=./:$PATH
$ hello
hello
$ wc -c hello
52
经过一番努力,AT&T
的完整 binary 版本如下:
# hello.s
#
# as --32 -o hello.o hello.s
# ld -melf_i386 --oformat=binary -o hello hello.o
#
.file "hello.s"
.global _start, _load
.equ LOAD_ADDR, 0x00010000 # Page aligned load addr, here 64k
.equ E_ENTRY, LOAD_ADDR + (_start - _load)
.equ P_MEM_SZ, E_ENTRY
.equ P_FILE_SZ, P_MEM_SZ
_load:
.byte 0x7F
.ascii "ELF" # e_ident, Magic Number
.long 1 # p_type, loadable seg
.long 0 # p_offset
.long LOAD_ADDR # p_vaddr
.word 2 # e_type, exec # p_paddr
.word 3 # e_machine, Intel 386 target
.long P_FILE_SZ # e_version # p_filesz
.long E_ENTRY # e_entry # p_memsz
.long 4 # e_phoff # p_flags, read(exec)
.text
_start:
popl %ecx # argc # e_shoff # p_align
popl %ecx # argv[0]
mov $5, %dl # str len # e_flags
mov $4, %al # sys_write(fd, addr, len) : ebx, ecx, edx
jmp next # jump to next part of the code
.word 0x34 # e_ehsize = 52
.word 0x20 # e_phentsize = 32
.word 1 # e_phnum = 1
.text
_next: int $0x80 # syscall # e_shentsize
mov $1, %al # eax=1,sys_exit # e_shnum
int $0x80 # syscall # e_shstrndx
编译和运行效果如下:
$ as --32 -o hello.o hello.s
$ ld -melf_i386 --oformat=binary -o hello hello.o
$ export PATH=./:$PATH
$ hello
hello
$ wc -c hello
52 hello
注:编译时务必要加 --oformat=binary
参数,以便直接基于源文件构建一个二进制的 Elf
文件,否则会被 ld
默认编译,自动填充其他内容。
汇编语言极限精简之道(45字节)
经过上述努力,我们已经完全把程序头和代码都融入了 52 字节的 Elf
文件头,还可以再进一步吗?
基于资料一,如果再要努力,只能设法把 Elf
末尾的 7 个 0 字节删除,但是由于代码已经把 Elf
末尾的 7 字节 0 字符都填满了,所以要想在这一块努力,只能继续压缩代码。
继续研究下代码先:
.global _start
_start:
popl %ecx # argc
popl %ecx # argv[0]
movb $5, %dl # 设置字符串长度
movb $4, %al # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
int $0x80
movb $1, %al
int $0x80
查看对应的编码:
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o --oformat=binary
$ hexdump -C hello
00000000 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |YY..........|
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
,替换后如下:
.global _start
_start:
popl %eax # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
popl %ecx # argv[0], 字符串
movb $5, %dl # 设置字符串长度
int $0x80
movb $1, %al # eax = 1, sys_exit
int $0x80
这里需要传入 4 个参数,即让栈弹出的第一个值,也就是参数个数赋予 eax
,也就是:hello 5 4 1
。
难道我们只能把该代码优化到 10 个字节?
巧合地是,当偶然改成这样的情况下,该代码还能正常返回。
.global _start
_start:
popl %eax # eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
popl %ecx # argv[0], 字符串
movb $5, %dl # 设置字符串长度
int $0x80
loop _start # 触发系统退出
注:上面我们使用了 loop
指令而不是 jmp
指令,因为 jmp _start
产生的代码更长,而 loop _start
指令只有两个字节。
这里相当于删除了 movb $1, %al
,最后我们获得了 8 个字节。但是这里为什么能够工作呢?
经过分析 arch/x86/ia32/ia32entry.S
,我们发现当系统调用号无效时(超过系统调用入口个数),内核为了健壮考虑,必须要处理这类异常,并通过 ia32_badsys
让系统调用正常返回。
这个可以这样验证:
.global _start
_start:
popl %eax # argc, eax = 4, 设置系统调用号, sys_write(fd, addr, len) : ebx, ecx, edx
popl %ecx # argv[0], 文件名
mov $5, %dl # argv[1],字符串长度
int $0x80
mov $0xffffffda, %eax # 设置一个非法调用号用于退出
int $0x80
那最后的结果是,我们产生了一个可以正常打印字符串,大小只有 45 字节的 Elf
文件,最终的结果如下:
# hello.s
#
# $ as --32 -o hello.o hello.s
# $ ld -melf_i386 --oformat=binary -o hello hello.o
# $ export PATH=./:$PATH
# $ hello 0 0 0
# hello
#
.file "hello.s"
.global _start, _load
.equ LOAD_ADDR, 0x00010000 # Page aligned load addr, here 64k
.equ E_ENTRY, LOAD_ADDR + (_start - _load)
.equ P_MEM_SZ, E_ENTRY
.equ P_FILE_SZ, P_MEM_SZ
_load:
.byte 0x7F
.ascii "ELF" # e_ident, Magic Number
.long 1 # p_type, loadable seg
.long 0 # p_offset
.long LOAD_ADDR # p_vaddr
.word 2 # e_type, exec # p_paddr
.word 3 # e_machine, Intel 386 target
.long P_FILE_SZ # e_version # p_filesz
.long E_ENTRY # e_entry # p_memsz
.long 4 # e_phoff # p_flags, read(exec)
.text
_start:
popl %eax # argc # e_shoff # p_align
# 4 args, eax = 4, sys_write(fd, addr, len) : ebx, ecx, edx
# set 2nd eax = random addr to trigger bad syscall for exit
popl %ecx # argv[0]
mov $5, %dl # str len # e_flags
int $0x80
loop _start # loop to popup a random addr as a bad syscall number
.word 0x34 # e_ehsize = 52
.word 0x20 # e_phentsize = 32
.byte 1 # e_phnum = 1, remove trailing 7 bytes with 0 value
# e_shentsize
# e_shnum
# e_shstrndx
效果如下:
$ as --32 -o hello.o hello.s
$ ld -melf_i386 -o hello hello.o --oformat=binary
$ export PATH=./:$PATH
$ hello 0 0 0
hello
$ wc -c hello
45 hello
到这里,我们获得了史上最小的可以打印字符串的 Elf
文件,是的,只有 45 个字节。
小结
到这里,关于可执行文件的讨论暂且结束,最后来一段小小的总结,那就是我们设法去减少可执行文件大小的意义?
实际上,通过这样一个讨论深入到了很多技术的细节,包括可执行文件的格式、目标代码链接的过程、 Linux 下汇编语言开发等。与此同时,可执行文件大小的减少本身对嵌入式系统非常有用,如果删除那些对程序运行没有影响的节区和节区表将减少目标系统的大小,适应嵌入式系统资源受限的需求。除此之外,动态连接库中的很多函数可能不会被使用到,因此也可以通过某种方式剔除 [8],[10] 。
或许,你还会发现更多有趣的意义,欢迎给我发送邮件,一起讨论。
参考资料
- A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux
- UNIX/LINUX 平台可执行文件格式分析
- C/C++ 程序编译步骤详解
- The Linux GCC HOW TO
- ELF: From The Programmer’s Perspective
- Understanding ELF using readelf and objdump
- Dissecting shared libraries
- 嵌入式 Linux 小型化技术
- Linux 汇编语言开发指南
- Library Optimizer
- ELF file format and ABI:[1],[2],[3],[4]
- i386 指令编码表