익스플로잇 개발 (z3-solver)

(주)수호아이오 / 20. 10. 07. 오전 12:12

안녕하세요. 수호 보안 분석가 Hackability 입니다.

본 글에서는 SMT (Satisfiability Modulo Theories) Solver 로 많이 사용되는 z3 패키지에 대한 버그와 이 버그를 이용하여 시스템 쉘을 획득 하는 과정을 정리하였습니다.

버그 찾기

본 버그는 python-z3를 이용하여 연구를 하던중 우연히 발견한 Crash Log에 의해 시작되었습니다.

최초 충돌 로그

관련 로그를 이용하여 z3 깃 이슈를 확인해보니 작년 5월에 이미 등록되었던 이슈였고, 개발자가 특별한 이슈가 아니라고 판단한 뒤 해당 이슈는 닫혔습니다.

Type Confusion of Z3_inc_ref version <= 4.7.1 · Issue #1639 · Z3Prover/z3 While working with Z3, I found that missing parsing value before call Z3_inc_ref which lead to memory corruption or…github.com

주말을 어떻게 보낼지 고민하고 있던 저로써는 놀기 좋은 장난감을 발견한 기분이였습니다. 그리고 버그 찾기 여정을 시작하였습니다.

결과는? 찾았습니다. 아주 많이…

버그 분석 및 익스플로잇 작성

작년에 제작한 파이썬 스크립트 퍼저를 이용하여 테스트를 해본 결과, 엄청난 양의 Segmentation Fault 로그가 발생함을 볼 수 있었습니다.

하지만 Segmentation Fault를 발생한 로그가 있다고 해서 모든 로그들이 Exploitable 한 것은 아닙니다. 따라서, 각 로그를 보면서 조작 가능한 레지스터와 이에 의해 실행 흐름을 변경할 수 있는 로직을 찾아야 합니다. 위에서 찾은 poc 코드는 제가 원하는 형태가 아니기 때문에 다시 찾기 시작했습니다.

로그 분석 결과 위 조건을 만족하는 취약점을 발견하였습니다. 퍼저에서 제공한 poc는 다음과 같습니다.

파이썬 스크립트 퍼저에서 생성된 PoC

이를 디버깅 해보면 다음과 같이 제 입력에 영향을 받아 잘못된 메모리를 참조하여 Segmentation Fault 가 발생했음을 알 수 있습니다.

PoC 스크립트에 의한 충돌 시점

이 로그를 선택한 이유는 크래쉬가 발생한 rip 이후 jmp rax 호출이 존재하기 때문입니다. rax 는 제 값에 의해 조작된 값을 가지고 있기 때문에 현재 충돌이 발생한 위치에서 적절히 값을 수정하여 실행 흐름을 jmp rax 까지도달 시키면 그 이후부터는 제가 원하는 실행 흐름으로 변경 시킬 수 있을 것 같습니다.

수정된 poc를 이용하여 충돌이 발생한 위치와 이유, 그리고 충돌을 발생 시키지 않고 실행 흐름을 변경할수 있는 방안에 대해 step-by-step 으로 분석을 해보도록 합시다.

먼저, 수정된 poc는 다음과 같습니다.

수정된 PoC

첫 번째 인자로 널 값이 들어 가지 않도록 바이트 시퀀스로 넣고, 두 번째 인자와 세번째 인자는 퍼저에서 발생되었던 형태로 넣었습니다. 디버깅을 위한 input 함수는 파이썬을 실행하여 libz3 가 동적으로 메모리에 로드 되었을때 해당 함수에 breakpoint (bp)를 걸기 위함 입니다. 따라서, 먼저 poc를 실행 시키고 해당 프로세스에 디버거를 붙인 뒤, 라이브러리 베이스 주소 + 우리가 보려는 함수의 offset 에 bp를 걸고 시작합니다.

디버거를 해당 프로세스에 붙인뒤 메모리 맵을 확인해보면 다음과 같이 libz3.so가 특정 메모리에 올라와 있음을 확인할 수 있습니다.

0x7f480e578000 r-xp libz3.so
0x7f480f884000 ---p libz3.so
0x7f480fa83000 rw-p libz3.so

0x7f480e578000 의 메모리 맵의 권한이 r-xp 로 실행 권한이 있기 때문에 이곳이 라이브러리의 코드 베이스가 되며 우리가 추적하기 원하는 함수의 offset은 0xeb870 이기 때문에 결론적으로 0x7f480e663870 에 bp를 걸면 될 것 같습니다.

추가적으로 대부분의 최신 리눅스에서는 기본적으로 ASLR (Address Space Layout Randomization)이 설정되어 있기 때문에 PoC 코드를 실행할 때 마다 위의 라이브러리 코드 베이스 주소가 변경 될 것 입니다. 디버깅 시 매번 라이브러리의 코드 베이스 주소를 구하여 디버깅 하는 방법이 있고, 다른 방법으로는 시스템 설정의 ASLR을 끄는 것 입니다. 라이브러리 함수의 offset은 고정적이기 때문에 변경되지 않습니다.

sudo sysctl -a 
; to see all sysctls
sudo sysctl -w kernel.randomize_va_space=0
; 0 - disable ASLR
; 1 - half ASLR
; 2 - full ASLR <- default

api::context::set_error_code 진입 지점

원하는 위치에 잘 랜딩 한 것 같습니다. 레지스터를 잠깐 확인해보면 rdi, [r10], r12, r14 등의 레지스터가 우리가 입력한 값과 연관이 있는 것 같습니다. 이후 내용을 보면 rbx 역시 우리가 컨트롤할수 있는 rdi에 의해 값을 할당 받기 때문에 rbx 역시 컨트롤 가능 합니다.

EB870  push    r13
EB872  push    r12
EB874  push    rbp
EB875  mov     ebp, esi
EB877  push    rbx
EB878  mov     rbx, rdi
EB87B  sub     rsp, 8
EB87F  test    esi, esi
EB881  mov     [rbx+518h], esi
EB887  jnz     short loc_EB898
loc_EB889:
EB889  add     rsp, 8
EB88D  pop     rbx
EB88E  pop     rbp
EB88F  pop     r12
EB891  pop     r13
EB893  retn
EB894  align 8
EB898
loc_EB898:
EB898  mov     rax, [rdi+528h]

0xEB887 에서 [rbx+0x518] 과 rsi 를 비교하여 같지 않으면 0xEB898로 분기 합니다. 만약 분기 하지 않으면 이후 return 으로 함수가 끝나기 때문에 분기를 하도록 조건을 설정해야 합니다.

Condition 1: [rbx+0x518] != rsi

loc_EB898:
EB898  mov     rax, [rdi+528h]
EB89F  mov     r12, rdx
EB8A2  lea     r13, [rdi+528h]
EB8A9  xor     ecx, ecx
EB8AB  xor     esi, esi
EB8AD  mov     rdi, r13
EB8B0  mov     rdx, [rax-18h]
EB8B4  call    __ZNSs9_M_mutateEmmm
EB8B9  test    r12, r12
EB8BC  jz      short loc_EB8D4
EB8BE  mov     rdi, r12
EB8C1  call    _strlen
EB8C6  mov     rsi, r12
EB8C9  mov     rdx, rax
EB8CC  mov     rdi, r13
EB8CF  call    __ZNSs6assignEPKcm
loc_EB8D4:
EB8D4  mov     rax, [rbx+520h]

다음 블록을 확인해보면 rax에 [rdi+0x528]을 할당하는데 이때 rdi는 우리가 설정한 값 이기 때문에 이 때, rax 역시 우리가 컨트롤 가능한 레지스터가 됩니다. 그 아래 r13 의 경우에도 [rdi+0x528]로 할당을 하기 때문에 r13도 컨트롤 가능한 레지스터가 되지만 한 가지 rax와 차이점이 있습니다. rax의 경우에는 rdi+0x528 위치의 값을 넣기 때문에 우리가 넣은 값으로 rax를 설정할 수 있지만 r13의 경우에는 우리가 컨트롤 가능한 데이터를 가리키는 주소를 넣기 때문에 r13 값 자체는 컨트롤이 되는 것은 아닙니다. 물론, r13을 가리키는 값이 우리가 컨트롤 가능한 영역이기 때문에 유용한 값이긴 합니다.

우리가 설정한 값으로 중간 중간에 있는 call과 분기문들을 넘어서 0xEB8D4까지 도달하게 됩니다.

loc_EB8D4:
EB8D4  mov     rax, [rbx+520h]
EB8DB  test    rax, rax
EB8DE  jz      short loc_EB889
EB8E0  lea     rdx, g_z3_log
EB8E7  cmp     qword ptr [rdx], 0
EB8EB  jz      short loc_EB8F7
EB8ED  lea     rdx, g_z3_log_enabled
EB8F4  mov     byte ptr [rdx], 1
loc_EB8F7:
EB8F7  add     rsp, 8
EB8FB  mov     rdi, rbx
EB8FE  mov     esi, ebp
EB900  pop     rbx
EB901  pop     rbp
EB902  pop     r12
EB904  pop     r13
EB906  jmp     rax

이 부분이 가장 중요한 부분인데 중간에 있는 0xEB8DE 분기만 타지 않는다면 jmp rax 가젯 까지 실행 흐름을 도달 시킬 수 있습니다.

Condition 2: test rax, rax (rax != 0)

먼저 rax에 [rbx+0x520] 값을 넣게 되는데 rbx는 우리가 첫 번째 인자로 넣은 데이터의 첫 번째 주소 입니다. 현재 poc 테스트에서는 인자로 A를 0x100개를 넣었기 때문에 0x520 위치에는 어떤 값이 들어 갈지 알 수가 없습니다. 만약 rbx+0x520 값을 컨트롤 할수 없다면 test rax, rax에서 분기를 타서 우리가 원하는 흐름으로 가지 못할 수 있습니다.

위에서 분석한 결과대로 poc를 수정해봅니다. 첫 번째 인자로 A를 0x520개 그리고 B를 8개 넣어 봅니다. 기대하는 결과로 실행 흐름이 0xeb8d4에 왓을 때 rax의 값이 “BBBBBBBB”가 되어야 합니다.

modified_poc_02.py

다른 충돌 상황

우리가 원하는 위치에 도달 하기 전에 segmentation fault가 발생합니다. 레지스터를 확인해보면 잘못된 메모리에 접근해서 문제가 발생한건데 왜 rax에 저런 값이 들어 갓는지 살펴 봅니다.

loc_EB898:
EB898  mov     rax, [rdi+528h]
EB89F  mov     r12, rdx
EB8A2  lea     r13, [rdi+528h]
EB8A9  xor     ecx, ecx
EB8AB  xor     esi, esi
EB8AD  mov     rdi, r13
EB8B0  mov     rdx, [rax-18h]

rax에 [rdi+0x528] 을 넣습니다. 위 크래쉬가 발생했을 때 rdi 값은 변경된 rdi 값이기 때문에 rax 값을 대입할 때 레지스터를 확인해보면 다음과 같습니다.

rax 값이 할당되는 상황

rdi 가 우리가 넣은 값을 가리키고 있습니다. 우리는 0x528개를 payload로 넣었기 때문에 0x528 부터 접근하는 값은 어떤값이 될지 모릅니다. 따라서 8바이트를 더 넣어주어야 하는데 이 때 중요한 점은 이때 넣는 8바이트는 주소로 생각하여 해당 주소-0x18 값이 메모리에 접근이 가능해야 위와 같은 충돌이 발생되지 않을 것 같습니다.

Condition 3: mov rdx, [rax-0x18] (rax-0x18 is valid memory address)

이 때 선택해야 하는 값은 고정적인 메모리 주소를 선택해야 하는데 간단하게는 python 바이너리의 got나 bss 영역을 지정하여 그 영역에 있는 주소를 참조 하도록 하는 것 입니다. 본 환경의 python 은 PIE (Position Independent Executable) 옵션이 disabled 로 컴파일이 되어 있기 때문에 python의 코드 베이스는 고정적이며 data, bss 영역 역시 고정적 일 것입니다.

bss 영역의 주소는 0x9b4000 이며 이 메모리를 살펴 보면 다음과 같습니다.

BSS 영역 메모리 덤프

rdx가 [rax-0x18] 로 접근 하기 때문에 만약 rdx가 0x9b4000이 되게 하고 싶다면 실제로는 0x9b4000+0x18을 넣어야 합니다. 이를 바탕으로 페이로드를 다시 구성하면 다음과 같습니다.

bss 영역에 있는 고정 주소 추가

실행 해보면 우리의 의도대로 정상적으로 동작하고 jmp rax 에서 BBBBBBBB로 점프 하려다가 유효하지 않은 주소 이기 때문에 에러가 발생하고 멈춰 있습니다.

jmp 0x4242424242424242 ㄴㅇㄱ

정확히 우리가 원하는 형태로 되었네요. 이제 어디로 흐름을 변경하면 좋을까요? 일반적으로 쉘을 획득하기 위해 system, exec 등의 명령을 실행해야 합니다.

먼저 system 함수의 경우에 python 바이너리 내부에 plt 가 노출이 되어 있기 때문에 이 주소를 이용합니다.

.plt:41F4E0 ; int system(const char *command)
.plt:41F4E0 _system proc near
.plt:41F4E0 jmp cs:off_9B4348
.plt:41F4E0 _system endp

또한 system 인자로 들어갈 스트링의 주소인 rdi 가 친절하게 우리가 컨트롤 가능한 메모리 영역을 가르키고 있습니다. 이 부분을 “/bin/sh”로 넣고 수정을 합니다. 최종적인 형태는 다음과 같습니다.

최종 익스플로잇 코드

결과는 다음과 같습니다.

쉘 획득!

Yeah!

결론

본 글에서는 최신버전 z3-solver (4.8.6)를 대상으로 스크립트 퍼저에서 발생된 Type Confusion 버그를 분석하고 시스템 쉘을 획득하는 익스플로잇 까지 제작을 해보았습니다.

C 라이브러리에 인터페이스된 python 패키지에서 Python Object를 유저 입력으로 받는 경우가 많기 때문에 Type Confusion 버그가 exploitable 한 경우가 많습니다. 하지만 파이썬의 경우에는 javascript 처럼 강력한 샌드박스 정책을 펼치기 어려운 환경이기 때문에 이러한 버그에 대해서 개발자가 일일이 대응하기 힘든 부분이 있을 것 같습니다.

본 과정 자체는 다른 어플리케이션의 버그를 찾고 익스플로잇을 제작 하는 과정과 비슷하기 때문에 버그 헌팅에 익숙하지 않은 분들은 파이썬으로 시작하셔서 경험을 쌓는 것도 좋은 방법일 것 같습니다.

여러분들의 박수는 저에게 또 다른 재미있는 글을 작성하는데 큰 원동력이 됩니다. 재밌게 보셨다면 두 손벽을 부딪혀주세요. ;)

본 글에 관련해서 궁금하신점이 있으시면 편하게 댓글 또는 메일 hackability@sooho.io로 연락주시기 바랍니다.

기업문화 엿볼 때, 더팀스

로그인

/