打造史上最小可執(zhí)行ELF文件(45字節(jié))

2018-02-24 15:41 更新

打造史上最小可執(zhí)行 ELF 文件(45 字節(jié),可打印字符串)

前言

本文從減少可執(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í)行文件格式的選取

可執(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)連接器。 NOTEGNU_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)行。

鏈接優(yōu)化

從上面的討論中已經(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.ocrtend.o,crtn.o)并不是必須的,不過值得注意的是,如果沒有鏈接那些文件但在程序末尾使用了 return 語句,main 函數(shù)將無法返回,因此需要替換為 _exit 調(diào)用;另外,既然程序在進(jìn)入 main 之前有一個入口,那么 main 入口就不是必須的。因此,如果不采用默認(rèn)鏈接也可以減少可執(zhí)行文件的大小。

可執(zhí)行文件“減肥”實(shí)例(從6442到708字節(jié))

這里主要是根據(jù)上面兩點(diǎn)來介紹如何減少一個可執(zhí)行文件的大小。以 Hello World 為例。

首先來看看默認(rèn)編譯產(chǎn)生的 Hello World 的可執(zhí)行文件大小。

系統(tǒng)默認(rèn)編譯

代碼同上,下面是一組演示,

$ 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

不采用默認(rèn)編譯

可以考慮編輯時就把 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% “沒用的”代碼。

刪除對程序運(yùn)行沒有影響的節(jié)區(qū)

使用上述 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é)。

刪除可執(zhí)行文件的節(jié)區(qū)表

用普通的工具沒有辦法刪除節(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í)行文件的“減肥”。

用匯編語言來重寫"Hello World"(76字節(jié))

采用默認(rèn)編譯

先來看看 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

刪除掉匯編代碼中無關(guān)緊要內(nèi)容

現(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)編譯并刪除掉無關(guān)節(jié)區(qū)和節(jié)區(qū)表

如果不采用默認(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ū)。

用系統(tǒng)調(diào)用取代庫函數(shù)

前面提到,實(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ū)。

把字符串作為參數(shù)輸入

不過是否還有辦法呢?把 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_exitsys_write 都用到了 eaxebx 寄存器,它們之間剛好有那么一點(diǎn)巧合:

  • sys_exit 調(diào)用時,eax 需要設(shè)置為 1,ebx 需要設(shè)置為 0 。
  • sys_write 調(diào)用時,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

通過文件名傳遞參數(shù)

對于標(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,eaxebx 的高位即使不初始化,也常為 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

合并代碼段、程序頭和文件頭(52字節(jié))

把代碼段移入文件頭

純粹的指令只有 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

在非連續(xù)的空間插入代碼

另外,還有 1

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號