详解ELF可执行文件格式:读取头部信息和程序表头

coding

要想实现ELF文件的入口劫持,不深入掌握其运行原理与组成结构那是不可能的。ELF的内部结构复杂,加载逻辑难以理解,因此我们需要通过切香肠的方式,将这个困难的技术点一点一滴的去攻克。

这一节我们先掌握如何读取头部信息和程序表头,我们先看ELF文件的大致结构:

ELF文件格式最重要的就是所谓的段,特别是其中的代码段和数据段。对应上图就是.text,.data两个段。每个段都对应一个段表来描述,而若干隔断会组成一个整体,它对应一个program,而后者则由program header table来指向,讲解ELF数据结构最为详细的就是网址如下,有心的朋友可以认真阅读:

https://man7.org/linux/man-pages/man5/elf.5.html

我们本次要解读ELF文件的两个部分,一个是其文件头。文件头描述了ELF文件很多重要信息,例如它运行的平台,支持的CPU类型等。使用命令行readelf -h 可以读取指定ELF文件的头部信息,如下图所示:

其对应数据结构如下:

#define EI_NIDENT 16

typedef struct {
unsigned char e_ident[EI_NIDENT];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
ElfN_Addr e_entry;
ElfN_Off e_phoff;
ElfN_Off e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum;
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
} ElfN_Ehdr;

e_ident数组的不同字节 有不同含义,第0个字节必须是0x7f,接下来对应三个字符’ELF’,第4个字节说明它运行在32位还是64位系统,第5个字节说明数据是大端还是小端,第6个字节表示版本,大多数情况下该字节为1.

接下来的两个字节也就是e_type对应ELF文件的类型,用于表明它是可执行文件,亦或是动态链接库,还是重定向文件也就是编译后还没有被链接的二进制文件。e_machine用于表明它运行的CPU类型,e_entry表示它被加载到内存后,第一条指令所在的虚拟地址,e_phoff表示程序表头相对于该文件内部偏移,后面我们要读取程序表头时需要使用该值。e_shoff表示的是段表在文件内部的偏移。

段与程序头有逻辑上的对应关系,就像前面图形所示,一个程序头对应多个段,程序头用于告诉系统如何将各个段放入到内存中。段对应的数据有多种类型,其中最重要的就是.text和.data,分别对应代码和数据,e_flags通常取值0,它的作用暂时用不到。

e_ehsize对应ELF文件头数据结构的大小。e_phentsize用于表明程序表头一条记录的大小,程序表头记录用于描述每个程序段对应的属性和性质,e_phnum表示程序表头记录的个数,e_shentsize表示段记录的大小,它用来描述每个段的性质,e_shnum表示段记录的个数,最后e_shstrndx表示段字符串表的下标。

该数据结构中有很多字段我们不需要关系,需要关心的也就是程序表头和段表头对应的字段,这些字段的使用在后续说明中会详细解读,我们首先展示如何使用python实现ELF文件头的解读,其中链接: 

https://pan.baidu.com/s/1YbApA8J_68E1UlLHpAtc9A

 密码: ao1d
对应的是代码所解读的ELF文件,以下是解读ELF头的实现:

import struct
elf32_path = "/content/drive/My Drive/elf32/hello_world.o"


ET_REL = 1 #.o类型
ET_EXEC = 2 #可执行
ET_DYN = 3 #动态链接

ELFCLASSNONE = 0
ELFCLASS32 = 1
ELFCLASS64 = 2

LITTLE_ENDIAN = 1 #数据编码是大端还是小端
BIG_ENDIAN = 2

#支持的CPU类型
MACHINE_EM_386 = 3 #Intel 80386
MACHINE_EM_860 = 7 #Intel 80860
MACHINE_S570 = 9 #IBM System/370
VERSION_CURRENT = 1

PT_NONE = 0 #程序头表未定义
PT_LOAD = 1 #对应的段要被加载到内存中
PT_DYNAMIC = 2 #包含动态链接对应的信息
PT_INTERP = 3 #连接器二进制可执行文件对应路径
PT_NOTE = 4 #
PT_SHLIB = 5 #保留,不应该是该值
PT_PHDR = 6 #该程序头专门用于描述程序头表

PF_X = 1 #可执行
PF_W = 2 #可写
PF_R = 3 #可读

def read_elf_header(binary_data):
format = "@"+ "".join(['c']*16)
magic = struct.unpack(format, binary_data[0:16])
print("Magic: ", magic)
elf_class = int.from_bytes(magic[4], "little") #32位还是64位
if elf_class == ELFCLASS32:
print("class ELF32")
if elf_class == ELFCLASS64:
print("class ELF64")
endian = int.from_bytes(magic[5], "little")
if endian == LITTLE_ENDIAN:
print("little endian")
elif endian == BIG_ENDIAN:
print("big endian")
version = int.from_bytes(magic[6], "little")
if version == VERSION_CURRENT:
print("Version Current")

o_class = struct.unpack("h", binary_data[16:18])[0]
file_type = "type: "
if o_class == ET_REL:
file_type += "ET_REL"
if o_class == ET_EXEC:
file_type += "ET_EXEC"
if o_class == ET_DYN:
file_type += "ET_DYN"
print(file_type)

machine_type = struct.unpack("h", binary_data[18:20])[0]
if machine_type == MACHINE_EM_386:
print("Machine: Intel 80386")
obj_file_version = struct.unpack("I", binary_data[20: 24])[0]
print("object file version: ", obj_file_version)
virtual_entry = struct.unpack("i", binary_data[24:28])[0]
print("Entry point address: ", hex(virtual_entry))
program_header_offset = struct.unpack("i", binary_data[28:32])[0]
print("program header offset: ", program_header_offset) #程序头表在文件内部偏移
section_header_offset = struct.unpack("i", binary_data[32:36])[0]
print("section header offset: ", section_header_offset)#段头表在文件内部偏移
processor_flag = struct.unpack("i", binary_data[36:40])[0]
print("processor flag: ", processor_flag )
this_header_size = struct.unpack("h", binary_data[40:42])[0]
print("size of this header: ", this_header_size)
program_header_entry_size = struct.unpack("h", binary_data[42:44])[0] #程序头表中一条记录的大小
print("program header entry size: ", program_header_entry_size)
program_entry_count = struct.unpack("h", binary_data[44:46])[0] #程序头表中记录的数量
print("program header entry count: ", program_entry_count)
section_header_entry_size = struct.unpack("h", binary_data[46:48])[0] #段记录的大小
print("section header entry size: ", section_header_entry_size)
section_header_count = struct.unpack("h", binary_data[48:50])[0] #段表记录的数量
print("section header count: ", section_header_count)
section_string_table = struct.unpack("h", binary_data[50:52])[0]
print("section string table index: ", section_string_table)
return (program_header_offset, section_header_offset, program_header_entry_size, program_entry_count, section_header_entry_size, section_header_count)

接下来我们看程序表头的读取,使用readelf -l 就能获取程序表头的信息。程序表头记录告诉系统如何将ELF文件内的数据加载到内存中,它对应的数据结构如下:

typedef struct {
uint32_t p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
uint32_t p_filesz;
uint32_t p_memsz;
uint32_t p_flags;
uint32_t p_align;
} Elf32_Phdr;

p_type对应程序表头记录所描述的数据的类型。特别重要的有以下几种,PT_LOAD表示它描述的数据需要被加载到内存中,p_vaddr是要加载到内存中的虚拟地址,p_addr是内存对应的物理地址,了解计算机结构体系的同学会知道内存虚拟地址其实是将内存物理地址经过一系列映射或转换后得到的数值,p_addr该字段在绝大多数情况下用不到。我们基于尽可能简化认知负担的原则,对p_type的其他值暂时不考量,使用到时再详细说明。

p_offset表示程序表头记录相对于文件内的偏移。p_filesz表示程序表头记录所描述的数据长度,p_memsz表示对应数据加载到内存后的长度。通常情况下这两者相同,但由于加载到内存时可能需要字节对齐,因此后者有可能比前者要大。

p_flags描述程序表头记录所描述数据的属性,如果取值PF_X表示描述的数据是可执行的代码,PF_W表示所描述数据是可修改的数据,PF_R表示所描述数据具有可读性质。p_align用于表明所描述数据是否需要内存对齐,取值0表示不需要对齐,要不然它必须取值2的指数,同时必须使得 p_vaddr % p_align == p_offset % p_align,这些知识涉及到计算机体系结构,我们暂时先忽略,接下来看解读程序表头的代码:

def readelf_program_header(binary_data, size, count):
print("there are {} program header entries".format(count))
for i in range(count):
binary_data = binary_data[size * i :]
program_type = struct.unpack("i", binary_data[0:4])[0]
if program_type == PT_NONE:
print("header type: PT_NONE")
if program_type == PT_LOAD:
print("header type: PT_LOAD") #可转载到内存里的程序段,对应代码和数据,这些段才是我们关心的
elif program_type == PT_DYNAMIC:
print("header type: PT_DYNAMIC")
elif program_type == PT_INTERP:
print("header type: PT_INTERP")
elif program_type == PT_NOTE:
print("header type: PT_NOTE")
elif program_type == PT_SHLIB:
print("header type: PT_SHLIB")
elif program_type == PT_PHDR:
print("header type: PT_PHDR")
else:
print("header type hex: ", hex(program_type))

header_offset = struct.unpack("i", binary_data[4:8])[0]
print("program header content offset: ", hex(header_offset))
virtual_addr = struct.unpack("i", binary_data[8:12])[0]
print("program header content virtual address: ", hex(virtual_addr))
pysical_addr = struct.unpack("i", binary_data[12:16])[0]
print("program header content pysical address: ", hex(pysical_addr))
header_file_size = struct.unpack("i", binary_data[16:20])[0]
print("program header file size: ", header_file_size)
header_memory_size = struct.unpack("i", binary_data[20:24])[0]
print("program header memory size: ", header_memory_size)
header_flags = struct.unpack("i", binary_data[24:28])[0]
if (header_flags & PF_X):
print("this segment can be execute")
if (header_flags & PF_R):
print("this segment can be read")
if (header_flags & PF_W):
print("this segment cab be write")
header_align = struct.unpack("i", binary_data[28:32])[0]
print("align value: ", header_align)

最后我们将两部分实现衔接起来:

with open(elf32_path, 'rb') as f:
binary_data = f.read()
elf32_info = read_elf_header(binary_data)
program_header_offset = elf32_info[0]
program_header_entry_size = elf32_info[2]
program_header_entry_count = elf32_info[3]
print("header offset: ", program_header_offset)
readelf_program_header(binary_data[program_header_offset:], program_header_entry_size,
program_header_entry_count)

上面代码运行后可以看到输出的内容与使用readelf -h 或-l所得的结果差不多。深刻掌握ELF文件结构及其加载原理是实现Linux上二进制劫持的基础,其过程很烦琐,同时又涉及到很多平常用不上的关于硬件和体系结构的知识,能否啃得下这些枯燥的知识点,决定了一个人是否有毅力和决心在技术之路上走的足够远并且最终能脱颖而出。

更多精彩内容请点击”阅读原文“


本文分享自微信公众号 - Coding迪斯尼(gh_c9f933e7765d)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

以上是 详解ELF可执行文件格式:读取头部信息和程序表头 的全部内容, 来源链接: utcz.com/z/509451.html

回到顶部