您的位置:首页 > 运维架构 > Linux

怎样创建真正很小的Linux下的ELF可执行文件————X86-64 Ubuntu实践

2012-12-17 17:05 316 查看
/***

***by hjs.hust

***hjs.hust@gmail.com

***2012-12-17

***/

参考:http://www.muppetlabs.com/~breadbox/software/tiny/teensy.html 

相关软件工具:

    readelf:readelf显示一个或多个elf格式的目标文件信息,还可以反汇编(具体看手册),可通过各种参数选项来控制显示目标文件的特定信息

    nm:主要功能是列出目标文件中的符号,这样可以帮助程序员定位和分析程序和目标文件中的符号信息

    objdump:显示文件的特定信息

    ar:用于创建、修改、提取归档文件

    objcopy:转换文件格式(只能是BFD库中所描述的文件格式)    

elf定义:/usr/include/elf.h中有elf的更详细的结构定义,或者man elf

程序:HelloWorld.c

开始:

        $ readelf -a hw

    只看头部信息:        

        $ readelf -h hw

开始:

  //tiny.c

    int main(void)

    {

            return 42;

    }

执行一下:

    $ gcc -Wall tiny.c

    $ ./a.out ; echo $?    #print return result   

看下文件多大:

    $ wc -c a.out

    8655 a.out

 strip可执行文件,编译到汇编语言,不进行汇编和链接

    $ gcc -Wall -S tiny.c

    $ wc -c a.out

    6296 a.out

进行优化

    $ gcc -Wall -S -O3 tiny.c

    $ wc -c a.out

    6296 a.out        //没有变化,程序中几乎没有什么可以优化的

下面用汇编了:

    ; tiny.asm

    BITS 64

    GLOBAL main

    SECTION .text

    main:

    mov eax, 42

    ret

编译:

    $ nasm -f elf64 tiny.asm

    $ gcc -Wall -s tiny.o

    $ wc -c a.out

    6296 a.out    //天哪,有没变,难道C较之汇编得没有额外开销吗?

问题是,通过使用main()接口我们仍然引进了很大开销。链接器仍然为我们添加一个到OS的接口,真正调用main()的是那个接口。那如果我们不需要它的话该怎么做?链接器真正使用的入口点缺省是名字为_start的符号。当我们使用gcc链接时,它自动包含一个_start例程,该例程将设置argc和argv,以及其他事情,然后调用main()。那么,看看我们能否绕过这一点。定义我们自己的_start例程:

    ; tiny.asm

    BITS 64

    GLOBAL _start

    SECTION .text

    _start:

    mov eax, 42

    ret

编译:

    $ nasm -f elf64 tiny.asm

    $ gcc -Wall -s -nostartfiles  tiny.o         //注意添加这个参数

但是:

    $ ./a.out ; echo $?

    段错误 (核心已转储)

    139

错在哪里?错误在于我们把_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

好吧,这在32位机里面是正确的,但到了64咋就不行了呢(返回232),不过不要紧,下面还有。

gcc还有什么别的有意思的选项吗?在文档中紧接着-nostartfiles的,很是显眼:-nostdlib

    Don't use the standard system libraries and startup files whenlinking. Only the files you specify will be passed to the linker.

    $ gcc -Wall -s -nostdlib tiny.o

    tiny.o:在函数‘_start’中:

    tiny.asm:(.text+0x3):对‘_exit’未定义的引用

    collect2: 错误: ld 返回 1

是的..._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 elf64 tiny.asm && gcc -Wall -s -nostartfiles  tiny.o && ./a.out ; echo $?

    42

    $ wc -c a.out

    4832 a.out

我们可以不再用gcc来链接我们的可执行文件,因为我们没有使用它的任何附加功能,我们可以自己调用链接器,ld:

    $ nasm -f elf64 tiny.asm && ld -s tiny.o && ./a.out ; echo $?

    42

    hjs@hjs-GREATWALL-PC:~/ELF$ wc -c a.out

    352 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

我想现在说我们再也不能把这个程序变得更小已经很安全了。

    ; tiny.asm

    BITS 64

    GLOBAL _start

    SECTION .text

    _start:

    xor eax, eax

    inc eax

    mov bl, 42

    int 0x80

看下:

    $ nasm -f elf64 tiny.asm && ld -s tiny.o && ./a.out ; echo $?

    42

    $ wc -c a.out

    352 a.out

好吧,理论上讲应该要又小了4个字节,但是事实上没有少,靠。(嘿!我们不是砍掉了5个字节吗?是的,我们是砍了5个字节,但是ELF文件的对齐考虑导致它需要一个额外字节的填充。)那么...我们到头了么?这就是我们所能达到的最小么?我们的程序现在是7个字节长。ELF文件真的需要361字节的开销?文件里面到底是什么? 我们可以使用objdump察看文件的内容:

    $ objdump -x a.out

哈哈,我发现我的输出已经没了什么.comment节,没办法,我用的nasm版本高,哇咔咔。。。所以。。。现在让我们集中看一下节(section)列表:

节:

Idx Name          Size      VMA               LMA               File off  Algn

  0 .text         00000008  0000000000400080  0000000000400080  00000080  2**4

                  CONTENTS, ALLOC, LOAD, READONLY, CODE

完整的.text节在列表中是7字节长(好吧,我这里是8),正如我们所指出的。所以好像可以下结论说我们现在可以完全控制程序的机器语言内容了。(如果你遇到下面情况的话:但是还有另一个名为“.comment”的节。为什么会有它?并
ec02
且它有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

...我们发现:

    $ as -o tiny1.o tiny.s

    $ ld -s tiny.o && ./a.out ; echo $?

    42

    $ wc -c  a.out

    352 a.out

...没有区别!实际上,再次使用objdump,我们会发现这回出来的东西和高版本的NASM完全一样!!!但是如果你碰到下面情况,请继续:

    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格式的规范文档可以在ftp://tsx.mit.edu/pub/linux/packages/GCC/ELF.doc.tar.gz找到。如果你不喜欢Postscript文档,你可以在http://www.muppetlabs.com/~breadbox/software/ELF.txt找到一个纯文本版本的。该规范覆盖了很多领域,所以如果你不想读完整个文档,我可以理解。基本上,下面的东西是我们需要知道的:

每个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可执行文件应该是什么样子。

这个映像包含一个ELF头,指出文件是一个Intel-386(x86-64)可执行文件,没有节头表,有一个包含一项的程序头表。那一项指示程序装载器把整个文件装载到内存(程序在其内存映像中包含ELF头和程序头表是正常行为)中内存地址0x08048000处(这是可执行文件装载的缺省地址),然后从_start开始执行代码,_start出现在紧接着程序头表处。没有.data段,没有.bss段,没有注释——除了必需的东西外什么也没有。那,让我们加入我们自己的小程序:

    ;tiny.asm

    org 0x08048000

    ehdr: ; Elf64-x86-64_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: ; Elf64-x86-64_Ehdr

    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:

    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

    93 a.out

93个字节。是我们上一次的四分之一大小还不到,是我们最初版本大小的四十分之一还不到!而且,这一次我们可以算计每一个字节。我们确切的知道可执行文件中是什么东西,以及为什么需要它。终于,到达了极限。我们再也不能做得更小了。真的是这样子的么?

如果你真地读了ELF规范,你可能会发现几个事实。

    1)ELF文件的不同部分可以位于任何位置(除了ELF头,它必须位于文件头部),并且它们可以相互重叠。

    2)头中的某些域并没有真正被使用。

具体地说,我在考虑那16字节的标识域尾部的9个字节的0。他们纯是填充,为ELF标准将来的扩展留下空间。所以OS不应该关心那里面是什么东西。并且我们已经把所有的东西都装载到内存了,而我们的程序只有7字节长...我们可以把自己的代码放进ELF头里面么?为什么不呢?

    ; tiny.asm

    BITS 32

    org 0x08048000

    ehdr: ; Elf64-x86-64_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: ; Elf64-x86-64_Ehdr

    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

    $ ./a.out ; echo $?

    42

    $ wc -c a.out

    84 a.out

还不错吧?现在我们真的走到头了。我们的文件只有一个ELF头和一个程序头表项,要把程序装进内存并运行这两者都是绝对需要的。所以现在没有什么可以缩减了。除了... 如果我们能对程序头表做一下刚才对程序所做的事情会怎么样?也就是说,把它和ELF头重叠。这可能么?这真的可能!看一下我们的程序。注意ELF头中的最后8个字节和程序头表的开始8个字节有某种相像。这种相像可以被描述为“相同”。所以...

    dw 1

    dw 0

    dw 0

    dw 0

    ehdrsize equ $ - ehdr

    phdr:

    dd 1

    dd 0

改写为:

    phdr:

    dd 1

    dd 0

    ehdrsize equ $ - ehdr

如此,我们的程序:

    ; 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

    $ ./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只是要给予内存段什么权限。它需要是可读的,否则根本不可用,并且需要是可执行的,否则我们不能执行其中的代码。其他位也可以被设置,但是我们至少需要这些。最后,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

    $ ./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,如果我们把它们从文件映像中扔掉:

    dw 1 ; e_phnum

    dw 0 ; e_shentsize

    dw 0 ; e_shnum

    dw 0 ; e_shstrndx

全部换成

    db 1

这样我们的程序:

    ; 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了。这不是那种人们愿意吐露其作者身份的程序。另一方面,这个可执行文件中的每一个字节都是有理由存在并且可以被证明的。最近你创建了多少可以这么说的可执行文件呢?
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: