p { margin-bottom: 0.21cm; }
如果你现在就停止阅读 ELF 规范,那么你本可以发现另一些规则的: 1)ELF 文件的不同块可以位于文件中的任何位置,除了 ELF header 必须位于文件最开始部分,所以可以把一些部分进行重叠; 2)header 里的一些字段并没有真正被使用。
具体地说,我正在想 header 中的 16 字节长的标识符字段尾部的一串零。它们只是纯粹的填充,旨在为 ELF 规范将来的扩展预留空间。所以操作系统并不关心那个地方有什么信息。我们已经把任何东西都加载到内存中,我们的程序代码只有 7 字节。。。
那么我们能把程序代码放到 ELF header 里去吗?
; tiny.asm
BITS 32
org 0x08048000
ehdr: ; Elf32_Ehdr
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0, 0
_start: mov bl, 42
xor eax, eax
inc eax
int 0x80
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
我们是否可以对 program header table 做同样的事情呢?它和 ELF header 重叠可能吗?
ELF header 的最后 8 字节和 program header table 的最开始的 8 字节完全是一样的。
那么:
; tiny.asm
BITS 32
org 0x08048000
ehdr:
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0, 0
_start: mov bl, 42
xor eax, eax
inc eax
int 0x80
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 $ - $$
And sure enough, Linux doesn't mind our parsimony one bit:
$ 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 确实会去检测字段 e_machine 。但是,有相当数量的其他字段被默默地忽略了。
接下来就是 ELF header 中关键的和非关键的字段。第一个四字节必须包含魔数,否则 Linux 不会去识别这个文件。在字段 e_ident 里的其他三字节不会被检测,那意味着我们有不少于 12 字节的连续空间可以填充任何信息。 e_type 必须被设置成 2 ,来标示其为可执行文件, e_machine 必须为 3 , e_version 就像是 e_ident 中的版本号,完全忽略掉了。这也可以理解,目前只有唯一一个 ELF 标准版本。 e_entry 也必须是有效的,因为它指向程序的开始。很显然, e_phoff 必须包含 program header table 在文件中的正确的偏移, e_phnum 必须包含 program header table 中的正确的项数, e_flags 据记载现在没有为 intel 使用,所以我们可以自由地使用它。 e_ehsize 用于验证 ELF header 有期待的长度,但是 Linux 并没有用它。 e_phentsize 用于验证 program header table 中表项的长度。这个字段在比较老的内核中没有用,但是现在它必须被正确地设置。 ELF header 里的其他信息是关于 section header table ,它们对于可执行文件不起作用。
现在让我们来考虑下 program header table 表项。字段 p_type 必须包含数字 1 ,来标示它为一个可加载段, p_offset 也需要有正确的文件偏移来开始加载。同样地, p_vaddr 需要包含合适的加载地址。注意我们没有被要求必须加载到内存映像 0x08048000 处。任何位于 0x00000000 和 0x80000000 之间的页对齐的地址都可以。 p_paddr 被忽略,所以是肯定可以自由使用的。 p_filesz 标示文件中的多少字节要被加载到内存中, p_memsz 标示内存段需要有多大, p_flags 标示内存段的权限设置,它必须是 readable(4) ,否则,它就没有用,它也必须是 executable(1) ,否则就不能被执行。其他位也可以被设置,但是必须至少包含这两位。最后, p_align 给出了内存段的位对齐需求。这个字段主要用于重定位包含 pic 的段时,所以对于可执行文件 Linux 会忽略我们存储在这里的任何垃圾。
总结起来,有点余地发挥。特别是,仔细观察后发现 ELF header 中的大部分必须字段在前半部,后半部可以自由挥霍。基于这点考虑,我们能重叠那两个结构再多一点点:
; tiny.asm
BITS 32
org 0x00200000
db 0x7F, "ELF" ; e_ident
db 1, 1, 1, 0, 0
_start:
mov bl, 42
xor eax, eax
inc eax
int 0x80
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 $ - $$
正如你现在看到的, program header table 的头 20 字节和 ELF header 的最后 20 字节重叠了。在 ELF header 中有两部分需要注意,第一个是 e_phnum 字段,它刚好和 p_paddr 字段相同,然而在 program header table 中它被明确忽略了。另一个是 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 字节!
我们注意到 p_memsz 标示为内存段分配的内存量,显然它至少要跟 p_filesz 一样大,但是如果它更大些也无妨。毕竟我们申请多少并不意味着我们必须使用多少。
另外,与我们的期望相反, executable 位能够从 p_flags 字段中去掉。看起来 readable 和 executable 是冗余的:两者都可以代表另一个。
想到这两点,我们可以重新组织文件:
; 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 _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
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
filesize equ $ - $$
p_flags 从 5 变到 4 ,与 e_phoff 的值相同,他标示了 program header table 在文件中的偏移值。程序已经被移到 ELF header 的更低的部分,开始与 e_shoff 字段,结束于 e_flags 字段。
加载地址也被改变到一个更低的值,这能保证 e_entry 字段中的值足够小,这很好因为它也是 p_memsz 。 p_filesz 的改变需要解释下。因为我们没有设置 p_flags 字段中的 write 位, Linux 不允许我们定义 p_memsz 比 p_filesz 大,因为它不能零初始化那些额外的字节因为它们不可写。既然我们在保证 program header table 对齐的条件下不能改变 p_flags 字段的值,你可能会想唯一的解决方案就是把 p_memsz 下移至与 p_filesz 相等。尽管如此,还有另一种解决方案,增加 p_filesz 使之等于 p_memsz 。那意味这它俩都比实际文件大的多,但是它能让加载器避免写只读内存,这是它所关注的。
$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
52 a.out
看起来就算文件的长度并不是整个 ELF header 的长度, Linux 仍然能玩得转,并用零填充缺失的字节。我们有不少于 7 个零在文件末尾,所以我们可以把它们从文件映像中去除。
; 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 _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 $ - $$
... we can, incredibly enough, still produce a working executable:
$ 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 可执行文件大小的八分之一还要小,比最小的用纯 C 代码编写的文件的十五分之一还要小。当然,文件里一半的值都违反了 ELF 标准。
另一方面,这个可执行文件里的每一个字节都是有意义的并且是正当的。有多少你最近创建的可执行文件能够这么说?
http://www.muppetlabs.com/~breadbox/software/tiny/teensy.html