本文從減少可執(zhí)行文件大小的角度分析了 ELF
文件,期間通過經(jīng)典的 Hello World
實(shí)例逐步演示如何通過各種常用工具來分析 ELF
文件,并逐步精簡代碼。
為了能夠盡量減少可執(zhí)行文件的大小,我們必須了解可執(zhí)行文件的格式,以及鏈接生成可執(zhí)行文件時的后臺細(xì)節(jié)(即最終到底有哪些內(nèi)容被鏈接到了目標(biāo)代碼中)。通過選擇合適的可執(zhí)行文件格式并剔除對可執(zhí)行文件的最終運(yùn)行沒有影響的內(nèi)容,就可以實(shí)現(xiàn)目標(biāo)代碼的裁減。因此,通過探索減少可執(zhí)行文件大小的方法,就相當(dāng)于實(shí)踐性地去探索了可執(zhí)行文件的格式以及鏈接過程的細(xì)節(jié)。
當(dāng)然,算法的優(yōu)化和編程語言的選擇可能對目標(biāo)文件的大小有很大的影響,在本文最后我們會跟參考資料 [1] 的作者那樣去探求一個打印 Hello World
的可執(zhí)行文件能夠小到什么樣的地步。
可執(zhí)行文件格式的選擇要滿足的一個基本條件是:目標(biāo)系統(tǒng)支持該可執(zhí)行文件格式,資料 [2] 分析和比較了 UNIX
平臺下的三種可執(zhí)行文件格式,這三種格式實(shí)際上代表著可執(zhí)行文件的一個發(fā)展過程:
a.out 文件格式非常緊湊,只包含了程序運(yùn)行所必須的信息(文本、數(shù)據(jù)、 BSS
),而且每個 section
的順序是固定的。
coff 文件格式雖然引入了一個節(jié)區(qū)表以支持更多節(jié)區(qū)信息,從而提高了可擴(kuò)展性,但是這種文件格式的重定位在鏈接時就已經(jīng)完成,因此不支持動態(tài)鏈接(不過擴(kuò)展的 coff
支持)。
elf 文件格式不僅動態(tài)鏈接,而且有很好的擴(kuò)展性。它可以描述可重定位文件、可執(zhí)行文件和可共享文件(動態(tài)鏈接庫)三類文件。
下面來看看 ELF
文件的結(jié)構(gòu)圖:
文件頭部(ELF Header)
程序頭部表(Program Header Table)
節(jié)區(qū)1(Section1)
節(jié)區(qū)2(Section2)
節(jié)區(qū)3(Section3)
...
節(jié)區(qū)頭部(Section Header Table)
無論是文件頭部、程序頭部表、節(jié)區(qū)頭部表還是各個節(jié)區(qū),都是通過特定的結(jié)構(gòu)體 (struct)描述的,這些結(jié)構(gòu)在
elf.h文件中定義。文件頭部用于描述整個文件的類型、大小、運(yùn)行平臺、程序入口、程序頭部表和節(jié)區(qū)頭部表等信息。例如,我們可以通過文件頭部查看該
ELF` 文件的類型。
$ cat hello.c #典型的hello, world程序
#include <stdio.h>
int main(void)
{
printf("hello, world!\n");
return 0;
}
$ gcc -c hello.c #編譯,產(chǎn)生可重定向的目標(biāo)代碼
$ readelf -h hello.o | grep Type #通過readelf查看文件頭部找出該類型
Type: REL (Relocatable file)
$ gcc -o hello hello.o #生成可執(zhí)行文件
$ readelf -h hello | grep Type
Type: EXEC (Executable file)
$ gcc -fpic -shared -W1,-soname,libhello.so.0 -o libhello.so.0.0 hello.o #生成共享庫
$ readelf -h libhello.so.0.0 | grep Type
Type: DYN (Shared object file)
那節(jié)區(qū)頭部表(將簡稱節(jié)區(qū)表)和程序頭部表有什么用呢?實(shí)際上前者只對可重定向文件有用,而后者只對可執(zhí)行文件和可共享文件有用。
節(jié)區(qū)表是用來描述各節(jié)區(qū)的,包括各節(jié)區(qū)的名字、大小、類型、虛擬內(nèi)存中的位置、相對文件頭的位置等,這樣所有節(jié)區(qū)都通過節(jié)區(qū)表給描述了,這樣連接器就可以根據(jù)文件頭部表和節(jié)區(qū)表的描述信息對各種輸入的可重定位文件進(jìn)行合適的鏈接,包括節(jié)區(qū)的合并與重組、符號的重定位(確認(rèn)符號在虛擬內(nèi)存中的地址)等,把各個可重定向輸入文件鏈接成一個可執(zhí)行文件(或者是可共享文件)。如果可執(zhí)行文件中使用了動態(tài)連接庫,那么將包含一些用于動態(tài)符號鏈接的節(jié)區(qū)。我們可以通過 readelf -S
(或 objdump -h
)查看節(jié)區(qū)表信息。
$ readelf -S hello #可執(zhí)行文件、可共享庫、可重定位文件默認(rèn)都生成有節(jié)區(qū)表
...
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
...
三種類型文件的節(jié)區(qū)(各個常見節(jié)區(qū)的作用請參考資料 [11])可能不一樣,但是有幾個節(jié)區(qū),例如 .text
,.data
,.bss
是必須的,特別是 .text
,因?yàn)檫@個節(jié)區(qū)包含了代碼。如果一個程序使用了動態(tài)鏈接庫(引用了動態(tài)連接庫中的某個函數(shù)),那么需要 .interp
節(jié)區(qū)以便告知系統(tǒng)使用什么動態(tài)連接器程序來進(jìn)行動態(tài)符號鏈接,進(jìn)行某些符號地址的重定位。通常,.rel.text
節(jié)區(qū)只有可重定向文件有,用于鏈接時對代碼區(qū)進(jìn)行重定向,而 .hash
,.plt
,.got
等節(jié)區(qū)則只有可執(zhí)行文件(或可共享庫)有,這些節(jié)區(qū)對程序的運(yùn)行特別重要。還有一些節(jié)區(qū),可能僅僅是用于注釋,比如 .comment
,這些對程序的運(yùn)行似乎沒有影響,是可有可無的,不過有些節(jié)區(qū)雖然對程序的運(yùn)行沒有用處,但是卻可以用來輔助對程序進(jìn)行調(diào)試或者對程序運(yùn)行效率有影響。
雖然三類文件都必須包含某些節(jié)區(qū),但是節(jié)區(qū)表對可重定位文件來說才是必須的,而程序的執(zhí)行卻不需要節(jié)區(qū)表,只需要程序頭部表以便知道如何加載和執(zhí)行文件。不過如果需要對可執(zhí)行文件或者動態(tài)連接庫進(jìn)行調(diào)試,那么節(jié)區(qū)表卻是必要的,否則調(diào)試器將不知道如何工作。下面來介紹程序頭部表,它可通過 readelf -l
(或 objdump -p
)查看。
$ readelf -l hello.o #對于可重定向文件,gcc沒有產(chǎn)生程序頭部,因?yàn)樗鼘芍囟ㄏ蛭募]用
There are no program headers in this file.
$ readelf -l hello #而可執(zhí)行文件和可共享文件都有程序頭部
...
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 #節(jié)區(qū)和上面類似,這里省略
從上面可看出程序頭部表描述了一些段(Segment
),這些段對應(yīng)著一個或者多個節(jié)區(qū),上面的 readelf -l
很好地顯示了各個段與節(jié)區(qū)的映射。這些段描述了段的名字、類型、大小、第一個字節(jié)在文件中的位置、將占用的虛擬內(nèi)存大小、在虛擬內(nèi)存中的位置等。這樣系統(tǒng)程序解釋器將知道如何把可執(zhí)行文件加載到內(nèi)存中以及進(jìn)行動態(tài)鏈接等動作。
該可執(zhí)行文件包含 7 個段,PHDR
指程序頭部,INTERP
正好對應(yīng) .interp
節(jié)區(qū),兩個 LOAD
段包含程序的代碼和數(shù)據(jù)部分,分別包含有 .text
和 .data
,.bss
節(jié)區(qū),DYNAMIC
段包含 .daynamic
,這個節(jié)區(qū)可能包含動態(tài)連接庫的搜索路徑、可重定位表的地址等信息,它們用于動態(tài)連接器。 NOTE
和 GNU_STACK
段貌似作用不大,只是保存了一些輔助信息。因此,對于一個不使用動態(tài)連接庫的程序來說,可能只包含 LOAD
段,如果一個程序沒有數(shù)據(jù),那么只有一個 LOAD
段就可以了。
總結(jié)一下,Linux 雖然支持很多種可執(zhí)行文件格式,但是目前 ELF
較通用,所以選擇 ELF
作為我們的討論對象。通過上面對 ELF
文件分析發(fā)現(xiàn)一個可執(zhí)行的文件可能包含一些對它的運(yùn)行沒用的信息,比如節(jié)區(qū)表、一些用于調(diào)試、注釋的節(jié)區(qū)。如果能夠刪除這些信息就可以減少可執(zhí)行文件的大小,而且不會影響可執(zhí)行文件的正常運(yùn)行。
從上面的討論中已經(jīng)接觸了動態(tài)連接庫。 ELF
中引入動態(tài)連接庫后極大地方便了公共函數(shù)的共享,節(jié)約了磁盤和內(nèi)存空間,因?yàn)椴辉傩枰涯切┕埠瘮?shù)的代碼鏈接到可執(zhí)行文件,這將減少了可執(zhí)行文件的大小。
與此同時,靜態(tài)鏈接可能會引入一些對代碼的運(yùn)行可能并非必須的內(nèi)容。你可以從《GCC 編譯的背后(第二部分:匯編和鏈接)》 了解到 GCC
鏈接的細(xì)節(jié)。從那篇 Blog 中似乎可以得出這樣的結(jié)論:僅僅從是否影響一個 C 語言程序運(yùn)行的角度上說,GCC
默認(rèn)鏈接到可執(zhí)行文件的幾個可重定位文件 (crt1.o
,rti.o
,crtbegin.o
,crtend.o
,crtn.o
)并不是必須的,不過值得注意的是,如果沒有鏈接那些文件但在程序末尾使用了 return
語句,main
函數(shù)將無法返回,因此需要替換為 _exit
調(diào)用;另外,既然程序在進(jìn)入 main
之前有一個入口,那么 main
入口就不是必須的。因此,如果不采用默認(rèn)鏈接也可以減少可執(zhí)行文件的大小。
這里主要是根據(jù)上面兩點(diǎn)來介紹如何減少一個可執(zhí)行文件的大小。以 Hello World
為例。
首先來看看默認(rèn)編譯產(chǎn)生的 Hello World
的可執(zhí)行文件大小。
代碼同上,下面是一組演示,
$ uname -r #先查看內(nèi)核版本和gcc版本,以便和你的結(jié)果比較
2.6.22-14-generic
$ gcc --version
gcc (GCC) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)
...
$ gcc -o hello hello.c #默認(rèn)編譯
$ wc -c hello #產(chǎn)生一個大小為6442字節(jié)的可執(zhí)行文件
6442 hello
可以考慮編輯時就把 return 0
替換成 _exit(0)
并包含定義該函數(shù)的 unistd.h
頭文件。下面是從《GCC 編譯的背后(第二部分:匯編和鏈接)》總結(jié)出的 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)
把上面的代碼復(fù)制到一個Makefile文件中,并利用它來編譯hello.c。
$ make #編譯
$ ./hello #這個也是可以正常工作的
Hello World
$ wc -c hello #但是大小減少了4382個字節(jié),減少了將近 70%
2060 hello
$ echo "6442-2060" | bc
4382
$ echo "(6442-2060)/6442" | bc -l
.68022353306426575597
對于一個比較小的程序,能夠減少將近 70% “沒用的”代碼。
使用上述 Makefile
來編譯程序,不鏈接那些對程序運(yùn)行沒有多大影響的文件,實(shí)際上也相當(dāng)于刪除了一些“沒用”的節(jié)區(qū),可以通過下列演示看出這個實(shí)質(zhì)。
$ 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
通過比較發(fā)現(xiàn)使用自定義的 Makefile
文件,少了這么多節(jié)區(qū): .bss .ctors .data .dtors .eh_frame .fini .gnu.hash .got .init .jcr .note.ABI-tag .rel.dyn
。再看看還有哪些節(jié)區(qū)可以刪除呢?通過之前的分析發(fā)現(xiàn)有些節(jié)區(qū)是必須的,那 .hash?.gnu.version?
呢,通過 strip -R
(或 objcop -R
)刪除這些節(jié)區(qū)試試。
$ wc -c hello #查看大小,以便比較
2060
$ time ./hello #我們比較一下一些節(jié)區(qū)對執(zhí)行時間可能存在的影響
Hello World
real 0m0.001s
user 0m0.000s
sys 0m0.000s
$ strip -R .hash hello #刪除.hash節(jié)區(qū)
$ wc -c hello
1448 hello
$ echo "2060-1448" | bc #減少了612字節(jié)
612
$ time ./hello #發(fā)現(xiàn)執(zhí)行時間長了一些(實(shí)際上也可能是進(jìn)程調(diào)度的問題)
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字節(jié)
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
通過刪除各個節(jié)區(qū)可以查看哪些節(jié)區(qū)對程序來說是必須的,不過有些節(jié)區(qū)雖然并不影響程序的運(yùn)行卻可能會影響程序的執(zhí)行效率,這個可以上面的運(yùn)行時間看出個大概。通過刪除兩個“沒用”的節(jié)區(qū),我們又減少了 52+612
,即 664 字節(jié)。
用普通的工具沒有辦法刪除節(jié)區(qū)表,但是參考資料[1]的作者已經(jīng)寫了這樣一個工具。你可以從這里下載到那個工具,它是該作者寫的一序列工具 ELFkickers
中的一個。
下載并編譯(注:1.0 之前的版本才支持 32 位和正常編譯,新版本在代碼中明確限定了數(shù)據(jù)結(jié)構(gòu)為 Elf64
):
$ git clone https://github.com/BR903/ELFkickers
$ cd ELFkickers/sstrip/
$ git checkout f0622afa # 檢出 1.0 版
$ make
然后復(fù)制到 /usr/bin
下,下面用它來刪除節(jié)區(qū)表。
$ sstrip hello #刪除ELF可執(zhí)行文件的節(jié)區(qū)表
$ ./hello #還是可以正常運(yùn)行,說明節(jié)區(qū)表對可執(zhí)行文件的運(yùn)行沒有任何影響
Hello World
$ wc -c hello #大小只剩下708個字節(jié)了
708 hello
$ echo "1396-708" | bc #又減少了688個字節(jié)。
688
通過刪除節(jié)區(qū)表又把可執(zhí)行文件減少了 688 字節(jié)?,F(xiàn)在回頭看看相對于 gcc
默認(rèn)產(chǎn)生的可執(zhí)行文件,通過刪除一些節(jié)區(qū)和節(jié)區(qū)表到底減少了多少字節(jié)?減幅達(dá)到了多少?
$ echo "6442-708" | bc #
5734
$ echo "(6442-708)/6442" | bc -l
.89009624340266997826
減少了 5734 多字節(jié),減幅將近 90%
,這說明:對于一個簡短的 hello.c
程序而言,gcc
引入了將近 90%
的對程序運(yùn)行沒有影響的數(shù)據(jù)。雖然通過刪除節(jié)區(qū)和節(jié)區(qū)表,使得最終的文件只有 708 字節(jié),但是打印一個 Hello World
真的需要這么多字節(jié)么?事實(shí)上未必,因?yàn)椋?/p>
Hello World
字符串,我們無須調(diào)用 printf
,也就無須包含動態(tài)連接庫,因此 .interp
,.dynamic
等節(jié)區(qū)又可以去掉。為什么?我們可以直接使用系統(tǒng)調(diào)用 `(sys_write)來打印字符串。Hello World
字符串存放到可執(zhí)行文件中?而是讓用戶把它當(dāng)作參數(shù)輸入。下面,繼續(xù)進(jìn)行可執(zhí)行文件的“減肥”。
先來看看 gcc
默認(rèn)產(chǎn)生的匯編代碼情況。通過 gcc
的 -S
選項(xiàng)可得到匯編代碼。
$ cat hello.c #這個是使用_exit和printf函數(shù)的版本
#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 #看看默認(rèn)產(chǎn)生的代碼大小
$ wc -c hello
6523 hello
現(xiàn)在對匯編代碼 hello.s
進(jìn)行簡單的處理得到,
.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個字節(jié)
80
如果不采用默認(rèn)編譯呢并且刪除掉對程序運(yùn)行沒有影響的節(jié)區(qū)和節(jié)區(qū)表呢?
$ sed -i -e "s/main/_start/g" hello.s #因?yàn)闆]有初始化,所以得直接進(jìn)入代碼,替換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 #和之前的實(shí)驗(yàn)類似,也減少了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 #這個結(jié)果比之前的708(在刪除所有垃圾信息以后)個字節(jié)少了708-676,即32個字節(jié)
676 hello
$ ./hello
Hello World
容易發(fā)現(xiàn)這 32 字節(jié)可能跟節(jié)區(qū) .rodata
有關(guān)系,因?yàn)閯偛旁阪溄油暌院蟛榭垂?jié)區(qū)信息時,并沒有 .rodata
節(jié)區(qū)。
前面提到,實(shí)際上還可以不用動態(tài)連接庫中的 printf
函數(shù),也不用直接調(diào)用 _exit
,而是在匯編里頭使用系統(tǒng)調(diào)用,這樣就可以去掉和動態(tài)連接庫關(guān)聯(lián)的內(nèi)容。如果想了解如何在匯編中使用系統(tǒng)調(diào)用,請參考資料 [9]。使用系統(tǒng)調(diào)用重寫以后得到如下代碼,
.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
現(xiàn)在編譯就不再需要動態(tài)鏈接器 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 #相對于之前,已經(jīng)只需要123個字節(jié)了,又減少了553個字節(jié)
553
可以看到效果很明顯,只剩下一個 LOAD
段,它對應(yīng) .text
節(jié)區(qū)。
不過是否還有辦法呢?把 Hello World
作為參數(shù)輸入,而不是硬編碼在文件中。所以如果處理參數(shù)的代碼少于 Hello World
字符串的長度,那么就可以達(dá)到減少目標(biāo)文件大小的目的。
先來看一個能夠打印程序參數(shù)的匯編語言程序,它來自參考資料[9]。
.text
.globl _start
_start:
popl %ecx # argc
vnext:
popl %ecx # argv
test %ecx, %ecx # 空指針表明結(jié)束
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 # 系統(tǒng)調(diào)用號(sys_write)
movl $1, %ebx # 文件描述符(stdout)
int $0x80
jmp vnext
exit:
movl $1,%eax # 系統(tǒng)調(diào)用號(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字節(jié)
130 args
可以看到,這個程序可以接收用戶輸入的參數(shù)并打印出來,不過得到的可執(zhí)行文件為 130 字節(jié),比之前的 123 個字節(jié)還多了 7 個字節(jié),看看還有改進(jìn)么?分析上面的代碼后,發(fā)現(xiàn),原來的代碼有些地方可能進(jìn)行優(yōu)化,優(yōu)化后得到如下代碼。
.global _start
_start:
popl %ecx #彈出argc
vnext:
popl %ecx #彈出argv[0]的地址
test %ecx, %ecx #空指針表明結(jié)束
jz exit
movl %ecx, %ebx #復(fù)制字符串地址到ebx寄存器
xorl %edx, %edx #把字符串長度清零
strlen: #求輸入字符串的長度
movb (%ebx), %al #復(fù)制字符到al,以便判斷是否為字符串結(jié)束符\0
inc %edx #edx存放每個當(dāng)前字符串的長度
inc %ebx #ebx存放每個當(dāng)前字符的地址
test %al, %al #判斷字符串是否結(jié)束,即是否遇到\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
再測試(記得先重新匯編、鏈接并刪除沒用的節(jié)區(qū)和節(jié)區(qū)表)。
$ wc -c hello
124 hello
現(xiàn)在只有 124 個字節(jié),不過還是比 123 個字節(jié)多一個,還有什么優(yōu)化的辦法么?
先來看看目前 hello
的功能,感覺不太符合要求,因?yàn)橹恍枰蛴?Hello World
,所以不必處理所有的參數(shù),僅僅需要接收并打印一個參數(shù)就可以。這樣的話,把 jmp vnext
(2 字節(jié))這個循環(huán)去掉,然后在第一個 pop %ecx
語句之前加一個 pop %ecx
(1 字節(jié))語句就可以。
.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
現(xiàn)在剛好 123 字節(jié),和原來那個代碼大小一樣,不過仔細(xì)分析,還是有減少代碼的余地:因?yàn)樵谶@個代碼中,用了一段額外的代碼計(jì)算字符串的長度,實(shí)際上如果僅僅需要打印 Hello World
,那么字符串的長度是固定的,即 12 。所以這段代碼可去掉,與此同時測試字符串是否為空也就沒有必要(不過可能影響代碼健壯性?。?,當(dāng)然,為了能夠在打印字符串后就換行,在串的末尾需要加一個回車($10
)并且設(shè)置字符串的長度為 12+1
,即 13,
.global _start
_start:
popl %ecx
popl %ecx
popl %ecx
movb $10,12(%ecx) #在Hello World的結(jié)尾加一個換行符
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
現(xiàn)在只剩下 111 字節(jié),比剛才少了 12 字節(jié)。貌似到了極限?還有措施么?
還有,仔細(xì)分析發(fā)現(xiàn):系統(tǒng)調(diào)用 sys_exit
和 sys_write
都用到了 eax
和 ebx
寄存器,它們之間剛好有那么一點(diǎn)巧合:
eax
需要設(shè)置為 1,ebx
需要設(shè)置為 0 。ebx
剛好是 1 。因此,如果在 sys_exit
調(diào)用之前,先把 ebx
復(fù)制到 eax
中,再對 ebx
減一,則可減少兩個字節(jié)。
不過,因?yàn)闃?biāo)準(zhǔn)輸入、標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯誤都指向終端,如果往標(biāo)準(zhǔn)輸入寫入一些東西,它還是會輸出到標(biāo)準(zhǔn)輸出上,所以在上述代碼中如果在 sys_write
之前 ebx
設(shè)置為 0,那么也可正常往屏幕上打印 Hello World
,這樣的話,sys_exit
調(diào)用前就沒必要修改 ebx
,而僅需把 eax
設(shè)置為 1,這樣就可減少 3 個字節(jié)。
.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
現(xiàn)在看一下純粹的指令還有多少?
$ 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
對于標(biāo)準(zhǔn)的 main
函數(shù)的兩個參數(shù),文件名實(shí)際上作為第二個參數(shù)(數(shù)組)的第一個元素傳入,如果僅僅是為了打印一個字符串,那么可以打印文件名本身。例如,要打印 Hello World
,可以把文件名命名為 Hello World
即可。
這樣地話,代碼中就可以刪除掉一條 popl
指令,減少 1 個字節(jié),變成 107 個字節(jié)。
.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
在測試中發(fā)現(xiàn),edx
,eax
,ebx
的高位即使不初始化,也常為 0,如果不考慮健壯性(僅這里實(shí)驗(yàn)用,實(shí)際使用中必須考慮健壯性),幾條 xorl
指令可以移除掉。
另外,如果只是為了演示打印字符串,完全可以不用打印換行符,這樣下來,代碼可以綜合優(yōu)化成如下幾條指令:
.global _start
_start:
popl %ecx # argc
popl %ecx # argv[0]
movb $5, %dl # 設(shè)置字符串長度
movb $4, %al # eax = 4, 設(shè)置系統(tǒng)調(diào)用號, 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
純粹的指令只有 96-84=12
個字節(jié)了,還有辦法再減少目標(biāo)文件的大小么?如果看了參考資料 [1],看樣子你又要蠢蠢欲動了:這 12 個字節(jié)是否可以插入到文件頭部或程序頭部?如果可以那是否意味著還可減少可執(zhí)行文件的大小呢?現(xiàn)在來比較一下這三部分的十六進(jìn)制內(nèi)容。
$ 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 #實(shí)際代碼部分(12bytes)
00000054 59 59 b2 05 b0 04 cd 80 b0 01 cd 80 |YY..........|
00000060
從上面結(jié)果發(fā)現(xiàn) ELF
文件頭部和程序頭部還有好些空洞(0),是否可以把指令字節(jié)分散放入到那些空洞里或者是直接覆蓋掉那些系統(tǒng)并不關(guān)心的內(nèi)容?抑或是把代碼壓縮以后放入可執(zhí)行文件中,并在其中實(shí)現(xiàn)一個解壓縮算法?還可以是通過一些代碼覆蓋率測試工具(gcov
,prof
)對你的代碼進(jìn)行優(yōu)化?
在繼續(xù)介紹之前,先來看一個 dd
工具,可以用來直接“編輯” ELF
文件,例如,
直接往指定位置寫入 0xff
:
$ hexdump -C hello -n 16 # 寫入前,elf文件前16個字節(jié)
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 # 把最后一個字節(jié)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
選項(xiàng)表示要保留寫入位置之后的內(nèi)容,默認(rèn)情況下會截?cái)唷?/li>
bs=1
表示一次讀/寫 1 個count=1
表示總共寫 1 次覆蓋多個連續(xù)的值:
把第 12,13,14,15 連續(xù) 4 個字節(jié)全部賦值為 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
確認(rèn)哪些部分對于可執(zhí)行文件的執(zhí)行是否有影響?這里是逐步測試后發(fā)現(xiàn)依然能夠執(zhí)行的情況:
$ 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
可以發(fā)現(xiàn),文件頭部分,有 30 個字節(jié)即使被篡改后,該可執(zhí)行文件依然可以正常執(zhí)行。這意味著,這 30 字節(jié)是可以寫入其他代碼指令字節(jié)的。而我們的實(shí)際代碼指令只剩下 12 個,完全可以直接移到前 12 個 0xff
的位置,即從第 4 個到第 15 個。
而代碼部分的起始位置,通過 readelf -h
命令可以看到:
$ readelf -h hello | grep "Entry"
Entry point address: 0x8048054
上面地址的最后兩位 0x54=84
就是代碼在文件中的偏移,也就是剛好從程序頭之后開始的,也就是用文件頭(52)+程序頭(32)個字節(jié)開始的 12 字節(jié)覆蓋到第 4 個字節(jié)開始的 12 字節(jié)內(nèi)容即可。
上面的 dd
命令從 echo
命令獲得輸入,下面需要通過可執(zhí)行文件本身獲得輸入,先把代碼部分移過去:
$ 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
這個時候還不能執(zhí)行,因?yàn)榇a在文件中的位置被移動了,相應(yīng)地,文件頭中的 Entry point address
,即文件入口地址也需要被修改為 0x8048004
。
即需要把 0x54
所在的第 24 個字節(jié)修改為 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
修改后就可以執(zhí)行了。
程序頭部分經(jīng)過測試發(fā)現(xiàn)基本上都不能修改并且需要是連續(xù)的,程序頭有 32 個字節(jié),而文件頭中連續(xù)的 0xff
可以被篡改的只有從第 46 個開始的 6 個了,另外,程序頭剛好是 01 00
開頭,而第 44,45 個剛好為 01 00
,這樣地話,這兩個字節(jié)文件頭可以跟程序頭共享,這樣地話,程序頭就可以往文件頭里頭移動 8 個字節(jié)了。
$ dd if=hello of=hello bs=1 skip=52 seek=44 count=32 conv=notrunc
再把最后 8 個沒用的字節(jié)刪除掉,保留 84-8=76
個字節(jié):
$ 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 個字節(jié),原來是 0x34
,即 52 的位置。
$ echo "obase=16;ibase=10;44" | bc # 先把44轉(zhuǎn)換是16進(jìn)制的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
修改后即可執(zhí)行了,目前只剩下 76 個字節(jié):
$ wc -c hello
76
另外,還有 1
更多建議: