HNU计算机系统实验3:保姆级攻略
- [[#安装nasm|安装nasm]]
- [[#linux 64位操作系统环境下用gcc编译32位程序|linux 64位操作系统环境下用gcc编译32位程序]]
- [[#Hello World 的elf文件分析|Hello World 的elf文件分析]]
- [[#Hello World 的elf文件分析#分析hw.c的elf文件结构|分析hw.c的elf文件结构]]
- [[#分析hw.c的elf文件结构#ELF Header分析|ELF Header分析]]
- [[#分析hw.c的elf文件结构#Section Headers 分析|Section Headers 分析]]
- [[#分析hw.c的elf文件结构#Program Headers分析|Program Headers分析]]
- [[#分析hw.c的elf文件结构#.dynamic分析|.dynamic分析]]
- [[#分析hw.c的elf文件结构#重定位表分析|重定位表分析]]
- [[#分析hw.c的elf文件结构#Q&A 如何通过重定位表进行重定位?|Q&A 如何通过重定位表进行重定位?]]
- [[#分析hw.c的elf文件结构#符号表分析|符号表分析]]
- [[#Hello World 的elf文件分析#程序执行流程|程序执行流程]]
- [[#Hello World 的elf文件分析#文件大小分析|文件大小分析]]
- [[#Hello World 的elf文件分析#分析hw.c的elf文件结构|分析hw.c的elf文件结构]]
- [[#开始获取最小ElF文件|开始获取最小ElF文件]]
- [[#开始获取最小ElF文件#step1|step1]]
- [[#开始获取最小ElF文件#step2|step2]]
- [[#step2#手写汇编尝试|手写汇编尝试]]
- [[#开始获取最小ElF文件#step 3|step 3]]
- [[#step 3#原始汇编代码(zero)|原始汇编代码(zero)]]
- [[#step 3#elf文件大小|elf文件大小]]
- [[#step 3#查看并分析其elf文件|查看并分析其elf文件]]
- [[#step 3#Q&A:我们并没有明显调用外部库函数,为什么还会出现这些链接部分以及其有什么作用?|Q&A:我们并没有明显调用外部库函数,为什么还会出现这些链接部分以及其有什么作用?]]
- [[#step 3#尝试取消链接标准库和启动代码|尝试取消链接标准库和启动代码]]
- [[#step 3#修改后的汇编代码(one)|修改后的汇编代码(one)]]
- [[#step 3#elf文件大小|elf文件大小]]
- [[#开始获取最小ElF文件#step 3.5|step 3.5]]
- [[#step 3.5#根据实验指导缩减代码|根据实验指导缩减代码]]
- [[#step 3.5#不缩减代码会有什么问题?|不缩减代码会有什么问题?]]
- [[#开始获取最小ElF文件#step 4|step 4]]
- [[#step 4#哪些部分对极简elf文件是必要的|哪些部分对极简elf文件是必要的]]
- [[#step 4#手搓elf文件代码|手搓elf文件代码]]
- [[#开始获取最小ElF文件#step 5|step 5]]
- [[#step 5#将_start压入elf_header(one)|将_start压入elf_header(one)]]
- [[#将_start压入elf_header(one)#此时的elf文件大小|此时的elf文件大小]]
- [[#将_start压入elf_header(one)#step3.5简化代码的原因|step3.5简化代码的原因]]
- [[#step 5#将program header压入elf header(two)|将program header压入elf header(two)]]
- [[#将program header压入elf header(two)#此时的elf文件大小|此时的elf文件大小]]
- [[#step 5#将_start压入elf_header(one)|将_start压入elf_header(one)]]
- [[#开始获取最小ElF文件#step 6|step 6]]
- [[#step 6#elf header各部分的内容,作用,大小(这个表有点乱,有缘再整理)|elf header各部分的内容,作用,大小(这个表有点乱,有缘再整理)]]
- [[#step 6#program header 各部分的内容,作用,大小|program header 各部分的内容,作用,大小]]
- [[#step 6#重叠elf header 结构与program header 结构(one)|重叠elf header 结构与program header 结构(one)]]
- [[#重叠elf header 结构与program header 结构(one)#修改后的代码|修改后的代码]]
- [[#重叠elf header 结构与program header 结构(one)#此时的elf文件大小|此时的elf文件大小]]
- [[#step 6#将program header和elf header重叠得更多|将program header和elf header重叠得更多]]
- [[#将program header和elf header重叠得更多#修改后的代码(two)|修改后的代码(two)]]
- [[#将program header和elf header重叠得更多#elf文件大小|elf文件大小]]
- [[#step 6#移除文件末尾的0(three)|移除文件末尾的0(three)]]
- [[#移除文件末尾的0(three)#修改后的代码|修改后的代码]]
- [[#移除文件末尾的0(three)#elf文件大小|elf文件大小]]
- [[#正常执行
完结撒花❀|正常执行完结撒花❀]] - [[#实验过程中出现的问题|实验过程中出现的问题]]
- [[#体会|体会]]
一.实验任务
安装nasm
linux 64位操作系统环境下用gcc编译32位程序
1 |
|
Hello World 的elf文件分析
分析hw.c的elf文件结构
elf通常的文件结构如下:
ELF Header分析
- 文件类型:32位ELF(ELF32)
- 字节序:小端序(little endian)
- 文件类型:DYN(位置无关可执行文件,PIE)
gcc中没有使用-static参数,可以看到entry point的入口也不是静态链接时的0x8开头的32位地址 - 目标架构:Intel 80386(x86 32位)
- 入口点地址:0x1060(_start函数的地址)
- 程序头表(program headers)数量:11个
- 节区头表(section headers)数量:29个
Section Headers 分析
.text (0x1060-0x11cd):包含可执行代码,大小为0x16d字节
- 包含main函数(地址0x118d,大小60字节)
- 包含_start函数(地址0x1060,大小44字节)
.rodata (0x2000):只读数据段,包含”Hello,World!”字符串常量
.data (0x4000):初始化的数据段,大小为8字节
.bss (0x4008):未初始化的数据段,大小为4字节
.dynamic:动态链接信息
.plt:过程链接表,用于动态链接
.got:全局偏移表,用于动态链接
Program Headers分析
程序头表定义了11个段,主要包括:
- PHDR:程序头表自身
- INTERP:指定动态链接器路径(/lib/ld-linux.so.2)
- 4个LOAD段:
- 第一个LOAD段:只读,包含ELF头和程序头表
- 第二个LOAD段:可读可执行,包含.text节(代码)
- 第三个LOAD段:只读,包含.rodata节(常量数据)
- DYNAMIC:动态链接信息
- GNU_STACK:定义栈属性(可读可写,不可执行)
.dynamic分析
动态节区主要显示:
- 依赖的共享库:libc.so.6(glibc标准c库)
- INIT:起始代码段地址
- FINI:终止代码段地址
- STRTAB/SYMTAB:动态字符串表/符号表地址
- PLTGOT:GOT地址
重定位表分析
- .rel.dyn(对应.got)处理数据段重定位,类型包括
R_386_RELATIVE
(基址重定位)和R_386_GLOB_DAT
(全局符号地址) - .rel.plt(对应.got.plt)处理函数跳转重定位(如
printf
和__libc_start_main
),类型为R_386_JUMP_SLOT
(PLT 跳转槽)。
Q&A 如何通过重定位表进行重定位?
- .rel.dyn
- R_386_RELATIVE重定位过程:* 修正地址=基址(info)+原始值(offset)
- R_386_GLOB_DAT重定位过程:初始化got条目,填入符号的绝对地址
- .rel.plt
- 1.首次调用时,跳转到plt桩(如printf的libc地址),通过.got.plt条目触发动态连接器解析符号地址
- 2.地址回填:解析后的函数地址写入.got.plt(如0x3fe8)
- 3.后续调用:直接跳转到.got.plt缓存的地址,无需再次解析
符号表分析
- .dynsym (8项):
动态符号表,记录外部依赖符号(如printf@GLIBC_2.0
)。 - .symtab (40项):
静态符号表,包含本地符号(如main
、_start
)和调试信息。
程序执行流程
- 操作系统加载程序,并将控制权交给动态链接器(/lib/ld-linux.so.2)
- 动态链接器解析所有外部符号(如printf)
- 执行从_start函数(地址0x1060)开始
- _ start调用__libc_start_main,后者设置C运行时环境
- _ libc_start_main调用main函数(地址0x118d)
- main函数调用printf打印”Hello,World!”
- main返回0,程序结束
文件大小分析
尽管源代码只有几行,编译后的ELF文件包含大量节区和信息,这是因为:
- C运行时环境:在main函数执行前库和准备安全机制。,需要大量的初始化代码(_start函数和__libc_start_main函数)来设置程序环境、初始化C
- 动态链接支持:程序使用了printf这样的外部函数,需要.dynamic、.plt、.got等节区来支持动态链接,以及重定位表来解析外部符号。
- 元数据和调试信息:ELF文件包含符号表、字符串表、版本信息和注释信息等元数据,这些对于程序的加载、调试和维护至关重要。
- 安全机制:现代编译器会添加位置无关代码(PIE)、堆栈保护和GOT保护等安全特性,这些都增加了文件大小。
- 标准库支持:即使只调用一个printf函数,也需要包含支持该函数运行的所有必要信息。
开始获取最小ElF文件
step1
- 前述仅仅返回你学号(最后两位)的代码,ta的可执行文件有多大?
- 如果要减小可执行代码,其C源码层面还能进一步简化吗?如果不能,为什么?
该代码仅包含一个main
函数和return
语句,从语法角度已无法进一步简化。任何修改(如删除return
或修改返回值)都会改变程序行为或导致编译错误. - 求助于gcc编译器呢?ta有优化选项啊?请尝试,是否缩小了可执行文件?
- 使用-O3优化选项
- 剥离符号表:编译时加
-s
选项移除调试信息
可以看到减少了很多字节,readelf查看后可以看到
==Entry address发生了变化,.symtab和.strstab节消失==
- 使用-O3优化选项
step2
手写汇编尝试
- 示例代码
- 运行尝试
1 |
|
- 通过readelf objdump 查看.o文件,可执行文件的内容
1 |
|
step 3
原始汇编代码(zero)
- asm
- 汇编(objdump)
elf文件大小
查看并分析其elf文件
- 其动态链接部分如下
1 |
|
==显式依赖:0x00000001 (NEEDED) Shared library: [libc.so.6]==
==隐式依赖:动态链接器,ld-linux.so.2==
Q&A:我们并没有明显调用外部库函数,为什么还会出现这些链接部分以及其有什么作用?
- 我们可以查看一下08文件的依赖库
即使什么外部库都没有调用,可执行文件依然链接了三个库:libc.so.6,ld-linux.so.2,ld-linux.so.2
[!NOTE] 解释
==libc.so.6==
动态节区中的NEEDED
条目(0x00000001
)直接声明了依赖GNU C库(glibc)提供标准C函数(如printf
、malloc
)和程序启动/终止的底层支持(如__libc_start_main
)
程序入口初始化:__libc_start_main
(.rel.plt
中重定位的符号)负责调用main
函数前的环境设置(如栈初始化、全局对象构造)
事务内存:_ITM_registerTMCloneTable
等符号(.rel.dyn
R_386_GLOB_DAT类型)由GCC自动插入,用于线程安全操作
==ld-linux.so.2==
虽然未在.dynamic
中显式列出,但所有动态链接的ELF文件均隐式依赖动态链接器(通过.interp
节指定路径)。它负责加载libc.so.6
并处理重定位
工作流程:
内核加载可执行文件后,将控制权交给ld-linux.so.2
。
解析.dynamic
节区,加载libc.so.6
等库到内存。
重定位符号地址
==linux-gate.so.1==
这是一个虚拟动态共享库[VSDO](vDSO - 维基百科,自由的百科全书 — vDSO - Wikipedia),由Linux内核直接提供,不实际存在于文件系统中**。它用于加速系统调用(syscall),避免传统int 0x80
中断的性能开销
特点:
地址固定(如0xf7f08000
),映射到每个进程的地址空间。
-提供高效的sysenter
或syscall
指令实现(取决于CPU架构)
所以我们可以知道出现这些链接部分的原因啦
- gcc默认动态链接libc,即使代码只包含mov和ret,程序入口(_ start)等仍然由libc提供
- ld-linux.so.2即动态链接器本身也是个共享库,他负责加载所有共享库并处理重定位;
- 现代工具链默认生成位置无关代码(pie),依赖动态链接器处理基址重定位
- gcc插入_ITM_符号,需要libc支持
尝试取消链接标准库和启动代码
1 |
|
报错:
我们需要编写自己的_start入口函数去代替标准库的main调用
报段错误:
查看堆栈顶部:
[!NOTE] 解释
程序启动时,os会将命令行参数和环境变量压入堆栈:
==堆栈顶部 ->argc argv数组 envp数组==
argc是命令行参数个数,argv是输指向参数字符串的指针数组,envp为环境变量指针数组
此时堆栈顶部看似是地址(如果这是作为c函数的话),但其实是argc,在_start中使用ret会错把argc当成返回地址,导致段错误
_ start 不是c函数,而是链接器指定的程序入口点符号,使用-nostdlib编译时,程序跳过了标准启动文件crt1.o,此时需要手动实现_start的逻辑,不能用c函数的方式返回,因为操作系统不会在堆栈顶部设置return address
所以我们应该用系统调用直接终止进程,回忆一下系统调用的原理?
int$0x80软中断陷入内核查找中断向量表,%eax存储了系统调用号,%ebx存储了返回值,
[!NOTE] 解释
sys_exit
的系统调用号为1,存在一个约定的地方(eax
)让内核读取从而去系统调用表查找对应系统调用函数
前 5 个参数依次存入 EBX、ECX、EDX、ESI、EDI,超过 5 个参数时需通过内存块传递,sys_exit
仅需 1 个参数(退出码),因此该参数存入 ebx
寄存器int $0x80
通过软中断触发用户态到内核态的切换,0x80是预设的系统调用中断号,cpu根据该值在中断向量表中找到对应的处理程序入口地址(system_call)
明白原理,让我们手搓一点点汇编
修改后的汇编代码(one)
- asm
- 汇编(objdump)
此时的elf文件大小为:
查看Elf文件,发现跟链接有关的节还是存在
非常讨厌,这是因为 gcc 在链接时会增添一些额外信息, 但我们没有使用它的任何附加功能,因此我们可以自己调用链接器 ld
1 |
|
再次查看elf文件,讨厌的dynamic终于消失了
[!NOTE] ATTENTION
ld 记得加-s哦不然会多出一大串systab出来
elf文件大小
step 3.5
根据实验指导缩减代码
- asm
- 汇编(objdump)
不缩减代码会有什么问题?
详见后文:step3.5简化代码的原因
step 4
哪些部分对极简elf文件是必要的
[!NOTE] 解释
- 必要部分
- elf header: 文件格式,入口地址
- program header: 至少需要一个Pt_load段,(VirtAddr->PhysAddr的映射),该段包含.text
- text节区:包含执行代码,绝对不能少
- pt_load段,可读可执行,对于.text节(程序代码)
- 非必要部分:
- .data .bss .rodata .symtab:我们没有变量(事实什么类型的变量都没有),不需要
- .rel.txt .rel.data:重定位条目,同理不需要
- .debug :这个更不必说,省略省略~
- section header:节头表用来记录每个节的位置,在链接的时候非常重要,但是我们现在要运行Elf,不太需要这东西
所以我们需要:ELF Header、Program Header(至少一个段)、.text
节区。
手搓elf文件代码
报错:
解决:
1 |
|
`### elf文件大小
step 5
将_start压入elf_header(one)
1 |
|
把以上填充字节用_start代替,进一步压缩文件
此时的elf文件大小
==执行文件时报错,那肯定是_start出现了什么问题?这个问题其实在step2中埋下了伏笔,让我们在这里一起为step2做出解答==
当我观察elf文件时,我发现phnum居然为0
可是明明在nasm里写的1
我怀疑是否哪里出了错导致我的phnum被覆盖了
非常显然,我应该去查看_start,我发现_start中的机器码一共7字节,而填充字节原本有8位
元凶就是你!
于是我们修改asm代码如下
现在就可以正常运行啦~
[!NOTE] 一些补充
dd
定义32位的数据单元dw
定义16位的数据单元db
定义8位的数据单元
step3.5简化代码的原因
汇编转机器码时,xor,inc比mov的指令编码长度要小的多,mov bl.08 比mov eax,08的编码长度要小,这样才能进一步通过叠叠乐将elf文件进一步简化,防止elf文件被覆写。
将program header压入elf header(two)
观察到program header前八个字节和elf header后八个字节长得很像 ,将相同的部分叠在一起,缝合
此时的elf文件大小
step 6
elf header各部分的内容,作用,大小(这个表有点乱,有缘再整理)
内容 | 作用 | 大小 | |
---|---|---|---|
e_ident[16] |
Magic Number(0-3字节):固定为0x7F 'E' 'L' 'F' ,标识ELF文件 EI_CLASS(4字节):文件类型, 1 表示32位(ELFCLASS32 ),2 表示64位(ELFCLASS64 )EI_DATA(5字节):字节序, 1 为小端(ELFDATA2LSB ),2 为大端(ELFDATA2MSB )EI_VERSION(6字节):ELF版本,固定为 1 (EV_CURRENT )EI_OSABI(7字节):操作系统ABI类型(如Linux为 3 )EI_ABIVERSION(8字节):ABI版本(通常为 0 )填充字节(9-15字节):对齐保留字段,默认用 0 填充 |
标识ELF文件的基本属性 | 16字节 |
e_type |
1:ET_REL:可重定位文件(如.o 文件)2:ET_EXEC:可执行文件 3:ET_DYN:共享库(如 .so 文件)4:ET_CORE:核心转储文件 |
定义文件类型 | 2字节 |
e_machine |
3:EM_386:Intel x86架构 0x28:EM_AR:ARM架构 0x3E:EM_X86_64:x86-64架构 |
目标架构标识 | 2字节 |
e_version |
固定为1 | ELF文件版本 | 4字节 |
e_entry |
程序入口点的虚拟地址(VA) | 程序入口点的虚拟地址(VA) | 4字节/32位 8字节/64位 |
e_phoff |
程序头表(Program Header Table)在文件中的偏移量 | 程序头表(Program Header Table)在文件中的偏移量 | 32位下4字节,64位下8字节 |
e_shoff |
节头表(Section Header Table)在文件中的偏移量 | 节头表(Section Header Table)在文件中的偏移量 | 32位下4字节,64位下8字节 |
e_flags |
通常为0 | 处理器特定标志(如ARM架构的指令集类型)。 | 4字节 |
e_ehsize |
ELF头自身的大小 | ELF头自身的大小(32位为52字节,64位为64字节) | 2字节 |
e_phentsize |
程序头表中每个条目的大小(默认值为32字节,0x20) | 程序头表中每个条目的大小 | 2字节 |
e_phnum |
程序头表中条目的数量。 | 程序头表中条目的数量。若为0 ,表示无程序头表 |
2字节 |
e_shentsize |
节头表中每个条目的大小(固定为40字节) | 节头表中每个条目的大小(固定为40字节) | 2字节 |
e_shnum |
节头表中条目的数量。若为0 ,表示无节头表 |
节头表中条目的数量 | 2字节 |
e_shstrndx |
节名称字符串表在节头表中的索引 | 节名称字符串表在节头表中的索引 | 2字节 |
program header 各部分的内容,作用,大小
内容 | 作用 | 大小(32位) | |
---|---|---|---|
p_type | 段类型标识符(如PT_LOAD 、PT_DYNAMIC ) |
定义段的用途,如代码加载、动态链接信息、解释器路径等 | 4字节 |
p_offset | 段在文件中的偏移量 | 指示段内容在ELF文件中的起始位置 | 4字节 |
p_vaddr | 段加载到内存的虚拟地址 | 指定段在进程虚拟地址空间中的起始位置 | 4字节 |
p_paddr | 段加载到物理内存的地址(通常与p_vaddr 相同,现代系统忽略) |
保留字段,多用于嵌入式系统或无虚拟内存的架构 | 4字节 |
p_filesz | 段在文件中的大小 | 确定从文件读取多少字节内容到内存(若为0,表示无文件内容) | 4字节 |
p_memsz | 段在内存中的大小 | 定义段在内存中占用的空间(可能大于p_filesz ,多余部分填充零) |
4字节 |
p_flags | 段权限标志(PF_R 、PF_W 、PF_X 的组合) |
控制内存页的访问权限(如可读、可写、可执行) | 4字节 |
p_align | 段在文件和内存中的对齐要求(必须是2的幂,如0x1000) | 确保段地址按页对齐,提升内存映射效率 | 4字节 |
可以看到,Elf header的后半部分几乎不太重要,让我们对他进行修改,只保留一些必要的信息
重叠elf header 结构与program header 结构(one)
修改后的代码
- 此时,程序头表的前 20 个字节与 ELF 头的最后 20 个字节重叠,而且还契合得很好。重叠区域内ELF头只有两个部分是重要的:第一个是 e_phnum 字段,它恰好与p_paddr 字段重合,这是程序头表中为数不多的绝对被忽略的字段之一。另 一个是 ==e_phentsize 字段(默认值应为0x20)==,它与 p_vaddr 字段的上半部分重合。这些为我们的程序选择一个非标准加载地址来匹配,上半部分等于 0x0020。
此时的elf文件大小
将program header和elf header重叠得更多
- p_memsz>= p_filesz
p_memsz 表 示要为内存段分配多少内存,即它至少需要与 p_filesz 一样大,但如果它更大, 也不会有任何坏处 —— 可以“尸位素餐”。 - p_flag
可以将 p_flags 字段中的 可执行位设置为0,因为可读位和可执行位存在着一种微妙的共生关系(任何一个 都会暗示另一个)。 - 000_
[!NOTE] 一些补充
传统系统中,可读(PF_R
)与可执行(PF_X
)权限存在隐式关联: 若段标记为PF_R
,某些旧版内核(或未启用严格内存保护的系统)会默认允许代码执行,即使未显式设置PF_X
修改后的代码(two)
- 此时e_phoff 变为00000100,这是program header的文件偏移量(0x7F,”ELF”),p_flags相应的变为4(可读和可执行相互暗示)
- 加载地址更改成一个低得多的数字,这样e_entry的值就会小,p_memsz就会相应的变小(实际上, 对于虚拟内存,这几乎无关紧要,为什么?)
- 对 p_filesz 为什么要如此更改?因为我们没有在 p_flags 字段中设置写入位,所以 Linux 不允许定义大于 p_filesz 的 p_memsz 值,因为如果这些额外的字节不可写, 它就无法对它们进行零初始化。将 p_filesz 增加到等于 p_memsz。但这会使得其比实际文件大。
[!NOTE] 为什么虚拟内存机制中,
p_memsz
(程序头表中定义的段在内存中的大小)对虚拟内存无关紧要?
我的理解:p_memsz仅描述段在虚拟空间中的逻辑范围,并不强制要求物理内存立即分配,物理内存的实际分配由操作系统按需完成
elf文件大小
移除文件末尾的0(three)
修改后的代码
elf文件大小
正常执行~完结撒花❀
二. 心得体会
实验过程中出现的问题
- elf文件对每个部分的规定都非常严格,有自己的字节数,缩小文件的时候我少打了一个0,就导致后面的部分被覆盖了,没办法正常执行
- 修改elf文件其实是一个偏离Elf文件编写规范的过程(因为我们只追求极致的小),所以要把它强行使用-f bin 生成原始二进制文件,不管其是否符合elf格式
- 还有一些小问题我写在实验文档穿插的Q&A里啦,往上翻翻
体会
实验三还是相对好做的,主要考察我们对编译链接过程的理解,对elf文件架构的理解以及缩小elf文件时的思考,我们需要思考链接过程能否进行优化,哪些内容是执行文件是必要的,哪些是不必要的,通过精巧的设计将elf文件不同部分重叠,抠字眼的去缩小。
这个实验可以说是对link这一章节的拓展延伸,让我们理论课的理解发生质变,受益匪浅,而且确实好玩~~ 之前做小班课病毒那一次时,我就有尝试对elf文件进行修改,又熟悉又陌生的感觉^-^
- tq ↩