转载: ELF文件格式研究
日期: Friday, July 25 @ 14:46:16 CST
主题: 软件开发
发信人: someone (++), 信区: Linux
标 题: 怎样创建真正很小的Linux下的ELF可执行文件
发信站: 红肩章 (2001年09月26日11:36:50 星期三), 站内信件
【 以下文字转载自 someone 的信箱 】
[
最近在看ELF格式和Linux程序装载方面的东东,发现一片挺有意思的文章,翻译了一下
。
555,花了一个晚上和一个上午。希望有人喜欢。
原文地址:
还有一个后记,懒得翻了,直接贴出来吧。
2001.9.26
]
A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux
(or, "Size Is Everything")
――――――――――――――――――――――――――――――――――――
She studied it carefully for about 15 minutes. Finally, she spoke. "There's
something written on here," she said, frowning, "but it's really teensy."
[Dave Barry, "The Columnist's Caper"]
如果你是一个对软件体积肿胀受够了的程序员,那么可能你能在这里找到合适的解决
方法。
本文讨论把额外的字节榨出简单程序的方法。(当然,本文更实际的目的是描述ELF
文件格式和Linux操作系统的一些内部工作。但是希望你也能在这个过程中学到一些
如何创建真正很小的ELF可执行文件的知识。)
请注意这里给出的信息和示例,绝大部分,都是针对运行于Intel-386体系结构下的
Linux平台上的ELF可执行文件的。我猜测这些信息中很有一部分也适用于其它基于ELF
的Unix系统,但是我在这方面的经验太有限了所以不能肯定。
本文中出现的汇编代码是要使用Nasm汇编的。(除了更适合于我们的需要之外,Nasm
的语法对于那些在学会使用Gas之前学习x86汇编语言的人来说比AT&T语法更好。)Nasm
可以免费获得并且非常容易移植;参见
也请注意如果你对汇编代码不是特别熟,你会发现本文的一些部分很难懂。
――――――――――――――――――――――――――――――――――――――
为了开始工作,我们需要一个程序。几乎任何程序都可以,但是程序越简单越好,因为
我们更感兴趣的是我们能把可执行文件做成多么小而不是能把程序作多么小。
让我们拿一个非常非常简单的程序,它什么也不作只是向操作系统返回一个数。为什么
不呢?毕竟,Unix已经带了至少两个这种程序:true和false。既然0和1已经被用过了,
我们就使用数42吧。
所以,这就是我们的第一版:
/* tiny.c */
int main(void) { return 42; }
我们可以进行如下的编译和测试:
$ gcc -Wall tiny.c
$ ./a.out ; echo $?
42
好了。它有多大呢?在我的机器上,有:
$ wc -c a.out
3998 a.out
(在你的机器上可能会稍有不同。)应该承认,按当今的标准来说这已经是非常小了。
但是它几乎肯定比它需要的要大。
很显然的第一步是strip可执行文件:
$ gcc -Wall -s tiny.c
$ ./a.out ; echo $?
42
$ wc -c a.out
2632 a.out
这的确有改善。下一步,优化会怎么样?
$ gcc -Wall -s -O3 tiny.c
$ wc -c a.out
2616 a.out
这也有所帮助,但是不多。这是合理的:程序中几乎没有什么可以优化的。
看起来好像我们再也不能缩减一个只有一条语句的C程序了。我们将抛弃C,转而使用汇
编。希望这将把C程序自动带来的额外开销砍掉。
现在,向我们的第二版进军。我们需要做的就是从main()中返回42。在汇编语言中,这
意味着该函数应该把累加器,eax,设置为42,然后返回:
; tiny.asm
BITS 32
GLOBAL main
SECTION .text
main:
mov eax, 42
ret
然后我们可以build并测试如下:
$ nasm -f elf tiny.asm
$ gcc -Wall -s tiny.o
$ ./a.out ; echo $?
42
(嘿,谁说汇编代码很难呀?)现在有多大?
$ wc -c a.out
2604 a.out
看起来我们只去掉了区区的12字节。难道C只自动引进这么多的额外开销吗?
问题在于,通过使用main()接口我们仍然引进了很大开销。链接器仍然为我们添加一个
到OS的接口,真正调用main()的是那个接口。那如果我们不需要它的话该怎么做?
链接器真正使用的入口点缺省是名字为_start的符号。当我们使用gcc链接时,它自动
包含一个_start例程,该例程将设置argc和argv,以及其他事情,然后调用main()。
那么,看看我们能否绕过这一点。定义我们自己的_start例程:
; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
mov eax, 42
ret
gcc会按我们想要的去做吗?
$ nasm -f elf tiny.asm
$ gcc -Wall -s tiny.o
tiny.o(.text+0x0): multiple definition of `_start'
/usr/lib/crt1.o(.text+0x0): first defined here
/usr/lib/crt1.o(.text+0x36): undefined reference to `main'
不会。嗯,实际上,它会的,但是首先我们得知道怎样才能得到我们想要的东西。
原来gcc能识别一个选项-nostartfiles。从gcc的info页上可以看到:
-nostartfiles
Do not use the standard system startup files when linking. The
standard libraries are used normally.
耶!现在看看我们能做些什么:
$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
$ ./a.out ; echo $?
Segmentation fault
139
gcc不抱怨了,但是程序不能工作。错在哪里?
错误在于我们把_start当作它好像是一个C函数,并且试图从它返回。实际上,它根本
不是一个函数。它只是目标文件中链接器用来定位程序入口点的一个符号。当我们的程
序被激活时,它被直接激活。如果我们去查看一下,将会发现栈顶上是数1,这显然不
像是一个地址。事实上,栈顶上是我们程序的argc值。在这之后是argv数组的元素,包
括结束时的NULL元素,接着是envp的元素。这就是全部。在栈顶上没有返回地址。
那,_start是如何退出的?它调用了exit()函数!毕竟,这就是它出现的作用。
实际上,我说谎了。它真正做的是调用_exit()函数。[译注:原文如此。标准启动中
_start还是调用exit()。](注意前面的下划线。)exit()要为进程进行某些任务的结
束处理,但是这些任务将不会被启动,因为我们绕过了库的启动代码。所以我们也需要
绕过库的结束代码,直接到达操作系统的结束处理。
好,让我们再试一下。我们将要调用_exit(),这是一个需要一个整数参数的函数。所
以我们需要做的就是把那个数压到栈上并调用该函数。(我们还需要声明_exit()为外
部)下面是我们的汇编:
; tiny.asm
BITS 32
EXTERN _exit
GLOBAL _start
SECTION .text
_start:
push dword 42
call _exit
然后我们像前面那样build和测试:
$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
$ ./a.out ; echo $?
42
终于成功了!现在看它有多大:
$ wc -c a.out
1340 a.out
几乎只有一半的大小!不错。真得不错。Hmmm...那gcc还有什么别的有意思的选项吗?
这一个,在文档中紧接着-nostartfiles的,很是显眼:
-nostdlib
Don't use the standard system libraries and startup files when
linking. Only the files you specify will be passed to the linker.
这值得研究一下:
$ gcc -Wall -s -nostdlib tiny.o
tiny.o(.text+0x6): undefined reference to `_exit'
Oops。是的..._exit()毕竟是一个库函数。它必须要被填充。
好吧。但是肯定,我们并不需要libc的帮助来结束一个程序,不是吗?
是的,我们不需要。如果我们愿意抛弃所有可移植性要求,我们可以退出程序而不需要
和任何其他东西链接。然而,首先我们需要了解如何在Linux下进行系统调用。
――――――――――――――――――――――――――――――――――――――
Linux,像大多数操作系统一样,通过系统调用对它支持的程序提供基本的必需功能。这
包括打开文件,读写文件句柄――当然,也包括结束一个进程。
Linux系统调用接口是一条指令:int 0x80。所有的系统调用都通过这个中断进行。要进
行一个系统调用,eax应当包含一个数来指明那个系统调用被调用,并且其他寄存器用于
传递参数,如果有的话。如果系统调用需要一个参数,它将在ebx里;两个参数的系统调
将使用ebx和ecx。类似的,edx,esi,和edi将分别被使用,如果需要第三、第四、第五
个参数的话。当从一个系统调用返回后,eax将包含返回值。如果发生错误,eax将包含
一个负值,其绝对值指出错误。
不同系统调用的号码在/usr/include/asm/unistd.h中列出。查看一下就知道exit系统调
用被分配的号码是1。类似于C函数,它需要一个参数,即返回给父进程的值,所以这将
通过ebx传递。
现在我们知道了如何创建我们程序的下一个版本,这个版本不需要任何外部函数的辅助
就可以工作:
; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
mov eax, 1
mov ebx, 42
int 0x80
接下来:
$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostdlib tiny.o
$ ./a.out ; echo $?
42
哈哈!大小是?
$ wc -c a.out
372 a.out
现在已经是非常小了。几乎是上一个版本大小的四分之一。
那...我们还能做些什么把它变得更小吗?
使用更短的指令怎么样?
如果我们为汇编代码生成一个list文件,就会发现如下:
00000000 B801000000 mov eax, 1
00000005 BB2A000000 mov ebx, 42
0000000A CD80 int 0x80
嗯,我们不需要初始化ebx的全部,因为操作系统只使用最低字节。只设置bl就足够了,
这将占用两个字节而不是五个。
我们还可以通过把eax xor成0然后使用一个字节的增量指令来把eax设成1。这将又节省
两个字节。
00000000 31C0 xor eax, eax
00000002 40 inc eax
00000003 B32A mov bl, 42
00000005 CD80 int 0x80
我想现在说我们再也不能把这个程序变得更小已经很安全了。
另外,我们可以不再用gcc来链接我们的可执行文件,因为我们没有使用它的任何附加
功能,我们可以自己调用链接器,ld:
$ nasm -f elf tiny.asm
$ ld -s tiny.o
$ ./a.out ; echo $?
42
$ wc -c a.out
368 a.out
又小了4个字节。(嘿!我们不是砍掉了5个字节吗?是的,我们是砍了5个字节,但是
ELF文件的对齐考虑导致它需要一个额外字节的填充。)
那么...我们到头了么?这就是我们所能达到的最小么?
姆。我们的程序现在是7个字节长。ELF文件真的需要361字节的开销?文件里面到底是
什么?
我们可以使用objdump察看文件的内容:
$ objdump -x a.out | less
输出可能看起来有点混乱,但现在让我们集中看一下节(section)列表:
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000007 08048080 08048080 00000080 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .comment 0000001c 00000000 00000000 00000087 2**0
CONTENTS, READONLY
完整的.text节在列表中是7字节长,正如我们所指出的。所以好像可以下结论说我们现
在可以完全控制程序的机器语言内容了。
但是还有另一个名为“.comment”的节。为什么会有它?并且它有28字节长,竟然!我
们不能确定这个.comment节是什么,但是好像它并不是必需的...
.comment节在列表中显示位于文件偏移00000087(十六进制)。如果我们使用一个
hexdump程序来查看一下文件该区域的内容,会发现:
00000080: 31C0 40B3 2ACD 8000 5468 6520 4E65 7477 1.@.*...The Netw
00000090: 6964 6520 4173 7365 6D62 6C65 7220 302E ide Assembler 0.
000000A0: 3938 0000 2E73 796D 7461 6200 2E73 7472 98...symtab..str
噢,噢,噢。谁会想到Nasm会这样破坏我们所追求的东西呢?或许我们需要转而使用gas
,尽管要用AT&T语法...
哎,如果我们这样做:
; tiny.s
.globl _start
.text
_start:
xorl %eax, %eax
incl %eax
movb $42, %bl
int $0x80
...我们发现:
$ gcc -s -nostdlib tiny.s
$ ./a.out ; echo $?
42
$ wc -c a.out
368 a.out
...没有区别!
实际上,是有一点区别的。再次使用objdump,我们会发现:
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000007 08048074 08048074 00000074 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000000 0804907c 0804907c 0000007c 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0804907c 0804907c 0000007c 2**2
ALLOC
没有了comment节,但是现在有两个没有用处的节来存储并不存在的数据。尽管这些节
是0字节长,它们确实有开销,毫无道理的使我们的文件体积变大。
那么,这些开销是什么呢?我们怎样去掉它?
为了回答这些问题,我们必须深入一些。我们需要理解ELF格式。
――――――――――――――――――――――――――――――――――――――
描述Intel-386体系结构ELF格式的规范文档可以在
packages/GCC/ELF.doc.tar.gz找到。如果你不喜欢Postscript文档,你可以在
了很多领域,所以如果你不想读完整个文档,我可以理解。基本上,下面的东西是我们
需要知道的:
每个ELF文件都以一个称为ELF头的结构开始。这个结构是52字节长,包含一些描述文件
内容的信息。例如,最开始的16个字节包含一个“标识”,包括文件的幻数(magic-
number)签名(7F 45 4C 46),及一些一字节标志,用来指示文件内容是32位还是64
位,little-endian还是big-endian,等等。ELF头中的其他域包括:目标体系结构;
ELF文件是一个可执行文件,一个目标文件,还是一个共享库(shared-object library
);程序的起始地址;以及程序头表(program header table)和节头表(section
header table)在文件中的位置。
这两个表可以位于文件中任何地方,但是通常前者紧接着ELF头,后者位于或接近于文件
尾。这两个表的目的类似,他们标识文件的组成部分。但是,节头表更侧重于标识程序
的各部分位于文件中什么地方,而程序头表描述这些部分如何以及被装载的内存中的何
处。简单的说,节头表是给编译器和链接器用的,而程序头表是给程序装载器用的。程
序头表对目标文件是可选的,并且在实际中目标文件从来没有它。类似,节头表对可执
行文件是可选的――但是可执行文件几乎都有它。
好了,这就是我们第一个问题的答案。我们程序中的相当一部分开销是完全不必要的节
头表,以及可能是同样没有用处的节,这些节不参与程序的内存映像。
来看我们的第二个问题:我们如何去掉这些东西?
哎,我们只能靠自己。没有任何标准工具会被设计成可以产生没有节头表的可执行文件
。
如果我们想这么做,我们必须自己动手。
这并不意味着我们必须找一个二进制编辑器并且手工写下十六进制值。老Nasm有一种平
板二进制输出格式,这刚好可以为我们所用。现在我们所需要的就是一个空ELF可执行文
件的映像,从而我们可以填充进我们自己的程序。我们程序,没有什么别的了。
我们可以查看ELF规范,和/usr/include/linux/elf.h,以及标准工具创建的可执行文
件,来推测出空的ELF可执行文件应该是什么样子。但是,如果你是那种没有耐心的人,
你可以直接使用下面我提供的这个:
BITS 32
org 0x08048000
ehdr: ; Elf32_Ehdr
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsize ; e_ehsize
dw phdrsize ; e_phentsize
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
ehdrsize equ $ - ehdr
phdr: ; Elf32_Phdr
dd 1 ; p_type
dd 0 ; p_offset
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesize ; p_filesz
dd filesize ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsize equ $ - phdr
_start:
; your program here
filesize equ $ - $$
这个映像包含一个ELF头,指出文件是一个Intel-386可执行文件,没有节头表,有一个
包含一项的程序头表。那一项指示程序装载器把整个文件装载到内存(程序在其内存映
像中包含ELF头和程序头表是正常行为)中内存地址0x08048000处(这是可执行文件装
载的缺省地址),然后从_start开始执行代码,_start出现在紧接着程序头表处。没有
.data段,没有.bss段,没有注释――除了必需的东西外什么也没有。
那,让我们加入我们自己的小程序:
; tiny.asm
org 0x08048000
;
; (as above)
;
_start:
mov bl, 42
xor eax, eax
inc eax
int 0x80
filesize equ $ - $$
试一下:
$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
我们完全通过拼凑(from scratch)创建了一个可执行文件。如何?现在,看看它的大
小:
$ wc -c a.out
91 a.out
91个字节。是我们上一次的四分之一大小还不到,是我们最初版本大小的四十分之一还
不到!
而且,这一次我们可以算计每一个字节。我们确切的知道可执行文件中是什么东西,以
及为什么需要它。终于,到达了极限。我们再也不能做得更小了。
是么?
――――――――――――――――――――――――――――――――――――――
如果你真地读了ELF规范,你可能会发现几个事实。1)ELF文件的不同部分可以位于任何
位置(除了ELF头,它必须位于文件头部),并且它们可以相互重叠。2)头中的某些域
并没有真正被使用。
具体地说,我在考虑那16字节的标识域尾部的9个字节的0。他们纯是填充,为ELF标准将
来的扩展留下空间。所以OS不应该关心那里面是什么东西。并且我们已经把所有的东西
都装载到内存了,而我们的程序只有7字节长...
我们可以把自己的代码放进ELF头里面么?
为什么不呢?
; tiny.asm
BITS 32
org 0x08048000
ehdr: ; Elf32_Ehdr
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0
_start: mov bl, 42
xor eax, eax
inc eax
int 0x80
db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsize ; e_ehsize
dw phdrsize ; e_phentsize
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
ehdrsize equ $ - ehdr
phdr: ; Elf32_Phdr
dd 1 ; p_type
dd 0 ; p_offset
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesize ; p_filesz
dd filesize ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsize equ $ - phdr
filesize equ $ - $$
毕竟,一个字节也是字节啊!
$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
84 a.out
还不错吧?
现在我们真的走到头了。我们的文件只有一个ELF头和一个程序头表项,要把程序装进
内存并运行这两者都是绝对需要的。所以现在没有什么可以缩减了。
除了...
如果我们能对程序头表做一下刚才对程序所做的事情会怎么样?也就是说,把它和ELF
头重叠。这可能么?
这真的可能。看一下我们的程序。注意ELF头中的最后8个字节和程序头表的开始8个字
节有某种相像。这种相像可以被描述为“相同”。
所以...
; tiny.asm
BITS 32
org 0x08048000
ehdr:
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0
_start: mov bl, 42
xor eax, eax
inc eax
int 0x80
db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsize ; e_ehsize
dw phdrsize ; e_phentsize
phdr: dd 1 ; e_phnum ; p_type
; e_shentsize
dd 0 ; e_shnum ; p_offset
; e_shstrndx
ehdrsize equ $ - ehdr
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesize ; p_filesz
dd filesize ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsize equ $ - phdr
filesize equ $ - $$
并且肯定,Linux一点也不在意我们的吝啬:
$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
76 a.out
现在我们真的到了最低了。再没有办法把两个结构重叠了。它们的字节不匹配。这是底
限了!
除非,我们能够修改结构的内容使它们匹配的更多。
到底Linux实际查看了这些域中的多少呢?例如,Linux真的检查e_machine域包含3(指
示为Intel-386目标),还是仅仅假定它就是?
实际上,在上面例子中Linux真的检查。但是很多的其他域都被悄悄的忽略了。
下面是ELF头中的必要部分。首四个字节必须包含幻数,否则Linux不会执行它。然而,
e_ident域中的其它3个字节不被检查,这意味着我们有不少于12个连续的字节可以设置
成任何东西。e_type必须被置成2,来指示是可执行文件,e_machine必须是3,正如刚
才所说的。e_version,像e_ident中的版本号一样,完全被忽略。(这是可以理解的,
因为目前ELF标准只有一个版本。)e_entry自然必须有效,因为它指向程序的开始。并
且显然,e_phoff需要包含程序头表在文件中正确的偏移,e_phnum需要包含这个表中正
确的项数。然而,e_flag在文档中指出现在对Intel来说没有使用,所以它可以被我们
利用。e_ehsize应该被用于验证ELF头有期望的大小,但是Linux没有管它。e_phentsize
类似,用于验证程序头表项的大小。它被检查了,但是只是在2.2内核的版本2.2.17之
后。2.2内核的早期版本忽略了它,2.4.0也忽略了。ELF头中的其它东西是关于节头表
的,这在可执行文件中没有作用。
程序头表项又如何呢?p_type必须包含1,标志它是一个可装载段。p_offset也真的需
要包含开始装载的正确的文件偏移。类似,p_vaddr需要包含适当的装载地址。注意,
我们并没有被要求装载到0x08048000。几乎可以使用任何地址,只要它位于0x0000000
之上,0x80000000之下,并且是页对齐的。p_paddr域在文档中指出是被忽略的,所以
它是可用的。p_filesz指示从文件中装载多少到内存,p_memsz指示内存段需要有多大,
所以这些数应该是健康的。p_flags只是要给予内存段什么权限。它需要是可读的(4)
,否则根本不可用,并且需要是可执行的(1),否则我们不能执行其中的代码。其他
位也可以被设置,但是我们至少需要这些。最后,p_align给出内存段的对齐要求。这
个域主要在重定位包含位置无关代码(position-independent code)的段(如对于共
享库)时使用,所以对于可执行文件Linux将忽略我们存储在里面的任何垃圾信息。
总而言之,还是有很多回旋余地的。特别的,仔细的审查可以发现ELF头中的大部分必
需域位于前半部分――后半部分几乎可以完全用来种绿豆(free for munging)。知道
了这些,我们可以把两个结构重叠的更多一些:
; tiny.asm
BITS 32
org 0x00200000
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0
_start:
mov bl, 42
xor eax, eax
inc eax
int 0x80
db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
phdr: dd 1 ; e_shoff ; p_type
dd 0 ; e_flags ; p_offset
dd $$ ; e_ehsize ; p_vaddr
; e_phentsize
dw 1 ; e_phnum ; p_paddr
dw 0 ; e_shentsize
dd filesize ; e_shnum ; p_filesz
; e_shstrndx
dd filesize ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
filesize equ $ - $$
(希望)你可以看到,现在程序头表的开始12个字节与ELF头的最后12个字节重叠了。
实际上,这两者吻合得非常好。ELF头中重叠区域里面只有两部分有关系。第一个是
e_phnum域,它刚好遇p_paddr域一致,p_paddr是程序头表中确定被忽略的少数域之一。
另一个是e_phentsize域,它和p_vaddr域的头半部一致。这是通过为我们的程序选择一
个非标准的装载地址而达到的,其头半部分等于0x0020。
现在我们真的抛弃了所有的可移植性...
$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
64 a.out
...但是它能工作!并且程序又小了12字节,正如我们预测的。
这就是我所说的我们再也不能比这做得更好了,但是当然,我们已经知道了我们可以―
―如果我们能够把程序头表完全放进ELF头中。这能做到么?
我们不能简单得把它再移上12字节而不遇到没有希望的障碍――需要使两个结构中几个
域匹配。仅有的另一种可能是让它紧接着开始的4个字节开始。这可以把程序头表的前半
部分舒适地放进e_ident区域中,但是其余部分还有问题。在一些试验之后,看起来这
不太可能达到了。
然而,结果表明程序头表中还有几个域我们可以使用的。
我们指出了p_memsz指示为内存段分配多少内存。显然它至少要和p_filesz一样大,但是
如果它更大也不会有什么危害...
其次,结果证明,与每个人的期望相反,可执行位可以从p_flags域中去掉,而Linux将
为我们把它置位。为什么会这样,老实说我并不知道――或许是因为Linux发现入口点
位于这个段中?不管如何,它可以工作。
所以,有了这些事实,我们可以把文件重新组织成这个小畸形物:
; tiny.asm
BITS 32
org 0x00001000
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 filesize ; 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
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
filesize equ $ - $$
p_flags域被从5改成了4,如我们指出的这么做我们能够脱身。这个4也是e_phoff域的
值,它给出了程序头表在文件中的偏移,这刚好是我们放它的地方。程序(还记得它
吗?)被移动到了ELF头的低半部分,从e_shoff域开始并结束于e_flags域中。
注意装载地址被变成了一个更低的数――尽可能的低,实际上是。这使得e_entry域保
持为一个小的数,这样有好处因为它也是p_memesz数。(实际上,对于虚拟内存这几乎
没有关系――我们可以保留它为原先的值可能也能正常工作。但是礼貌一些总没有坏
处。)
那现在...
$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
52 a.out
...现在,程序头表和程序自身都完全嵌入到了ELF头中,我们的可执行文件现在刚好就
是ELF头的大小。不大不小。并且仍然能在Linux下顺利运行。
现在,最终,我们真真的并且当然的到达了绝对的最小可能值。这没什么问题了,是吧
?毕竟,我们必须要有一个完整的ELF头(尽管它被破坏得乱七八糟),否则Linux不会
理我们!
对吗?
错。我们还有最后一个dirty技巧没有用。
情况是如果文件不是一个完整的ELF头的大小,Linux仍然能工作,并且用0填充所缺的字
节。我们在文件尾部至少有7个0,如果我们把它们从文件映像中扔掉:
; tiny.asm
BITS 32
org 0x00001000
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 filesize ; 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 a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
45 a.out
现在,终于终于,我们真的到了我们所能达到的地方。我们无法避免文件中的第45个字
节,它指出程序头表中的项数,需要为非零,需要存在,并且需要位于从ELF头开始的
第45个位置处的事实。我们被迫要承认再没有什么可以做的了。
――――――――――――――――――――――――――――――――――――――
这个45字节的文件比我们用标准工具所能创建的最小的ELF可执行文件的八分之一都要
小,并且比我们使用纯C代码所能创建的最小文件的四十分之一都要小。我们已经把任
何可能的东西都从文件中剔除了,并且尽可能得让文件中的内容具有双重目的。
当然,这个文件中的半数内容违反了ELF标准的某些部分,然而Linux仍然愿意认它(
sneeze on it),这真是一个奇迹,更不用说Linux还会给它一个进程ID了。这不是那
种人们愿意吐露其作者身份的程序。
另一方面,这个可执行文件中的每一个字节都是有理由存在并且可以被证明的。最近你
创建了多少可以这么说的可执行文件呢?
阅读(749) | 评论(0) | 转发(0) |