http://hi.baidu.com/zengzhaonong/item/32b06adfecdb774edcf9be78【來源】
現代Linux採用ELF(Executable and Linking Format)做為其可串連和可執行檔的格式,因此ELF格式也向我們透出了一點Linux核內的情景,就像戲台維幕留下的一條未拉嚴的縫。 PC世界32仍是主流,但64位的腳步卻已如此的逼近。如果你對Windows比較熟悉,本文還將時時把你帶回到PE中,在它們的相似之處稍做比較。ELF檔案以“ELF頭”開始,後面可選擇的跟隨著程式頭和節頭。地理學用等高線與等溫線分別展示同一地區的地勢和氣候,程式頭和節頭則分別從載入與串連角度來描述EFL檔案的組織方式。
ELF頭
------------------------------------------------
ELF頭也叫ELF檔案頭,它位於檔案中最開始的地方。
/usr/src/linux/include/linux/elf.h
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
#define EI_NIDENT 16
ELF頭中每個欄位的含意如下:
Elf32_Ehdr->e_ident[] (Magic)
這個欄位是ELF頭結構中的第一個欄位,在elf.h中EI_NIDENT被定義為16,因此它佔用16個位元組。e_ident的前四個位元組順次應該是0x7f、 0x45、 0x4c、 0x46,也就是"\177ELF"。這是ELF檔案的標誌,任何一個ELF檔案這四個位元組都完全相同。
16進位 8進位 字母
0x7f 0177
0x45 E
0x4c L
0x46 F
第5個位元組標誌了ELF格式是32位還是64位,32位是1,64位是2。
第6個位元組,在0x86系統上是1,表明資料存放區方式為低位元組優先。
第10個位元組,指明了在e_ident中從第幾個位元組開始後面的位元組未使用。
Elf32_Ehdr->e_type (Type)
ELF檔案的類型,1表示此檔案是重定位檔案,2表示可執行檔,3表示此檔案是一個動態串連庫。
Elf32_Ehdr->e_machine (Machine)
CPU類型,它指出了此檔案使用何種指令集。如果是Intel 0x386 CPU此值為3,如果是AMD 64 CPU此值為62也就是16進位的0x3E。
Elf32_Ehdr->e_version (Version)
ELF檔案版本,為1。
Elf32_Ehdr->e_entry (Entry point address)
可執行檔的入口虛擬位址。此欄位指出了該檔案中第一條可執行機器指令在進程被正確載入後的記憶體位址! (注: 入口地址並不是可執行檔的第一個函數 -- main函數的地址)。
Elf32_Ehdr->e_phoff (Start of program headers)
程式頭在ELF檔案中的位移量。如果程式頭不存在此值為0。
Elf32_Ehdr->e_shoff (Start of section headers)
節頭在ELF檔案中的位移量。如果節頭不存在此值為0。
Elf32_Ehdr->e_ehsize (Size of -ELF header)
它描述了“ELF頭”自身佔用的位元組數。
Elf32_Ehdr->e_phentsize (Size of program headers)
程式頭中的每一個結構佔用的位元組數。程式頭也叫程式頭表,可以被看做一個在檔案中連續儲存的結構數組,數組中每一項是一個結構,此欄位給出了這個結構佔用的位元組大小。e_phoff指出程式頭在ELF檔案中的起始位移。
Elf32_Ehdr->e_phnum (Number of program headers)
此欄位給出了程式頭中儲存了多少個結構。如果程式頭中有3個結構則程式頭(程式頭表)在檔案中佔用了(3×e_phentsize)個位元組的大小。
Elf32_Ehdr->e_shentsize (Size of section headers)
節頭中每個結構佔用的位元組大小。節頭與程式頭類似也是一個結構數組,關於這兩個結構的定義將分別在講述程式頭和節頭的時候給出。
Elf32_Ehdr->e_shnum (Number of section headers)
節頭中儲存了多少個結構。
Elf32_Ehdr->e_shstrndx (Section header string table index)
這是一個整數索引值。節頭可以看作是一個結構數組,用這個索引值做為此數組的下標,它在節頭中指定的一個結構進一步給出了一個“字串表”的資訊,而這個字串表儲存著節頭中描述的每一個節的名稱,包括字串表自己也是其中的一個節。
至此為止我們已經講述了“ELF頭”,在此過程中提前提到的一些將來才用的概念,不必急於瞭解。現在讀者可自己編寫一個小程式來驗證剛學到的知識,這有助於進一步的學習。elf.h檔案一般會存在於/usr/include目錄下,直接include它就可以。但我們能夠驗證的知識有限,當更多知識聯絡在一起的時候我們的理解正誤才可以得到更好的驗證。接下來我們再學習程式頭。
# readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x80482f0
Start of program headers: 52 (bytes into file)
Start of section headers: 3228 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 36
Section header string table index: 33
程式頭(程式頭表) -- Program Header
------------------------------------------------
程式頭有時也叫程式頭表,它儲存了一個結構數組(結構Elf32_Phdr的數組)。程式頭是從載入執行的角度看待ELF檔案的結果,從它的角度ELF檔案被分成許多個段。每個段儲存著用於不同目的的資料,有的段儲存著機器指令,有的段儲存著已經初始化的變數;有的段會做為進程映像的一部分被作業系統讀入記憶體,有的段則只存在於檔案中。
後面還會講到ELF的節頭,節頭把ELF檔案分成了許多節。ELF檔案的一部分常常是既在某一段中又在某一節中。Linux和Windows的進程空間都採用的是平坦模式,沒有x86的段概念,這裡ELF中提到的段僅是檔案的分段與x86的段沒有任何聯絡。
/usr/src/linux/include/linux/elf.h
typedef struct elf32_phdr{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
Elf32_Phdr->p_type
段的類型,它能告訴我們這個段裡存放著什麼用途的資料。此欄位的值是在elf.h中定義了一些常量。例如1(PT_LOAD)表示是可載入的段,這樣的段將被讀入程式的進程空間成為記憶體映像的一部分。段的種類再不斷增加,例如7(PT_TLS)在以前就沒有定義,它表示用於線程局部儲存。
Elf32_Phdr->p_flags
段的屬性。它用每一個二進位位表示一種屬,相應位為1表示含有相應的屬性,為0表示不含那種屬性。其中最低位是可執行位,次低位是可寫位,第三低位是可讀位。如果這個欄位的最低三位同時為1那就表示這個段中的資料載入以後既可讀也可寫而且可執行檔。同樣在elf.h檔案中也定義了一此常量(PF_X、 PF_W、PF_R)來測試這個欄位的屬性,做為一個好習慣應該盡量使用這此常量。
Elf32_Phdr->p_offset
該段在檔案中的位移。這個位移是相對於整個檔案的。
Elf32_Phdr->p_vaddr
該段載入後在進程空間中佔用的記憶體起始地址。
Elf32_Phdr->p_paddr
該段的物理地地址。這個欄位被忽略,因為在多數現代作業系統下物理地址是進程無法觸及的。
Elf32_Phdr->p_filesz
該段在檔案中佔用的位元組大小。有些段可能在檔案中不存在但卻佔用一定的記憶體空間,此時這個欄位為0。
Elf32_Phdr->p_memsz
該段在記憶體中佔用的位元組大小。有些段可能僅存在於檔案中而不被載入到記憶體,此時這個欄位為0。
Elf32_Phdr->p_align
對齊。現代作業系統都使用虛擬記憶體為進程式提供更大的空間,分頁技術功不可沒,頁就成了最小的記憶體配置單位,不足一頁的按一頁算。所以載入程式資料一般也從一頁的起始地址開始,這就屬於對齊。
儘管我給出了描述每個段資訊的程式頭結構,但我並不打算介紹任何一個具體類型的段所儲存的內容,大多數情況下它們和節中儲存的內容是一致的。我們只關心可以載入的段,但上面給出的資訊應該足夠了。好啦,你現在就是作業系統,你已經知道了組成程式的指令和資料都存放在檔案的各個段中,通過程式頭你知道它們在檔案中的位移和它們在檔案中的大小,你就可以把這個段讀到它的進程空間中以p_vaddr開始的地址處。水平所限,我所能表達的必然不是精確的,為了更好理解程式頭與進程載入,我設計了一個小實驗並給出C語言代碼 -- 代碼可以精確的說明一切!
覆蓋ELF可執行檔入口指令的實驗
------------------------------------------------
現在掌握了ELF頭和程式頭,從載入執行程式的角度可以說已對ELF檔案有了初步的瞭解。為更好理解它,做個實驗吧!
回憶一下程式頭表把ELF檔案分成了許多段,並告訴作業系統怎樣把這些段讀到記憶體裡去。當作業系統已按程式頭表的指示把ELF檔案各個段的資料讀入到記憶體中相應的地方以後,就可以說作業系統已建立了完整且正確的進程映像(如果不考慮依賴),下一步就是要執行程式了。ELF頭的e_entry給出了第一條機器指令在記憶體中的地址,作業系統只要在某個時候將指令流引向那裡就可以了。
這個猜測對不對呢,下面的這個實驗將從某種角度來證明它。首先準備好一段代碼 -- exit_print(),把這段代碼寫到ELF檔案 -- hello中,代碼寫入的位置恰恰是ELF檔案的第一條機器指令在檔案中的位置。這樣當系統把這個修改過的可執行程式 -- hello載入到記憶體時,它原來入口處的指令已經換成了我們準備的這段代碼,程式的行為被完全改變。可是ELF頭的e_entry給出的是記憶體位址而不檔案位移,所以這需要我們自己找到這個檔案位移。怎麼找? 運用剛剛掌握的知識。程式頭不是給出了檔案中每一段對應的記憶體起始地址嗎,還有每一段在記憶體中佔了多少位元組。只要遍曆程式頭中的每一個結構,看看哪個段的起始記憶體位址小於等於e_entry並且該地址加上該段記憶體大小又大於e_entry,那麼這個段就是程式第一條指令所在的段。第一條指令在段中位移就是e_entry減去該段的p_vaddr所得的值:
第一條指令在整個檔案中位移 = 該段的p_offset + (e_entry - 該段的p_vaddr)
下面就是我準備的那段代碼,它是一個C函數exit_print()。對於這段代碼有三點需要說明:
1) 這個函數中不能調用常用的庫函數,因為若從so中取函數我們現在無法解決動態引入;如果採用靜態串連,被調用函數有可能再調用其它函數,而被調用函在記憶體映像的地址、大小都不易掌握。
2) 這個段代碼最好是位置無關代碼,這樣能減少這個實驗的代碼量,而使用全域或靜態變數將使我們花更大代價來實現位置無關,所以這個函數不使用它們。
3) 這個函數只能在IA32機器上運行,若想在其它環境下做此實驗必須修改它的一段彙編代碼。
另外我們沒有判斷ELF檔案是否為可執行檔。為了確信這段代碼被運行,它將在控制台輸出“Hello zxl ”之後就結束整個程式。
鑒於上面的兩點說明,我們不能使用printf和malloc輸出字串和為它分配記憶體,也沒有把完整的字串做為變數儲存,而是用了堆棧中的局部變數,這將導致棧中記憶體配置。把字串放到strHello中用了四條C語句。注意,前三條中每條語句放入的四個字元的順序是顛倒的,這是x86低位元組優先儲存造成的。最後一條C語句放入一個斷行符號符‘\n’,字串沒有以0結尾。
void exit_print()
{
char strHello[20];
*((unsigned long*)&strHello[0])='lleH';
*((unsigned long*)&strHello[4])=' o';
*((unsigned long*)&strHello[8])=' zxl';
strHello[12]='\n';
__asm__ volatile ("int $0x80; movl $0,%%ebx; movl $1,%%eax; int $0x80"\
: \
: "a"((long) 4), \
"b" ((long)1), \
"c" ((long)(&strHello[0])),\
"d" ((long)13));
}
exit_print用到了一些彙編文法,不防在這裡先複習下彙編,如果你不喜歡看彙編,可以直接閱讀後面給出的完整C代碼,我可以保證它實現上面想要的功能。gcc內部彙編以“__asm__”開始,關鍵字volatile告訴gcc不要最佳化。彙編體以一對小括弧包圍並以分號結束:輸入部分把寄存器EAX置為4,這是 write系統調用的功能號;EBX置為1,這write系統調用使用的檔案控制代碼,1代表標準輸出裝置;寄存器ECX置為字串的起始地址;寄存器EDX 置為13,這代表字串的長度是13個位元組;我們不關心系統傳回值因此輸出部分沒有內容;接下來int
$0x80把剛才的設定到寄存器的參數傳給核心完成列印功能! 後面在把寄存器EBX置0、EAX置1後又是一次系統調用,它將結束當前進程並把EBX中的0返回給父進程。函數exit_print說明完畢!
------------------------------------------------
#include <stdio.h>
int main()
{
printf("hello\n");
}
# gcc hello.c -o hello
# ./hello
hello
下面給出這個實驗程式的完整代碼,它被存為mod_entry.c檔案,exit_print函數也在其中。下面代碼將替換ELF檔案hello的入口地址(將hello檔案放置在和mod_entry.c相同的目錄下)。
//檔案名稱 :mod_entry.c
//功能 : 覆蓋ELF可執行檔指令入口
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <elf.h>
void exit_print()
{
char strHello[20];
*((unsigned long*)&strHello[0])='lleH';
*((unsigned long*)&strHello[4])=' o';
*((unsigned long*)&strHello[8])=' lxz';
strHello[12]='\n';
__asm__ volatile ("int $0x80; movl $0,%%ebx; movl $1,%%eax; int $0x80"\
:\
: "a"((long) 4),\
"b" ((long)1),\
"c" ((long)(&strHello[0])),\
"d" ((long)13));
}
/*
AMD 64下的調用write系統調用可能有如下形式,其中__syscall是中斷調用指令,__NR_write是系統功能號:
__asm__ volatile (__syscall \
: \
: "a" (__NR_write),"D" ((long)(1)),"S" ((long)(&strHello[0])),"d" ((long)(13)) : "r11","rcx","memory" );
*/
//簡單判斷是否是ELF檔案
int IsElf(Elf32_Ehdr *pEhdr)
{
if( pEhdr->e_ident[EI_MAG0] !=0x7f
|| pEhdr->e_ident[EI_MAG1] !='E'
|| pEhdr->e_ident[EI_MAG2] !='L'
|| pEhdr->e_ident[EI_MAG3] !='F'
|| pEhdr->e_machine !=EM_386)//是否在x86上運行
return 0;
return 1;
}
//將檔案hFile,從pos處開始讀取count個位元組到緩衝區buf中
int ReadAt(int hFile, int pos, void *buf, int count)
{
if(pos == lseek(hFile, pos, SEEK_SET))
return read(hFile, buf, count);
return -1;
}
//將緩衝區buf中的內容(count個位元組),寫入檔案hFile中(從pos處開始寫入)
int WriteAt(int hFile, int pos, void* buf, int count)
{
if(pos == lseek(hFile, pos, SEEK_SET))
return write(hFile, buf, count);
return -1;
}
//找到程式第一條指令所在的段,並把該段的程式頭結構讀到pPhdr指向的結構中
//參數entry為第一條可執行機器指令在進程被正確載入後的記憶體位址(Elf32_Ehdr->e_entry)
int FileEntryIndex(int hFile, Elf32_Ehdr* pEhdr, Elf32_Phdr *pPhdr, unsigned long entry)
{
int i;
for(i = 0; i < pEhdr->e_phnum; i++) {
if(sizeof(*pPhdr) !=
ReadAt(hFile,
pEhdr->e_phoff + i*pEhdr->e_phentsize,
pPhdr,
sizeof(*pPhdr)))
return 0;
if(entry >= pPhdr->p_vaddr && entry < (pPhdr->p_vaddr + pPhdr->p_memsz))
return 1;
}
return 0;
}
int main()
{
int hFile;
int offset, size;
Elf32_Ehdr ehdr;
Elf32_Phdr phdr;
//以讀寫方式開啟檔案
hFile = open("hello", O_RDWR, 0);
if(hFile < 0)
return -1;
//讀取ELF頭
if(sizeof(ehdr) != ReadAt(hFile, 0, &ehdr, sizeof(ehdr)))
goto error;
//判斷是否是ELF檔案
if(!IsElf(&ehdr))
goto error;
//找到該檔案第一條指令所在的段並讀出這個段的程式頭結構資訊
if(!FileEntryIndex(hFile, &ehdr, &phdr, ehdr.e_entry))
goto error;
//計算第一條指令在整個檔案中的位置
offset = ehdr.e_entry - phdr.p_vaddr;
offset += phdr.p_offset;
//計算exit_print函數體的位元組數
size=(int)(&IsElf) - (int)(&exit_print);
//修改ELF檔案第一條可執行機器指令在進程被正確載入後的記憶體位址
if(size != WriteAt(hFile, offset, exit_print, size))
goto error;
printf("write Elf file success!\n");
error:
close(hFile);
return 0;
}
編譯的時候gcc會有如下警告提示!
# gcc mod_entry.c -o mod_entry
mod_entry.c:20:37: warning: multi-character character constant
mod_entry.c:21:37: warning: multi-character character constant
mod_entry.c:22:37: warning: multi-character character constant
不用在意這個警告,它毫無防礙。這個程式非常簡單,因為它忽略了許多本該注意的問題,比如被修改的ELF檔案的那個段是否足夠大可以容下我們的exit_print函數體? 實事上我們的函數很小,它幾乎總能使你的實驗成功。
# ./mod_entry
write Elf file success!
# ./hello (此時hello檔案的入口地址已經被修改了)
Hello zxl
mod_entry將函數exit_print的二進位機器指令複製到hello檔案e_entry開始的位置。注意複製後,hello的入口地址依然是e_entry。
節頭(節頭表) -- Section Header
------------------------------------------------
節頭也叫節頭表。ELF頭的e_shoff欄位給出了節頭在整個檔案中的位移(如果節頭存在的話),節頭可看做一個在檔案中連續儲存的結構數組(Elf32_Shdr結構的數組),數組的長度由ELF頭的e_shnum欄位給出,數組中每個結構的位元組大小由ELF頭的e_shentsize欄位給出。把檔案指標移到在ELF頭中e_shoff給出的位置,然後讀出的內容就是節頭了。節頭表是從串連角度看待ELF檔案的結果,所以從節頭的角度ELF檔案分成了許多的節,每個節儲存著用於不同目的的資料,這些資料可能被前面提到的程式頭重複引用。關於節的內容非常瑣碎,完成一次任務所的需的資訊往往被分散到不同的節裡。
相對而言,PE中的資源表、引入表、匯出表都集中給出了所有相關的資訊,理解起來真是方便多了。由於節中資料的用途不同,節被分為不同的類型,每種類型的節都有自己組織資料的方式。
有的節儲存著一些字串,例如前面提過的字串表就是一種類型的節;
有的節儲存一張符號表,程式從動態串連庫中引入的函數和變數都會出現在一個叫做”動態符號表“的節中;
重定位表則包含在重定位節中。
不管這些節是何種類型,在節頭中都用相同的結構儲存著與這些節有關的資訊。先來看一看節頭中用來儲存這些資訊的結構吧:
/usr/src/linux/include/linux/elf.h
typedef struct {
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;
Elf32_Shdr->sh_name
這個整數佔用兩個位元組,它能告訴你這個節的名字。講字串表的時候會再來研究這個欄位。
Elf32_Shdr->sh_type
節的類型。或者說它能告訴你這個節裡存放的是什麼樣的資料。隨著ELF的發展,用於不同目的的節會不斷增多,節的類型值是在elf.h中定義的一些常量。例如字串表是SHT_STRTAB,符號表是SHT_SYMTAB等。
Elf32_Shdr->sh_flags
節的屬性。這個欄位在32位下佔4個位元組,64位下佔8個位元組。與程式頭中的p_flags欄位一樣,它用每一個二進位位表示一種屬。其中最低位如果為1表示此節在進程執行過程中可寫,次低位為1表示此節的內容載入時要讀到記憶體中去,第三低位為1表示這個節中的資料是可執行檔機器指令。一些常量,如 SHF_INFO_LINK,協助用來測試節的屬性。
Elf32_Shdr->sh_addr
如果此節的內容將出現在進程空間裡,這個欄位給出了該節在記憶體中起始地址。
Elf32_Shdr->sh_offset
如果此節在檔案中佔用一定的位元組,這個欄位給出了該節在整個檔案中的起始位移量。
Elf32_Shdr->sh_size
如果此節在檔案中佔用一定的位元組,這個欄位給出了該節在檔案中的位元組大小,如果此節在檔案中不存在但卻存在於記憶體中那麼此欄位給出了此節在記憶體中的位元組大小。
Elf32_Shdr->sh_link
如果另一個節與這個節相關聯,這個欄位給出了相關的節在節頭中的索引。
Elf32_Shdr->sh_info
這個欄位如果用到再說。
Elf32_Shdr->sh_addralign
地址對齊。這個數是2的整數次冪,對齊只能是2位元組對齊、4位元組對齊、8位元組對齊等。如果這個數是0或1表示這個節不用對齊。
Elf32_Shdr->sh_entsize
這個欄位是一個代表位元組大小的數,對某些節才有意義。例如對動態符號節來說這個欄位就給出動態符號表中每個符號結構的位元組大小。
節頭的結構講完了,很多一時用不到的知識被暫時拋棄。對於節的知識我們掌握了很少,但我希望至少能夠知道每個節的名字。所以我們必須開始接觸我們將要學習的幾種類型節的第一類 -- 字串表!