从0学习pwn【第五章】剖析ret2text64位,从函数调用到gdb调试(解决玄学栈对齐)

📅 2026/7/12 16:26:47 👁️ 阅读次数 📝 编程学习
从0学习pwn【第五章】剖析ret2text64位,从函数调用到gdb调试(解决玄学栈对齐)

文章目录

  • **前言**
    • 1.从汇编角度理解
    • 2.什么是栈对齐
    • 3. 为什么需要栈对齐
      • 问题:为什么call指令执行后是8呢?
    • 4.为什么正常call不会错,ROP就会出错
    • 5.例题测试以及动态调试
      • 问题一:那么进入 backdoor 函数入口时,理想状态是什么呢?
      • 问题二:他并不是我们的理想状态,那么说明什么问题了?
      • 问题三:为什么没有栈对齐呢?
      • 问题四:怎么找到ret的地址呢?

前言

今日的知识更偏向于理论部分,在我刚开始学习64位的时候,我也是蒙的,很经典的问题就是payload是否加了ret的返回地址,有的时候不加ret地址就可以成功,而有的时候加了ret的地址才会成功,这就是传说中的玄学栈对齐,今天主要是理解栈对齐问题

1.从汇编角度理解

这几个寄存器已经是我们都熟悉的了,再重新复习一遍

rip = CPU 当前要执行的指令地址 rsp = 栈顶的地址 [rsp] = rsp 这个地址里面存的 8 字节内容

复习一下栈是如何移动的

64 位程序里,一个地址通常是 8 字节。栈是向低地址增长的。

push rax 等价于:

rsp = rsp - 8 [rsp] = rax

pop rax 等价于:

rax = [rsp] rsp = rsp + 8

所以所谓“消耗 8 字节”,不是内存被删除,而是:

rsp 移动了 8 字节

分析完了栈的移动,我们再重新分析一下 call和ret的底层

call backdoor 等价于:

rsp = rsp - 8 [rsp] = call 后面的返回地址 rip = backdoor

所以 call 会把返回地址压到栈上

ret 等价于:

rip = [rsp] rsp = rsp + 8

所以 ret会从栈顶取出一个地址放进 rip,然后 rsp 往后挪 8 字节。

这就是ROP的本质:你控制栈上的内容,让每个 ret 都从你的栈里拿下一个地址。

2.什么是栈对齐

栈对齐说的是:rsp 这个地址是不是满足某种倍数要求

在64位Linux系统常见要求是 16 字节对齐

也就是说一个地址如果能被 16 整除,就是 16 字节对齐

那么如何在pwndbg里面看呢?

指令:

p/x ((unsigned long)$rsp) & 0xf

如果结果是0x8说明 rsp 比 16 字节对齐差 8,如果结果是0x0说明 rsp 是 16 字节对齐

需要注意的是,函数入口处看到 rsp & 0xf = 0x8 并不矛盾,因为 call 指令会压入 8 字节返回地址。ABI 要求的是 call 前 rsp 按 16 字节对齐,因此函数入口通常表现为 rsp & 0xf = 0x8。

3. 为什么需要栈对齐

因为 64 位 Linux 的调用约定,也就是 System V AMD64 ABI,规定了函数调用时的栈状态:

call 指令执行前:rsp % 16 == 0 call 指令执行后:rsp % 16 == 8

问题:为什么call指令执行后是8呢?

答案:因为在我们刚刚复习ret和call的时候有说过,call的动作中包含了rsp = rsp - 8,因此执行后会变成8

如果 call 前:

rsp % 16 == 0

那么 call压入返回地址后:

rsp % 16 == 8

所以正常函数入口处,通常应该看到:

rsp % 16 == 8

编译器和 libc 默认相信这个规则。某些 libc 函数内部会用 SSE 指令,比如:

movaps xmmword ptr [rsp+0x10], xmm0

movaps要求地址 16 字节对齐。栈状态错了,就可能在 libc 里面崩。

4.为什么正常call不会错,ROP就会出错

这个问题是我之前的疑惑,我们还是从会汇编的角度分析

正常进入backdoor:

call backdoor

假设call前:

rsp % 16 == 0

执行call后进入 backdoor:

rsp % 16 == 8

backdoor开头通常是:

push rbp mov rbp, rsp

push rbp 会:

rsp = rsp - 8

于是:

backdoor 入口:rsp % 16 == 8 push rbp 后: rsp % 16 == 0

然后 backdoor里面如果要:

call system

此时 call system 前正好是:

rsp % 16 == 0

所以进入 system后:

rsp % 16 == 8

完全符合 ABI。

如果溢出不是通过call backdoor进函数,而是通过漏洞函数最后的ret进函数。

注意:

call 是 rsp -= 8 ret 是 rsp += 8

它俩对rsp的影响正好相反。

执行 ret:

rip = 0x400657 rsp = rsp + 8

于是进入backdoor时,rsp状态可能变成:

rsp % 16 == 0

但正常函数入口应该是:

rsp % 16 == 8

所以错了。

5.例题测试以及动态调试

我才用的题目是ctfshow中pwn38题目,因为只是作为一种验证方式,结合我之前的文章就先把我们需要的东西找到,根据之前的文章,我们需要找到backdoor地址、偏移就可以了,大家可以到时候自己我这边就不写详细的步骤了,相信各位师傅都能找到


这是我目前写的代码当然肯定是错误的,接着我们通过pwndbg调试看看会有什么

首先毋庸置疑肯定是打不通,来看看哪里出了问题

可以看到这里,目前rip里是0x400656 说明下一步会执行ret指令,

问题一:那么进入 backdoor 函数入口时,理想状态是什么呢?

答案: 是希望rsp%16=0x8,如果进入 backdoor 时 rsp & 0xf == 0x8,那么执行下一条 push rbp 后,rsp 会减 8,此时 rsp & 0xf 会变成 0x0。那么我们可以来测试一下看看对不对

目前可以看到左边最下边值并不是0x8而是0x0,并且在下一步其实是跟我们想要的结果恰恰是相反的

问题二:他并不是我们的理想状态,那么说明什么问题了?

答案:说明我们写的payload并没有栈对齐

问题三:为什么没有栈对齐呢?

答案:ret 会让 rsp += 8,进入 backdoor 时 rsp & 0xf 会变成 0x0,这不符合正常函数入口 rsp & 0xf == 0x8 的状态

我们既然已经找到了问题所在,那么就可以想解决办法了解决办法的思路是什么呢,有什么指令可以再往后挪8字节接着还能跳转的呢?

此时此刻灵光乍现,那就是再来一个ret指令就可以了。

所以我们这道题目一切都了然了,只需要再加上一个栈溢出后面加上一个ret地址,让他第一次跳转到我们自己加的ret地址,接着在ret执行我们的后门函数就即可

这样既可以栈对齐又可以返回到后门函数

问题四:怎么找到ret的地址呢?

答案:使用ROPgadget 工具,ROPgadget 是一个用于在二进制文件中自动搜索 ROP 片段的工具。

例如我这个题目使用的命令:

ROPgadget --binary ./pwn38 | grep 'ret'

搜索所有包含 ret 的 gadget

当然会搜索出来很多,只需要选择只有ret的地址就可以了,如图

我选择的地址是0x0000000000400287

下面重新写我们的payload:

frompwnimport*context(log_level="debug",arch="amd64",os="linux")io=process("./pwn38")elf=ELF("./pwn38")gdb.attach(io)padding=18backdoor=elf.symbols["backdoor"]ret_arr=0x0000000000400287payload=cyclic(padding)payload+=p64(ret_arr)payload+=p64(backdoor)io.sendline(payload)pause()io.interactive()

下面我们才调试一下看看和我们之前的理想状态一样不一样

直接进入到backdoor函数,接着看一下rsp%16的值

可以看到左下角屏幕那里显示了0x8,说明了我们的栈对齐成功了符合了正常进入函数的要求

接着一直ni就可以输入命令了

所以 我们所谓的玄学栈对齐这样被解决了

本篇文章写的内容目的就是让各位新手师傅们了解栈对齐、为什么要栈对齐、如何栈对齐,免得各位新手师傅遇到问题解决不掉或者知其然而不知其所以然

感谢各位师傅的观看,并且欢迎各位师傅批评指正