L09 Machine-Level Programming V: Advanced Topics

10次阅读
没有评论

L09 Machine-Level Programming V: Advanced Topics

L09 Machine-Level Programming V: Advanced Topics

x86-64 Linux中的内存大致可以分为以下板块:

  1. Stack 栈

    运行时的栈,8MB限制

  2. Heap 堆

    根据需求动态分配,当 调用 malloc() calloc() new() 的时候创建

  3. Data

    静态分配的数据,比如全局变量,静态常量

    • .data​:已初始化的全局/静态变量(如 int x = 5;)。
    • .bss​:未初始化或初始化为 0 的全局/静态变量(如 int x;​)。.bss 在 ELF 文件中不占实际磁盘空间,加载时才由操作系统清零分配。
  4. Text / Shared Libraries

    可执行程序(就是程序存放的地方)可读可执行。这就是你反汇编看到的 ​Text​​ 代码段,存放机器指令。

    只读。存放 ELF 文件的头部、段表等元数据。

现在64位的机器会限制使用47位的内存地址 256TB

这张图的顶部是 0x7FFFF 底部是 0x400000

  • 顶部(高地址 ​0x7FFF...​ ​ ) ​:是 ​Stack(栈) ,因为栈是向下增长的(向低地址生长)。
  • 底部(低地址 ​0x400000​​ ) ​:是 ​Text(代码段) ,因为程序代码通常从固定的低地址开始加载。
  • 中间的空洞​:Heap(堆)​ 向上增长,Shared Libraries(共享库) 向下映射,它们中间留了巨大的空洞。这留出了极大的弹性空间——如果堆和栈用不完中间的空洞,它们就不会相撞(相撞就会 Stack Overflow 或 OOM)。

‍

例如有下列程序:

L09 Machine-Level Programming V: Advanced Topics

于是他们的分布如下:

L09 Machine-Level Programming V: Advanced Topics

1. 地址与内存区域的映射(看图1右侧)

  • ​local​​ (0x00007ffe…) → Stack(栈)

    • 最高地址区域(约 0x7fff…,正好是 47 位),栈从高地址向低地址生长。local​ 是 main 函数里的局部变量,理所当然在栈上。
  • ​p1​​ (0x00007f72…) 和 ​p3​​ (0x00007f71…) → Shared Libraries / mmap 区域

    • 注意这两个指针的地址都是 0x7f...​ 开头。它们虽然是通过 malloc​ 分配的,但因为申请的空间非常大(p1 是 256MB,p3 是 4GB) ,glibc 的 malloc​ 会直接使用 ​mmap​​ 系统调用,在共享库附近分配一块独立的内存,而不是去挤那个传统的堆(Heap)。
  • ​p2​​ (0x00000008359d010) 和 ​p4​​ (0x00000008359d120) → Heap(堆)

    • 这两个指针是 0x08...​ 开头(相对较低),并且都在同一个区域。因为它们只申请了 256 字节(很小),glibc 会使用 ​brk​​ 系统调用,在传统的堆区(紧挨着 Data 段上方)分配。
    • 仔细观察 p2​ 和 p4​ 的差值:0x...d120 - 0x...d010 = 0x110 = 272 字节。这正好是 256 字节的请求 + 16 字节的堆块管理开销(元数据)。这证明了它们在连续的堆上。
  • ​big_array​​ (0x000000080601060) 和 ​huge_array​​ (0x0000000601060) → Data / BSS 段

    • 这些是全局数组(未初始化),位于低地址的静态数据区(.bss)。
    • 它们的地址(0x08...​ 和 0x06...​)看起来比堆的地址(0x00000008...​)还要低一点,这是因为全局变量在程序加载时就已经分配好了固定的虚拟地址。2GB 的 huge_array 更是直接撑大了 BSS 段。
  • ​main()​ ​ (0x00000000040060c) 和 ​useless()​ ​ (0x000000000400590) → Text(代码段)

    • 最低的地址区域(0x400...​),存放机器指令,且只读。

2. 图2代码中的隐藏知识点(如何解释这些地址)

图2的 C 代码解释了为什么会有图1的地址分布:

  • int local = 0;​ → 随 main​ 函数在栈上分配。
  • malloc(1L << 28);​ → 申请 256 MB(超过 MMAP 阈值,约 128KB)。所以走 mmap​,映射到共享库区域(得到 0x7f... 地址)。
  • malloc(1L << 8);​ → 申请 256 字节。走 brk​ 拓展堆(得到 0x08... 地址)。
  • malloc(1L << 32);​ → 申请 4 GB。毫无疑问走 mmap​,落在共享库区域(得到 0x7f... 地址)。
  • char big_array[1L<<24];​ → 全局变量,放在 ​.bss​ 段(16MB)。
  • char huge_array[1L<<31];​ → 全局变量,放在 ​.bss​ 段(2GB)。
  • useless()​ 和 main()​ → 代码,放在 ​.text​ 段(0x400000 附近)。

3. 你提到的“47位 / 128TB”验证

图1左上角的 address range ~2^47​ 和顶部栈地址 00007F...​ 遥相呼应。
​0x00007FFFFFFFFFFF 正是用户空间地址的极限(即 2^47 – 1)。

在 Linux 下,用户空间只能用到这个上限。再往上走(即 0xFFFF800000000000 以上),就是操作系统的内核空间了。你之前笔记里记的“限制使用47位内存地址 256TB”也没错(因为内核空间和用户空间各占 47 位,加在一起就是 256TB 的虚拟地址空间,但当前进程只能看到自己这 128TB)。

4. 总结你的理解

你把这四个板块:Stack、Heap、Data、Text 背下来并且明确了它们的地址特征。
现在配合这个实验,你可以把它变成条件反射:

  • 看到 0x7fff... → 栈变量
  • 看到 0x7f... → 大型 malloc(mmap)、共享库
  • 看到 0x400... → 代码段
  • 看到 0x08... 或极低地址 → 全局变量(Data/BSS)、小型 malloc(brk 堆)

‍

在共享库附近分配一块独立的内存,而不是去挤那个传统的堆(Heap) 可是图里面标注的还是heap

此外 假设我这台计算机只有32GB的ram 也是这样的内存地址吗

1. 为什么图里标注的还是“Heap”?

你的眼睛非常毒。图里箭头确实指向了标有“Heap”的绿色大块区域,但实际上:

  • CSAPP 教材的简化:在这张图里,作者把由 ​brk​​ 系统调用管理的“传统堆” 和由 ​mmap​​ 系统调用管理的“内存映射区(Memory Mapping Segment)” ,在视觉上笼统地合并成了一个大绿色的“Heap”区域。这是为了方便教学。
  • 真实的 Linux 布局:实际上在真实的进程地址空间里,0x7f...​ 开头的那片区域(p1​、p3​ 所在位置,以及共享库 Shared Libraries​ 所在位置)统称为 ​mmap​​ 区域(内存映射段) 。它从高地址向低地址生长(向下增长)。
  • malloc 的分水岭:p2​ 和 p4​(256字节)会走 brk​,放在传统堆(紧挨着 Data 段,向上生长,地址 0x08...​);而 p1​(256MB)和 p3​(4GB)太大了,直接走 mmap​,放在共享库下方(地址 0x7f...)。
  • 总结:图里那个大绿块,是指代所有动态分配的内存(包括 brk 和 mmap) 。所以箭头指向它并没有错,只是它具体分为“低地址的传统 Heap”和“高地址的 mmap 区域”。

2. 如果我的电脑只有 32GB RAM,地址还是这样吗?

答案是:是的,虚拟地址的排布完全一模一样。这是虚拟内存最伟大的魔法。

无论你的物理内存(RAM)是 32GB、16GB,甚至只有 4GB,你的程序看到的虚拟地址空间永远是一张 128TB(47位)的巨大地图。

这里你务必要分清两个概念:

  • 虚拟地址(Virtual Address) :程序里看到的地图(128TB)。不管物理内存有多少,这张地图的画法是不变的。
  • 物理内存(Physical RAM) :你实际拥有的 32GB 硬件。

为什么只有 32GB 内存,却能写出 2GB 的数组(​​huge_array​​ )和申请 4GB 的 ​malloc​​ ?

  1. 按需分页(Demand Paging) :当你声明 char huge_array[1L<<31]​(2GB)时,操作系统并不会立刻分配 2GB 的物理内存。它只是在你进程的虚拟地址地图上划了一个 2GB 的“虚拟圈”,标记为“未映射”。只有当你真的去读写这块内存的某个字节时,操作系统才会触发“缺页异常”,临时分配 4KB 的物理内存页给你。
  2. 交换空间(Swap) :如果你的物理内存真的用完了,操作系统还会把暂时不用的数据挪到硬盘上的 Swap 分区里。

那图里到底哪里变了?

  • 绝对地址变了(ASLR) :现代操作系统开启了地址空间布局随机化(ASLR) 。每次你运行这个程序,local​ 的地址可能从 0x7ffe...​ 变成 0x7ffc...​,p1​ 的地址也会在 0x7f...​ 范围内随机漂移。但相对位置(谁能用哪些前缀)是不变的。
  • 物理内存分配变了:如果你真的去遍历那个 2GB 的 huge_array 并写入数据,你的 32GB 物理内存会被迅速消耗,甚至可能触发 OOM(内存溢出)杀手。但如果你只是声明它,它根本不占你 32GB 里的一丁点空间。

缓冲区溢出:

L09 Machine-Level Programming V: Advanced Topics

这段代码的汇编是这样的:

L09 Machine-Level Programming V: Advanced Topics

这个 ​0x18​​ (24字节)是编译器为整个 ​echo​​ 函数分配的“总栈帧大小”(包含了缓冲区、填充数据、对齐空间)。

1. 为什么是 24 字节(0x18)?(栈对齐的硬性规定)

在 x86-64 Linux 下,System V ABI 有一条铁律:当执行 ​call​​ **指令时,**​ ​%rsp​​ 必须 16 字节对齐。
我们顺着调用链算一下:

  • 进入 call_echo​ 时,%rsp​ 是 8 mod 16(因为 call 指令压入了 8 字节的返回地址)。
  • 在 call_echo​ 里执行了 sub $0x8, %rsp​。此时 %rsp 是 0 mod 16。
  • 然后 call echo​,CPU 又压入 8 字节的返回地址。​进入 ​echo​​ **时,**​ ​%rsp​​ 又变成了 8 mod 16。
  • 为了在 echo​ 里调用 gets​ 和 puts​ 时恢复 16 字节对齐,编译器执行了 sub $0x18, %rsp。
  • 计算:0x18 (24) = 16 (为了对齐的调整) + 8 (弥补之前 call 压入的返回地址)​。8 - 24 = -16,正好是 16 的倍数。
  • 所以,这 24 字节里,只有 4 字节是真正给 ​buf​​ 用的,剩下的 20 字节全是“编译器为了对齐硬塞的 Padding(填充)” 。

2. 真实的栈帧布局(在 pwndbg 里能看到)

当执行到 callq <gets> 时,栈内存是这样的(从低地址到高地址):

地址偏移 (相对 rsp) 内容 大小
​rsp + 0​ ​buf[0..3]​ ​ (gets 的起始写入点) 4 字节
rsp + 4 编译器填充的 Padding(废弃空间) 4 字节
rsp + 8 编译器填充的 Padding(废弃空间) 8 字节
rsp + 16 编译器填充的 Padding(废弃空间) 8 字节
​rsp + 0x18​​ (24) ​echo​​ 的返回地址 (执行完 ret​ 后要跳回 call_echo​ 的 0x4006f6) 8 字节

看到了吗? 你的 char buf[4]​ 依然只有 4 字节。只要输入的字符超过 4 个,就会立刻踩进“填充”区;输入超过 24 个(0x18​),就会精准覆盖到​返回地址。

L09 Machine-Level Programming V: Advanced Topics

L09 Machine-Level Programming V: Advanced Topics

L09 Machine-Level Programming V: Advanced Topics

L09 Machine-Level Programming V: Advanced Topics

‍

L09 Machine-Level Programming V: Advanced Topics

  1. “Input string contains byte representation of executable code”
    你的输入字符串里,包含了可以直接让 CPU 执行的机器码(比如执行 execve("/bin/sh", 0, 0) 的汇编指令)。
  2. “Overwrite return address A with address of buffer B”
    你利用 gets​ 的溢出,精准地把栈上的返回地址 A​ 覆盖成了 ​​B​​ (缓冲区起始地址) 。
  3. “When Q() executes ret, will jump to exploit code”
    当 Q()​ 执行 ret​ 时,CPU 会把被篡改的返回地址(即 B​)弹进 %rip​。然后 CPU 跑去地址 B​ 处取指令——此时 ​%rip​​ 指向的是栈,而栈上恰好是你刚写进去的机器码。 于是 CPU 开始执行你注入的代码,攻击成功!

‍

这涉及计算机体系结构里一个非常关键的保护机制:NX(No-eXecute)位。

你刚才在 checksec​ 里看到的 NX enabled,就是用来防止这件事的。我帮你把这个历史演变和底层原理彻底讲透:

1. 为什么“理论上”可以?(CPU 的“盲人”视角)

在 CPU 硬件眼里,内存里的数据只是一堆 0 和 1。CPU 的“取指-译码-执行”周期非常机械:

  1. 去 %rip 指向的地址取 1 个字节。
  2. 译码,看是不是合法的机器码。
  3. 执行。

CPU 根本不在乎这块内存是叫“代码段(Text)”还是叫“栈(Stack)”。只要 ​%rip​​ 指向了栈上的地址,且那个内存页有“可执行(x)权限”,CPU 就会毫不犹豫地把栈上的数据当成指令来跑。

2. 曾经的黄金时代(32位与未保护系统)

在早期的 x86 32 位 Linux 或者现在某些没有开启 NX 的 CTF 题里,栈的权限是 ​rwx​​ (可读、可写、可执行) 。
这就是你上一张图(Code Injection Attacks)能够成功的直接原因:攻击者把 Shellcode(一段机器码)写进 buf​,然后把返回地址 A​ 覆盖成 buf​ 的地址 B​。ret​ 一执行,%rip​ 跳到 B,CPU 就开始在栈上执行攻击者的机器码。

3. 现代操作系统的防线(NX / XD 位)

为了彻底杜绝这种攻击,硬件和操作系统联手推出了保护方案:

  • 硬件层面:AMD 叫 NX(No-eXecute),Intel 叫 XD(Execute Disable)。在 CPU 的页表(Page Table)中增加了一个特殊标志位。
  • 操作系统层面:Linux/Windows 加载程序时,会将栈和堆标记为 ​rw-​ ​ (可读写,不可执行) 。
  • 发生了什么? 当攻击者依然用代码注入,把返回地址覆盖成栈地址时,ret​ 执行,%rip​ 跳到栈上。CPU 的 MMU(内存管理单元)在取指瞬间检查页表,发现当前页的 NX 位被置位,立刻触发硬件异常(段错误 / Segmentation Fault) ,进程直接被杀。

你之前看 vmmap​ 时,栈是 [stack]​ 且权限是 rw-p​,那个 p​ 是私有(Private),没有 x,这就意味着代码注入在现代 Linux 上打不通。

4. 现代 Pwn 的解法:ROP(返回导向编程)

既然不能执行自己的代码,攻击者就想了一个聪明的办法:借用程序本来就有的代码。

  • 我不往栈里写机器码了(写了也执行不了)。
  • 我去程序或 libc​ 里找现成的指令片段(Gadgets,比如 pop rdi; ret)。
  • 我利用溢出,把这些已有代码的地址排布在栈上。
  • 当 ret​ 执行时,%rip​ 跳去代码段(有 x​ 权限)执行这些片段,拼凑出 system("/bin/sh") 的效果。

这就是为什么你上一题算偏移量、找 p64(system_addr)​ 是现在的主流打法,而直接在栈上写 Shellcode 的做法在 NX enabled 的环境下已经行不通了。

💡 特殊情况:什么时候现在还能在栈上执行代码?

如果你非要执行栈上的代码,现代系统下也不是绝对不行:

  1. CTF 题目故意关掉 NX:checksec​ 显示 NX disabled,此时你依然可以快乐地写 Shellcode。
  2. 利用 ​mprotect​​ 等系统调用:如果有漏洞能让你调用 mprotect​ 把栈的内存页权限改为 rwx,你就能在栈上执行了(但这通常需要非常复杂的 ROP 链)。
  3. JIT(即时编译) :像 JavaScript 引擎(V8)为了防止性能损耗,会动态生成机器码并放在可执行的堆上,攻击者有时会利用 JIT 喷射(JIT Spray)来绕过 NX。

‍

为了避免缓冲区溢出,在最基础的程序设计上可以:

  1. 可以用更安全的输入方式,比如用fgets替代gets
  2. 用strncpy替代strcpy
  3. scanf使用的时候可以增加 %s

更安全的,可以使用ASLR(Address Space Layout Randomization),即地址空间分布随机化

ASLR 通常随机化这些区域:

  • 栈的起始位置/栈顶附近
  • 共享库、动态链接器的加载基址
  • mmap 映射区基址
  • 堆的起始地址,取决于系统配置
  • PIE 可执行文件的加载基址
  • vDSO、vvar 等

在 Linux 中,ASLR 强度由 ​/proc/sys/kernel/randomize_va_space 控制:

  • 0:关闭
  • 1:随机化栈、共享库、mmap 等
  • 2​:在 ​1 的基础上再随机化堆

所以结论是:

ASLR 是地址空间布局随机化;栈起始地址随机化是 ASLR 的一种表现形式/子集。若二选一,应选“内存地址随机化/地址空间布局随机化”,而不是“仅栈起始地址随机化”。

除了ASLR,还有子统计安全保护机制:不可执行代码段,也就是常说的NX位或DEP:

  • 传统 x86 的致命缺陷​:在早期的 x86 架构中,内存页只能被标记为“只读(read-only)”或“可写(writeable)”。​没有单独的“可执行(execute)”权限。这意味着:只要一块内存是可读的,CPU 就允许执行里面的代码。攻击者只要能把恶意代码塞进栈里,就能骗过 CPU 去执行它。
  • x86-64 的改进​:引入了明确的“执行(execute)”权限。操作系统借此将​栈(Stack)标记为不可执行(non-executable) 。

如果开启了 NX 保护,当攻击者试图让 CPU 执行栈上的 ​exploit code 时,CPU 会发现该内存区域(栈)只有读写权限,没有执行权限,于是抛出硬件异常(段错误),直接让程序崩溃,从而阻止了攻击。

‍

Canaries(金丝雀)对防护也有作用:它是一种放在栈上的特殊随机值,用来检测是否发生了栈缓冲区溢出。

1. 工作原理(Idea)

  • 放置位置(Place special value just beyond buffer) :
    函数在进入时,会在栈上的局部变量(缓冲区) 和保存的返回地址(以及栈帧指针) 之间,偷偷塞进去一个随机值(这就是金丝雀)。
    内存布局大致如下:
    ​[ 局部变量/缓冲区 ] -> [ 金丝雀值 ] -> [ 保存的栈帧指针 EBP ] -> [ 返回地址 ]
  • 检查机制(Check for corruption before exiting function) :
    函数在执行完毕、准备返回之前,会去检查这个金丝雀值是否还是原来那个。

2. 如何防御攻击?

在上一张 PPT(NX)中,攻击者试图用 ​gets()​ 溢出缓冲区,覆盖返回地址 B。
但现在有了金丝雀,攻击者为了覆盖返回地址,必须先覆盖掉前面的金丝雀值。由于攻击者不知道这个随机值是什么,他们填进去的数据大概率会改变金丝雀。

当函数准备返回时,检查机制发现金丝雀值被篡改了:

  • 程序会立刻调用 ​__stack_chk_fail 函数。
  • 程序直接崩溃(抛出异常并终止),绝不会去执行被覆盖的恶意返回地址。

3. GCC 编译器实现(PPT下方)

  • -fstack-protector:这是 GCC 提供的一个编译选项,用于自动插入金丝雀保护代码。
  • 现在通常已经作为很多现代操作系统的默认配置(Now the default),不需要程序员手动去写代码,编译器在编译时自动加上。

💡 延伸:金丝雀的种类

攻击者也在不断进化,所以金丝雀也分几种:

  1. Terminator Canary:包含 ​\x00​(空字节)、换行符、EOF 等。因为很多溢出函数(如 ​strcpy​, ​gets)遇到这些字符会停止读取,从而防止溢出覆盖。
  2. Random Canary:程序启动时生成一个完全随机的值。每次运行都不一样。
  3. Random XOR Canary:将随机值与返回地址等关键数据异或,不仅防覆盖,还防信息泄露。

总结:现代系统防御体系(三剑客)

到此,你已经集齐了现代系统抵御栈溢出攻击的三大防线:

  1. ASLR(地址空间随机化) :让攻击者找不到代码在哪(无法预测地址)。
  2. NX/DEP(不可执行位) :就算攻击者跳过来了,CPU 也拒绝执行栈上的代码(上一张PPT)。
  3. Stack Canary(栈金丝雀) :只要攻击者敢乱写,函数在返回前就会报警拉闸(这张PPT)。

这三者通常是协同工作的,极大地提高了软件的安全性。

L09 Machine-Level Programming V: Advanced Topics

‍

‍

‍

push   %rbp                 # main 的基地址入栈,保存调用者的 rbp
mov    %rsp, %rbp           # 把当前栈指针复制给基址指针,建立 main 的栈帧
sub    $0x10, %rsp          # 给 a[10] 分配 16 字节栈空间(高位向低位生长,所以 rsp 减 0x10)
; ...
lea    -0x10(%rbp), %rax    # 【纠正】这是把 rbp-0x10 这个“地址值”放入 rax,没有访问内存!
mov    %rax, %rsi           # scanf 的第 2 个参数 (buffer)
lea    0xe4f(%rip), %rdi    # scanf 的第 1 个参数 "%s" (通过 RIP 相对寻址)
mov    $0x0, %eax           # 调用可变参数函数前清零 eax
call   scanf@plt
; ...
lea    0xe4a(%rip), %rdi    # system 的参数 "whoami"
call   system@plt
; ...
mov    $0x0, %eax           # main 返回 0
leave                       # 等价于 mov %rbp, %rsp; pop %rbp
ret                         # 等价于 pop %rip
高地址
[   返回地址 RIP   ]  <-- rbp + 0x8 (8字节)
[   保存的 RBP     ]  <-- rbp (8字节)
[   填充对齐区     ]  <-- rbp - 0x8 到 rbp (6字节,为了凑齐16字节分配)
[   a[0] ~ a[9]   ]  <-- rbp - 0xC (10字节)
低地址

‍

针对这些防护,现在又有了新的攻击手法,即 ROP (Return-Oriented Programming),返回导向编程

ROP 的核心思想是:利用程序自身或者动态链接库(如 libc)中已经存在的代码片段(Gadgets),通过操控栈上的返回地址,把它们像拼乐高一样拼接起来,形成一条攻击链。

什么是 Gadget?
Gadget 是一段以 ​ret 指令(0xC3)结尾的极短指令序列。
比如:

  • pop rdi; ret
  • pop rsi; pop r15; ret
  • syscall; ret

这些 Gadget 本身就是程序正常编译后产生的合法指令。攻击者只需要知道这些 Gadget 在内存中的绝对地址。

具体执行流程演示:

假设攻击者构造了如下 Payload(攻击载荷)覆盖栈:

高地址
| ... (其他数据)
| [ system 函数的地址 ]   <-- 准备作为 ret 的目标
| [ "/bin/sh" 的地址  ]   <-- 准备被 pop 给 rdi
| [ pop rdi; ret 的地址 ] <-- 覆盖原本的返回地址
| [ 填充垃圾数据      ]
低地址 (rsp 当前指向这里)

当原函数执行 ​ret 时,奇妙的化学反应开始了:

  1. 第一步​:​ret​ 弹出栈顶的 ​pop rdi; ret 的地址​,赋值给 ​rip。程序跳转到这个 Gadget 执行。
  2. 第二步​:执行 ​​pop rdi​​。把此时栈顶的 ​"/bin/sh" 的地址​ 弹出,​完美存入 ​​rdi​​ 寄存器​。此时 ​rsp​ 下移 8 字节,指向了 ​system 函数的地址。
  3. 第三步​:执行 ​​ret​​。把此时栈顶的 ​system 函数的地址​ 弹出,赋值给 ​rip​。程序跳转到 ​system 函数执行。
  4. 最终结果​:程序执行了 ​system()​ 函数,并且因为第二步的操作,​​rdi​​ 里已经装好了 ​ ​"/bin/sh"​ ​ 的地址。

等价于执行了: ​​system("/bin/sh")​ ​ ,攻击者成功拿到 Shell!

‍

Unions会使用占用空间最大的域的大小来分配内存,一次只能用一个

‍

正文完
 0
评论(没有评论)