HowMuch_YouWant2Pwn-2
HowMuch_YouWant2Pwn-2
cvestone- 描述
- 0x00 提升pwn体验
- 0x01 ida静态调试基本使用
- 0x02 pwntools+python打pwn基本用法
- 0x03 程序的内存分布与函数调用
- 0x04 ret2libc
- 0x05 ret2csu
描述
b站pwn启蒙元老级师傅国资社畜 《你想有多pwn》学习记录与补充
0x00 提升pwn体验
pwndbg插件
(待续,pwndbg调试时如何看,举例子?)
终端分屏tmux
有时候动态调试过程中,当信息量大时,比对信息要滚动鼠标好大一个来回,打pwn的体验不是很友好,所以可以在linux上直接载一个tmux,一般直接用系统自带的包管理器如apt就能载 基本用法:首先在每次使用前都先键入tmux,会在原终端tty基础上跳出一个新的终端tty (1)分上下屏:ctrl + B,然后shift + ’ (2)分左右屏:ctrl + B,然后shift + % (3)切换分屏:ctrl + B,然后放开,按方向键切换 (4)在当前屏状态下调整当前屏大小:ctrl + B不放开,同时按方向键调,同时也会影响到其他屏的大小分布 (5)滚屏:默认在某个分屏下是无法滚屏的,先要用ctrl + B ,点 [ 启动复制模式,若要结束则按esc,再Enter就恢复正常了 配合pwndbg等插件:
1 | vim ~/.gdbinit |
在对应的插件那部分配置中添加: set context-output /dev/pts/数字 这的数字主要代表终端序号,可以在终端下输入tty命令查看,看需求,要把输出显示到哪个终端就填哪个对应序号,比如刚刚跳出的tmux终端是2,分左右屏,左屏还是tty2,而右屏就是tty3,在tty2输入gdb后再start,显示的信息显然就原超过该分屏范围默认就会分页,也就是用鼠标滚动看上下文,但此时若想把超出部分输出到右分屏tty3,上面配置的数字就可以填3,效果如下:
这样就不需要频繁地滚屏了
vim+插件=瑞士军刀
对于写代码用的编辑器,作为一名用惯了linux的合格大黑阔,能多在命令行窗口游走完成各种骚操作就绝不选其他planB,虽然默认状态下的vim和idea等常用的编译器软件相比体验不好,但vim装上一些必要的插件后就可以改善很多问题,虽小巧但功能强大,主要占用内存小。不过用vim其实更主要的原因是有时候打pwn写python脚本的同时,是需要附加gdb同时调试的,如果用软件写就会很不方便,软件的好处就是在写脚本的时候会纠语法等编译器能看出来的错误。 常用的插件: (1)插件管理器Vundle: https://github.com/VundleVim/Vundle.Vim https://vim80.readthedocs.io/zh/latest/plugin/vundle.html 一般很多插件现在都支持Vundle了,插件对应的库也有描述怎么在用户目录下的.vimrc配置,一般就是添加一行,然后保存后重新启动vim,按esc后再输入:PluginInstall就会自动安装了 (2)括号识别vim-rainbow: https://github.com/frazrepo/vim-rainbow 括号、标签删除与补全auto-pairs: https://github.com/jiangmiao/auto-pairs 在.vimrc可以加上:
1 | au Filetype FILETYPE let b:AutoPairs = {"(": ")"} |
(3)vim中操作文件资源管理器NERDTree: https://github.com/preservim/nerdtree 这个最好添加一个快捷键配置,用于每次打开vim时对文件资源管理器窗口的隐藏与开启,在.vimrc添加:
1 | map :NERDTreeToggle |
(4)提高注释体验NERD Commenter: https://github.com/preservim/nerdcommenter 配置时可以在.vimrc中添加上:
1 | let mapleader="," " 修改默认的leader快捷键 |
基本使用: 除了注释还有复制粘贴的强大功能 按esc后,按V块选,选择需要注释的行,然后按leader+c+i注释,按leader+c+u取消注释;也可以不块选,直接按数字,再按注释或取消注释对应的键,就会在该行基础上往下注释或取消;leader+c+c是只注释当前行 (5)代码引擎YCM: 比如写代码时要用到某些函数等忘记名字、忘记该传什么参数等等,都会提示我们,非常实用,这样就很接近idea等软件了 https://github.com/ycm-core/YouCompleteMe https://github.com/wsdjeg/plugins-tutorial/blob/master/YouCompleteMe.md 虽然是vim中插件最强大的补全代码类插件,但缺点就是安装有一点点复杂,并且安装过程需要魔法上网,然后root用户不允许安装 代码引擎coc.nvim: root用户安装可以用这个 https://github.com/neoclide/coc.nvim 注意如果通过Vundle的:PluginInstall完毕后,进入vim提示:
1 | [coc.nvim] build/index.js not found, please install dependencies and compile coc.nvim by: npm ci |
执行以下命令,等待npm编译完该插件即可
1 | cd ~/.vim/bundle/coc.nvim && npm ci |
然后可以进入vim,esc模式下输入:CocInfo检查是否安装成功,随便输入个字母,输出如下:
其余安装语法拓展等配置就直接参考文档了 (6)各种模糊搜索leaderF: https://github.com/Yggdroot/LeaderF 用来查找函数、变量、字符、文件、等等都非常方便 下面是安装了Vundle与以上插件后在.vimrc的配置参考:
1 | filetype off " required |
其他实用技巧补充: (1)vim复制粘贴: 【vim复制粘贴的秘密】 (2)撤销与恢复: esc模式下,u是撤销,ctrl+r是恢复 (3)显示行号: esc模式下,:set num,永久则直接加到.vimrc
0x01 ida静态调试基本使用
(1)关于ida,一般打pwn题最常做的就是从流程图看走向,F5反编译仅做个参考,静态调试一般看的就是程序结构和一些其他基本信息,大多数时候还是主要看动态调试。
(2)在反编译代码中,可以右键点击Copy to assembly,拷贝到反汇编中,这样查看反汇编代码时,会自动标注其对应的反编译代码,效果如下:
(3)设置显示对应机器码:Options-General:
最长是16,但改成8够大部分情况用了 (4)
高版本ida优化问题
比如对于第一章实验的其中一个程序, 高版本ida对其反编译后:
低版本ida对其反编译后:
发现只有低版本的保留了getshell有关的函数,实际上就和之前gdb的-O选项同理,因为这部分被认为是永远不可能执行的,所以高版本优化了
0x02 pwntools+python打pwn基本用法
1 | # -*- coding: utf-8 -*- |
将程序
1 | /pwn_b_lesson/chapter_1/test_7/question_3_x64_ubt20 |
用socat远程部署到云服务器,开打
注意到多了很多调试的输出,这可以检查我们输入的payload是否是对的,看到这里也确实按照小端的规则把0x40121f这个地址写入进去了,从而getshell
函数p32与p64处理大小端序问题
注意到上面的py脚本中用到了p64函数, 在pwntools模块中,p64()函数用于将一个64位整数(int类型)转换为对应的字节序列(bytes类型),以便在二进制数据中使用。它将64位整数的字节表示按照特定的字节序(默认为小端序)转换为字节序列;p32函数同理。 但是要注意并不是32位程序必须用p32,64位程序必须用p64!!!
比如对于地址0x40120b
p32后–》0x0b 0x12 0x40 0x00
p64后–》0x0b 0x12 0x40 0x00 0x00 0x00 0x00 0x00
其实还有p16–》0x0b 0x12
以及p8–》0x0b
哈哈哈哈哈搁这套娃呢qaq
总之,它们都是pwntools中用于自动处理小端序的,只不过填充的位数不同,实际使用时看需求,没有固定的标准
附加pwndbg调试
也就是在执行py脚本打pwn的同时可以配合使用pwndbg来调试,不过仅限在本地打pwn的时候,也就是取消注释py脚本中的
1 | gdb.attach(io) |
同样以第一章的test7程序为例,假如此时我们的payload没考虑大小端序问题,如下:
第二步是因为第一步ni完再等待该分屏的输入,而输入已经在py脚本定义好了,因此回车就行。 !!!注意:在这之前要把原来脚本里的sendafter改成sendlineafter,否则这里调试过程中会卡住。
然后就来到main函数体继续调试:
很明显,这里就可以看出我们写payload时大小端序没判断好 因此,打pwn时调试非常重要!
sendafter与sendlineafter函数:
sendafter(delimiter, data): 该函数在发送数据之前等待接收到特定的分隔符(delimiter)。一旦接收到分隔符,它将发送提供的数据给远程主机。这个函数通常用于发送需要在特定响应之后发送的数据。
sendlineafter(delimiter, data): 类似于sendafter()函数,但在发送数据之前会自动附加一个换行符(\n)。这个函数通常用于发送以换行符为结束符的数据,例如命令行输入。
padding的计算问题
当输入的缓冲区和可利用的缓冲区都比较大时,比如a[23] b[47] ,此时在gdb可用指令
1 | distance 输入的第一个字符所在地址 溢出位所在地址 |
来计算出padding: 将/pwn_b_lesson/chapter_1/test_6/question_1_plus.c中的缓冲区定义部分改成
1 | char a[23] = {}; |
然后重新编译,注意别忘了-no-pie参数,再用pwndbg调试:
这里显示的是十六进制0x20,换算成十进制后刚好就是上面内存中看到的共32个字节,因此padding就是32,此时就可以在py脚本中改,并加上溢出位要拼接的payload:
0x03 程序的内存分布与函数调用
引例
1 | /pwn_b_lesson/chapter_2/test_6/question_4_1_x86 |
1 |
|
实际上就是程序/pwn_b_lesson/chapter_1/test_1/question_1的变种,func函数有定义,但是去掉主函数中调用func函数的部分,然后其他部分稍微做了些改变,这时又该如何打?
read()函数
这个函数很重要,是get、puts、scanf等同类型函数使用时调用的底层。 在C++中,read()函数不是标准库函数,而是Unix/Linux系统中的一个系统调用函数,用于从文件描述符读取数据。该函数的原型如下:
1 |
|
其中,fd是文件描述符,buf是指向数据缓冲区的指针,count是要读取的字节数。函数返回实际读取的字节数,如果出现错误,则返回-1
程序的内存分布
查看程序运行时真正的内存分布情况,除了用vmmap,还可以在shell中用命令
1 | cat /proc/程序pid/maps |
以现在32位cpu的操作系统为例,对于每个程序一般分配的总虚拟内存都是4G(不过实际上一般不会用到这么多),为什么呢?
且虚拟内存地址范围一般是从0x00000000到0xffffffff
那么在程序运行时,是如何装载到虚拟内存空间的?
(上面是低地址,下面是高地址) 但这个分布并不是绝对的,实际情况可能会因为操作系统的不同或者编译器的设置而有所差异。
偏移
偏移是什么?有什么用?: 用上面的test6程序,找到ida静态反汇编后main的起始位置A,然后找到其运行后被分配到的虚拟内存空间起始位置B,B+A等于C,刚好此时C就是程序运行后main被分配到的地址,此时这个A就是偏移。同理还可以验证下main函数的变量和函数。
函数调用过程
实现函数调用的一套汇编指令,例:
1 | call 某函数 |
其中,这些指令实际上是有对应关系的, push ebp表示先当前esp-4,然后把ebp地址放入esp指的地方 leave实际上和代码3、4对应,相当于:
1 | mov esp,ebp ;均指现在的 |
可以发现是其反过程,其中pop ebp表示先把esp指的地方的地址给ebp,然后再把当前esp+4, 最后,retn和call对应,即返回到原来之前call时push的eip地址 其中注意call不是直接进入函数了,要先push当前的ip类寄存器指向的地址,否则容易导致进入子函数执行完准备返回了找不到父函数的地址 即: call:
1 | push eip ;特别注意这里的eip实际被压栈的是call下面一条指令的地址,并且实际上不能直接这样写汇编指令,因为ip类寄存器非常重要,它的用法是和其他寄存器不同的,一般的操作方法不适用于它! |
ret:
1 | pop eip |
我们可以进程序里用pwndbg调试一下看看是不是这样的:
进入dofunc函数前:
进入dofunc函数后:
esp现在确实指向call dofunc的下一条指令的地址,call指令验证成功
push+mov验证成功,发现实际上就是把原来的ebp存起来,然后更新ebp
leave前:
(这里leave最容易混乱!!!等效拆分来看,首先mov esp,ebp后,esp->0xffffd398,ebp->0xffffd398;然后pop ebp后,原ebp的值给现在的ebp相当于还原回去了,即ebp->0xffffd3a8,然后esp别忘了+4,即esp->0xffffd39c,因此结果就如下面的图了)
leave后:
ret后:
即pop eip,也就是把上一步的esp->0xffffd39c存储的地址0x8049218给现在的eip了,ret后自然就到eip指向的地址了
指令均验证成功,最终ret的地址正好就是最初call指令压入栈中的地址,通过比对也能发现leave后与和ret后的结果分别就和原来进入dofunc函数前与进入dofunc函数后的结果一模一样!
那么了解了函数调用过程和内存分布,就可以打上面的变种题了: 虽然main函数中对getshell所在函数的调用删除了,但是我们可以通过溢出,让retn返回的地址变成外部getshell所在函数的地址,同样能利用成功
ret2text
这是其中一种打缓冲区溢出题的技巧名字,因为返回地址是在程序的.text节里的。开打这道pwn题: 从上面的分析可以知道最终retn返回的地址是 0x8049218
修改后,发现此时也确实进入了该子函数外部的func函数,同时eip也指向了func函数入口地址!
(修正一下,这里的p64要改成p32,同时别忘了把前面arch里的值改成i386) getshell成功
但是我们只有在实际输入时能够实现上面的结果才算利用成功,这里只是调试过程中强行修改了值而已,那么自然现在就会想到要去观察我们的输入在运行过程中被丢到了哪里,和retn的返回又有什么关系呢? 现在重新调试,到read()时随便输入aaaaaaaaaaaaaa,直到来到leave:
发现我们的a跑到ecx里了,那此时可以用x指令看看ecx及其附近的虚拟内存空间数据:
暂时判断不出什么,没关系先放着,继续ni:
这时就会发现一件很有意思的事情,这里esp指向的地址0x8049218就是刚刚虚拟内存空间0xffffd398那行的,并且由retn可知这个地址即将会变成eip指向的,也就是溢出位对应的,那么此时溢出位置和输入的第一位都能同时确定了,就可以把padding计算出来了: 换成bx来看更容易看些:
即padding = 20 则修改py脚本的payload:
getshell成功!
打pwn题基本流程
梳理一下打pwn的基本流程,还是以上面的question_4_1_x86程序为例
(1)先运行程序,看做了什么
(2)checksec检查程序的配置情况
可以看出该程序的架构是i386,默认以小端序方式来存储数据,无pie等。
(3)丢到ida里静态分析,初步猜测
初步看看反编译结果,进行一些静态分析,通过分析出程序的代码结构等信息或许还能猜测出程序的关键执行逻辑,从而给出一个方向,然后用动态调试来验证我们的猜测,所以静态分析也很关键!比如:
F5反编译main函数:
进入dofunc()函数:
没有什么有用的信息。再shift+F12看看字符串表,这里会列出程序用到的字符串:
很明显,这里的“/bin/sh“和“system“是我们最感兴趣的,那猜测就是存在系统调用,并且这两者可能有关联,可以分别进入看看都有啥内容:
可以选中sh,键入x,来查看谁调用了它:
双击进入:
再反编译看看:
到这里几乎就能猜测个大概了,程序中存在系统调用/bin/sh的函数,但是该函数在前面main函数与dofunc均没有调用它,那它很可能只是被定义了而已,存在被利用的可能性。当然这些只是静态分析中其中一个方法,经验越丰富,猜测的准确性就越高。但是也要注意,由于各种原因,有时候ida不一定准确,所以还是要以最终动态调试为准。
(4)gdb动态调试与利用
引例延伸实验
(1)控制函数传参
1 | /pwn_b_lesson/chapter_2/test_6/question_4_2_x86 |
源码就变了一个地方:
1 | //用-m32 -fno-stack-protector -no-pie编译 |
原来system的参数是sh,因此能够直接getshell,而这里的cmd参数不能,此时又该如何解?
函数传参过程
函数调用约定cdecl(GCC的)
cdecl是C/C++默认调用方式,参数从右向左入栈,主调函数负责栈平衡,x86程序用栈来传参,用eax存返回值;而x64的前6个参数依次存放于rdi、rsi、rdx、rcx、r8、r9寄存器中,第7个以后的则继续用栈来传参。
1 | /pwn_b_lesson/chapter_2/test_6/cdecl_test.c |
我们可以将该程序分别编译成x86和x64的进行研究传递参数过程。
1 |
|
x86:用gcc -m32编译
可以发现先是去全局偏移量表把argc1定位到,因为这是一个全局变量,然后需要传递的参数的值先存到栈的某几个位置,在调用add_test函数之前,先分别按照30、20、10的顺序压栈,刚好就是从右往左。 在即将调用add_test时,pwndbg也智能地识别出来了参数:
也能发现当函数有参数在传递时,是先将参数压栈,再调用函数
x64:用gcc默认编译即可!
可以发现此时就并没有push了,参数也都是通过寄存器来传递,并且调用add_test函数之前,寄存器传参顺序为rdi、rsi、rdx、rcx,同样也是先传递参数,再调用函数
那么这道题question_4_2_x86的思路也很明确了,就是控制ret返回地址为func函数后,跟上参数sh所在的地址(可以用search指令找该参数的字符串值,就会回显其地址),但注意在这之前必须先构造一个执行完func函数后的返回地址,可以用0xdeadbeef,为什么不能遗漏这一步呢?因为我们知道当我们call进入一个函数后,不管里面做了什么,函数即将结束后,如果不ret,那么我们还是在函数里头,函数还没有成功执行,也就没法getshell。 开干:首先控制返回地址到func函数,然后pwndbg调试到call system@plt,因为我们现在主要目的就是寻找出哪个位置的值被作为system函数的参数传递,这样我们才能尝试修改参数,如下:
可以发现被push的[ebp+8]里的值被作为参数传递了,可以再具体看看这附近的内存空间:
然后看看“/bin/sh“这个字符串在哪里(注意:如果是实际黑盒环境中,我们并不知道这个字符串是否存在,一般是比如丢到ida在静态分析的时候得到的,然后动态调试时尝试利用它):
这里出现了两个存在该字符串的地方,实际上之后的题目会有用到libc中的
那就用set把当前esp指向的值改成该参数地址即可:
然后就成功getshell了
编写成exp:
这里的payload顺序一定要注意,我们的payload先是从栈的最低位到最高位开始填充,传递的sh参数是最先入栈的,所以被放在最下面,然后就是构造的函数返回地址,最后才是函数地址。另外exp中应该把return_addr改成func_addr更贴切些。
注意这里的中间一定要加上一个我们构造的ret返回地址,否则无法执行成功
然后再编译成64位的打,因为两者调用方式不同,利用时也会有点差别,本来控制传参是通过重新部署栈中的地址的,现在变成修改相应寄存器中存放的地址
1 | /pwn_b_lesson/chapter_2/test_6/question_4_2_x64 |
用-fno-stack-protector -no-pie编译。先粗略调试一下:
尝试输入很多个a:
从这里可以发现我们当输入16个a后,就出现了溢出位,即这里的0x7fffffffe228,这里的8个a被当作了ret后的返回地址,因此padding可以确定为16,即0x10。
我们同样地和前面做x86时一样,控制ret返回到func函数,然后ni到system函数:
会发现此时并没有push了,因为x64的程序在函数传参时前6个参数是由寄存器传参,我们目前只有一个rsi存储着我们的8个a,但如果要让system函数执行成功,必须得有“/bin/sh“传给它,此时就陷入很尴尬的情况,除非我们现在有可控的寄存器,使其传参“/bin/sh“,一个很奇妙的想法就是构造一个,但是我们能够通过什么方式呢?这个时候就有大黑阔想到了一种叫ROP的技术。
ROP初探
(ROP的出现让栈溢出的危害变得更严重,本来防止栈溢出做的一些保护,可以在一定程度上为shellcode等利用方式带来很大阻碍,但是ROP的攻击payload是来自于程序中原本就有的代码,给防御带来了更大的挑战。ROP又叫做返回导向编程,从原来的反汇编代码中筛选进行重新编程,以达到我们的目的。在前面提到的栈溢出技术都是线性的,不能够像shellcode注入一样可以任意执行我们想要的,但是NX保护的出现让数据所在的内存页不可执行,shellcode也就失效了,而ROP的出现也能够实现任意执行,执行的是汇编代码,此时栈就不是ROP的主战场了,主要是通过汇编指令的灵活应用和寄存器的配合)。 ROP通俗理解,就相当于用原来能够组装成派蒙的一堆积木,重新进行筛选组合,组装成了可莉。
其中,ROP Gadgets大部分一般是许多以“ret“或“jmp“结尾的汇编代码片段,这样才能实现许多次任意跳转,形成一个ROP链,程序的控制流就完全掌握在我们手里,从而一步一步地达到我们的最终目的。
pwndbg中,附带了查看程序中可能可以利用的ROP Gadgets(相当于能够拼成可莉的积木)的指令:
1 | ROPgadget --binary 程序路径 --gadgets |
会在当前目录生成一个gadgets文件,或者:
1 | ropper -f 程序路径 |
这个会直接输出,并且是多线程的
现在,我们从生成的gadgets文件中去筛选能够利用的:
由于rsi已经用过了,继续传参根据调用规则需要交给rdi,我们就可以看看与rdi有关的指令,哪个附加到我们的反汇编代码中,能够实现目的:
我们需要利用rop做到的是既要控制ret返回到func函数,又要保证有个寄存器能够存“/bin/sh“从而传给system函数,那么很可能就可用这里的pop rdi;ret,经过思考,可以将这段ROP拼接到dofunc函数最后ret后面,也就是控制ret跳转后地址为该gadget所在地址,同时要保证该地址在栈的下一个就是参数,因为ret相当于pop一次,然后在rsp的“/bin/sh“刚好会被存入rdi,再pop后下一个刚好就是跳转到func函数,所以对汇编指令的理解非常关键,即如下的效果:
当然,这只是最简单的ROP链的构造,这次也主要是运气好构造一个就足够。
则exp:
不过最终不知道什么原因执行shell代码没反应,整个攻击思路也没问题,课上也是这样做的,估计和环境有点关系
(2)有限缓冲区与sh软链接问题
1 | /pwn_b_lesson/chapter_2/test_6/question_4_3_x86 |
1 |
|
即在question_4_3的基础上,把sh改了,显然此时如果传该参数给system函数,不可能执行,并且read()留给我们的缓冲区空间也变小了很多,很有限,意味着我们必须得重新考虑payload的构造
首先第一个sh[]很好绕过,因为我们知道,在linux中,sh是/bin/sh的软链接,两者执行的效果是一样的,在python脚本中同样如此:
我们可以丢到ida静态分析中尝试找到该字符串的位置:
双击进去,发现默认是字符串格式显示,可以选中sh,按d,让其以数据的byte格式显示:
这样就可以看到字符串中的每个字节都在什么位置,显然此时我们只需要sh所在地址即可
接下来就是在pwndbg调试中确认padding与ret返回的函数位置:
确认padding有很多种方法,这里的思路就是先随便输入几个a,看我们的输入被放到了栈中什么位置,同时寻找原ret返回的函数地址,由ret指令的原理可知运行到某步时该地址在栈顶,然后看附近的内存空间,从而判断溢出位,最后计算偏移,如下:
即0x14
和前面引例实验question_4_2_x86情况差不多,exp中部分修改如下:
1 | elf = ELF(pwnfile) |
然后附加调试看看:
发现payload中的sh_addr并没有写入,0xdeadbeef后面跟的是传给system的参数。因为前面说了,该程序给的缓冲区很有限,那么此时又该怎么办呢?
(埋坑!先弄明白为什么question_4_2_x86要加0xdeadbeef且返回到func,而这里不需要且直接返回到system;以及为什么question_4_2_x64又不需要加0xdeadbeef)
因此最终的exp:
1 | from pwn import * |
0x04 ret2libc
引例再延伸实验
1 |
|
1 | /pwn_b_lesson/chapter_2/test_7/question_5_x64 |
可以把libc.so.6丢到ida看看system函数、write函数,由vmmap可以知道加载入内存的libc基地址,基地址还可以通过libc其中的函数载入内存地址-在静态编译时(丢到ida)的函数地址 来求出。题目question5_x64中有两个write函数,从程序的角度,第一次碰到call write@plt,会先到其对应的plt表,然后跳到其对应的got[plt]表,通过dl_runtime_resolve函数(动态链接器用于给函数做地址绑定的自带函数)和write@got[plt ]来找write真正在libc中的地址,第二次碰到call write@plt,就没有这个寻找的过程,而是直接jmp到这个真正的write地址了。
(待续,重读《程序员自我修养》P200-P202 重新整理一下语言表述)
调试时,先si到第一个call write@plt,一开始jmp到某个地址,实际上就是到got[plt]表去找真正的write,这里用rip(即jmp的下一条指令地址)+0x2fe2 = 0x404018 就是write对应的got[plt]表项,会发现read函数也是同理
内存断点
还可以用内存断点来观察上述这一变化,内存断点即下在虚拟内存空间中的断点,便于观察虚拟内存空间中的某个值发生的变化。
可以用watch指令在0x404018下内存断点,即watch *0x404018。重新调试,当ni完第一个call write@plt后,就会提示0x404018这一位置的值发生了更改,因为这时write函数在libc中的真正地址已经被定位到了并重新写入到0x404018中,这样下次就可以直接jmp到该真正地址,可以用a指令再核实比对一下:
这里断点值变更提示中显示的是机器码的补码格式,分别就对应着这两个地址,第一个地址是还没找到write前的plt表地址,第二个是找到后write在libc中的真正地址
以上就是动态链接下程序首次调用某函数时,是如何去寻找该函数真正地址的过程。其中GOT表叫做全局偏移量表,PLT表叫做程序链接表。关于plt和got表的详细解释,其实已经在《程序员的自我修养》里描述的很清楚了
RELRO保护
当checksec程序后,如果RELRO那栏显示的是full,那么情况就会发生些变化,对于上面的程序,在程序刚载入还没进入第一个call write@plt时,就已经写入了真实的write函数地址,则不给攻击者篡改GOT表的机会。该RELRO保护是设定对GOT表是否有写权限,有三种模式,它们分别对应的编译参数如下:
1 | -z norelro #可写,即No RELRO |
在pwndbg调试过程中,也可以用got和vmmap指令看出got入口所在位置权限的设置:
很明显,只读权限,并且注意到这里后面的红色字体不是指向write@plt,因为一开始就写入真正地址了。可以再看看No RELRO模式下的:
发现确实就是可写权限,并且红色字体中指向的和Full RELRO模式下不一样
(待续,打question_5_x64和question_5_x86,libcsearch工具,更重要的是libc database,链接在第一章最开始的工具里)
(待整理)libcSearcher 的工作原理:
首先,通过泄露程序中的一个 libc 函数的地址或函数偏移量,例如 puts 函数的地址。 使用泄露的地址或偏移量,结合已知的 libc 符号信息,例如函数名和偏移量,从 libc 数据库中搜索匹配的 libc 版本。一旦找到匹配的 libc 版本,就可以使用该版本中的函数地址来构造漏洞利用。
泄漏函数地址的必要性: 当程序没有开pie编译选项时,我们打本地通过pwndbg调试中的p &函数,与通过pwntools写python脚本泄漏libc中某个函数的地址,结果是一样的,因为程序加载时基地址是不变的,但不代表着此时没有必要泄漏了,因为如果打远程时是不能同时附加调试的,且无法保证本地的libc和远程的libc版本就是一样的,对应加载到内存的地址也会有偏差,因此可能本地通远程不通,因此就需要利用libcSearcher来匹配远程可能性最大的libc版本。而一旦泄漏该函数地址后,由于该函数在libc加载到内存前后,在libc中的相对位置是固定的,其他函数也如此,因此即使libc加载到内存后的基地址更新,依然可以通过该函数加载到内存中泄漏出的地址来计算出libc基地址,所以只要一个泄漏了其他在libc中的函数、参数等的加载地址也能计算出来。
在libcSearcher中,libc.dump(“libc中的某函数”)结果是加载前该函数在libc的相对位置,即偏移,如果是libc中的某字符串,则
(待整理)栈对齐与堆栈平衡问题:
参考文章:https://zhuanlan.zhihu.com/p/611961995
写好payload后,预期要ret到包含有system函数调用的后门函数时,如果是ubuntu18以上的x64程序都很可能会遇到这个问题,因为在我们不利用它之前,按照程序正常执行控制流不会出问题,但是一旦我们利用时,不管是不是用到rop,当我们更改了程序的控制流,导致程序流程不按原来的轨迹走了,从而栈中情况也会发生相应的变化,此时就可能导致堆栈不平衡,不平衡的具体细节就体现在出现在system函数内部的movaps指令的执行流中断了,该指令执行的条件是必须保证操作数中的内存地址是要“对齐”的,而对齐的依据取决于另一个操作数寄存器的类型,如果是xmm类寄存器,必须保证该操作数地址是十进制数16(即0x10)的倍数,这里可以参考官方指令文档:https://www.felixcloutier.com/x86/movaps
所以根本问题就是想办法将该操作数地址变成0x10的倍数即可,又因为该操作数地址不太可能是具体的值,而是由rsp或rbp为基准的偏移量,如:
1 | movaps xmmword ptr [rsp + 0x50], xmm0 |
因此问题就转化为进入后门函数前改变rsp或rbp所指地址。所以下次遇到这种情况的经验就是,payload逻辑没问题的前提下,通过调试,先记录下即将进入后门函数的rsp1所指地址(一般父函数的ret还未执行时,这里甚至不选后门函数,离调用system最近的父函数也可以,因为本质都是差不多的)A,继续调试,直到call system,continue,看是否执行到movaps并中断,中断则说明此时栈并未对齐,记录下中断指令rsp2所指地址B,由于程序代码不变,则|A-B|是不变的,记为R,则修改进入后门函数前的栈结构,进而可影响执行出错指令前的栈结构,这里是由rsp1来代表栈结构的变化,因此可通过在进入后门函数前补充不管从哪来的ret指令,来使rsp1所指地址发生变化,间接使偏移量[rsp + 0x50]变成0x10的倍数。最终计算要补充的ret指令个数(每补充一个会占8字节),记为x,即(|(x*8+A)-R|+0x50)%0x10=0,用代数法解出x即可没必要解方程,因为在这里x不可能取特别多,然后就可以修改原payload重新利用。
而当偏移量的计算基准变为rbp或movaps的操作寄存器是其他类型时,同理用该思路解决。
(待整理)偏移padding的其他确定方式:
1 | ssize_t ctfshow() |
比如上述的反编译代码,这里千万不要被迷惑了,最终buf 的范围是从 ebp-88h 到 ebp-88h+132,即从地址 ebp-0x88 到 ebp+0x4,132仅仅只是存储大小,范围要考虑在栈中的偏移。这里 [esp+0h] 表示 buf 在栈帧中的偏移量,而 [ebp-88h] 表示 buf 相对于栈帧指针 ebp 的偏移量。所以这里的溢出偏移量padding可以确定了,再跟进到buf对应的栈结构中也能看出来,这里的0x4是表示存了4个寄存器,用于平衡栈,避免栈对不齐的问题
因此这里的padding = 0x88+0x4
(待整理)ASLR和PIE的区别:
ASLR和PIE都是用于增加程序的安全性的机制,但它们的作用层次不同。ASLR是操作系统级别的安全机制,通过随机化程序在内存中的加载地址来增加攻击难度,而PIE是编译器和链接器级别的安全机制,用于生成位置无关的可执行文件,使其可以在不同内存地址加载。