안녕하세요. 수호 보안 분석가 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로 연락주시기 바랍니다.