본격적인 Pwnable에 대한 포스팅 시작하기 전에 필수 배경지식인 함수의 호출과 복귀(?)에 대해 먼저 포스팅 하고자 합니다. 직접 따라하면서 해보시는 것을 추천하지만 귀찮으시다면 옆에다 필기를 하면서 천천히 보시는걸 추천해드립니다. 그것도 귀찮다면 그냥 천천히 읽으셔도 됩니다만 아마 처음 접하시는 분들이라면 다소 머리가 아프실 수 있습니다.
진행은 Ubuntu 18.04로 진행합니다. 실습을 위해서 Ubuntu 18.04와 gcc, gdb, pwndbg, vim(혹은 vi)이 필요합니다.
gcc, gdb, vim은 apt-get으로 설치 가능하나 pwndbg의 경우 설치법이 따로 있으니 검색하셔서 설치해주시기 바랍니다.
진행전 알아둘 상식
1. 자료구조 Stack 에 대한 개념과 동작에 대한 이해
2. RBP, RSP, RIP 레지스터의 개념
3. 레지스터 개념이 어렵다면 RBP = 스택의 시작점, RSP = 스택의 꼭대기, RIP = 다음 실행할 명령어의 주소 보관함 정도로 알고 있기
먼저 아래와 같은 프로그램을 작성한 뒤 컴파일 해줍니다. 원활한 분석을 위해 -g 옵션(디버깅 옵션)을 넣어주세요.
$vim test.c
#include <stdio.h>
void toast()
{
printf("Hello Guys!\n");
}
int main(int argc, char* argv[])
{
toast();
}
$gcc -o test test.c -g
위 코드는 단순히 toast라는 함수를 호출하고 해당 함수에서 Hello Guys! 라는 메세지를 출력한 후 main으로 복귀, 종료되는 단순한 프로그램 입니다. argc와 argv에 대한 설명은 다음에 하기로 하고 천천히 따라서 실습해주시면 되겠습니다. 코드와 명령어의 구분을 위해 쉘에서는 앞에 $를 붙이고 gdb의 경우 앞에 (gdb)를 붙이도록 하겠습니다.
$gdb test
(gdb)disass main
Dump of assembler code for function main:
0x000000000000064d <+0>: push rbp
0x000000000000064e <+1>: mov rbp,rsp
0x0000000000000651 <+4>: sub rsp,0x10
0x0000000000000655 <+8>: mov DWORD PTR [rbp-0x4],edi
0x0000000000000658 <+11>: mov QWORD PTR [rbp-0x10],rsi
0x000000000000065c <+15>: mov eax,0x0
0x0000000000000661 <+20>: call 0x63a <toast>
0x0000000000000666 <+25>: mov eax,0x0
0x000000000000066b <+30>: leave
0x000000000000066c <+31>: ret
End of assembler dump.
0x000000000000064d <+0>: push rbp
0x000000000000064e <+1>: mov rbp,rsp
0x0000000000000661 <+20>: call 0x63a <toast>
0x000000000000066b <+30>: leave
0x000000000000066c <+31>: ret
위의 어셈블리어는 main함수 전체에 대한 것이고 아래는 이번 포스팅 내용에 필요한 부분만 뽑아낸것으로 다른 부분은 모르셔도 함수의 호출과 복귀에 대해 이해하는데 전혀 지장이 없습니다. 그럼 toast함수의 내용도 살펴보겠습니다.
(gdb)disass toast
Dump of assembler code for function toast:
0x000000000000063a <+0>: push rbp
0x000000000000063b <+1>: mov rbp,rsp
0x000000000000063e <+4>: lea rdi,[rip+0xaf] # 0x6f4
0x0000000000000645 <+11>: call 0x510 <puts@plt>
0x000000000000064a <+16>: nop
0x000000000000064b <+17>: pop rbp
0x000000000000064c <+18>: ret
End of assembler dump.
0x000000000000063a <+0>: push rbp
0x000000000000063b <+1>: mov rbp,rsp
0x000000000000064b <+17>: pop rbp
0x000000000000064c <+18>: ret
보시면 아시겠지만 main과 toast 함수 모두 공통되는 부분이 있습니다. 각 함수의 앞의 2줄과 맨 마지막줄이 공통인데 이를 '프롤로그'와 '에필로그'라고 부릅니다. 함수 사용을 위한 준비 과정과 마무리 과정이라고 생각하시면 되겠습니다. 그럼 다시 main 함수의 어셈블리 명령어로 돌아가서 천천히 분석해보도록 하겠습니다.
(참고 : 원래 에필로그는 보통 pop rbp, ret가 아닌 leave와 ret으로 구성되지만 위 예제는 leave대신 pop rbp만 있습니다. 이에 대한건 뒤에 설명드리겠습니다.)
분석을 위해 main함수와 toast함수에 breakpoint를 걸어주고 실행해줍니다.
(gdb) b main
(gdb) b toast
(gdb) r
여러분들이 저와 같은 환경을 구축해서 pwndbg를 쓰고 계시다면 저와 비슷한 화면을 보실 수 있으실겁니다. 보시면 call toast 직전에 bp(BreakPoint)가 걸려서 진행이 멈춘것을 보실 수 있습니다. 일단 이번 포스팅 내용과 큰 관련은 없으니 ni(Next Instruction) 명령어를 이용해 진행해주겠습니다.
(gdb)ni
call toast를 하기 직전의 상황입니다. 해당 명령어는 이름에서 유추 할 수 있듯이 toast 함수를 호출하는 명령어입니다. 하지만 단순히 해당 함수를 호출하는게 아니라 아래와 같은 과정을 거칩니다.
1. Stack에 다음 명령어의 주소(0x555555554666 <main+25> mov eax, 0)를 PUSH.
2. 호출할 함수의 주소(toast <0x55555555463a>)로 JMP(jump)
결국 현재 RSP 값에 -8을 하여 RSP 위치를 변경하고 변경된 위치에 다음 명령어의 주소를 저장한 뒤 toast 함수의 시작 주소로 이동(점프) 한다는 것입니다. 이런 방식을 통해 toast 함수가 끝난 뒤 main 함수로 복귀할때 정확히 어느 위치로 복귀할지 알려주는 것이죠. si 명령을 통해 직접 값의 변화를 살펴보겠습니다.
(gdb)si
RSP부분만 비교해보시면 기존 값에서 정확히 8이 빠졌고 해당 위치엔 main+25에 해당하는 주소값이 들어가 있다는걸 볼 수 있습니다.
자 그리고 현재 push rbp를 진행하기 직전인데 현재 rbp의 값과 main함수에 있었을때 rbp값을 보면 동일하다는 것을 알 수 있습니다. 결국 push rbp는 '이전 함수 스택의 시작점(SFP, Stack Frame Pointer)'을 저장하는 행위라고 보시면 됩니다.
(gdb)ni
push rbp를 해줬으니 당연히 rsp 값도 -8 만큼 변하게 됩니다. 이제 mov rbp, rsp를 진행할 차례인데 이 명령어를 알아 보기 쉽게 표현하자면 다음과 같습니다.
mov rbp, rsp --> rbp = rsp
rbp에 rsp 값을 넣어준다는 것으로 '함수의 스택 시작지점'을 지정해주는 겁니다. 그리고 그 이후에 내용을 보면 lea와 call 명령어를 이용해 뭔가를 해주는데 ni 명령어를 이용해 넘어가보시면 아시겠지만 printf를 호출하는 부분입니다. 그 아래 nop이 있는데 이건 말 그대로 아무것도 안하는 명령어죠. 그 이후에 pop rbp를 하는데 이부분부터 복귀를 위한 명령어라고 볼 수 있습니다. 일단 pop rbp 실행 전까지 진행하고 자세히 보도록 하겠습니다.
현재 rsp의 위치는 0x7fffffffdfe0이며 해당 위치에는 0x7fffffffe000라는 주소값이 위치해있습니다. pop rbp를 하면 rbp에 현재 rsp에 있는 값을 넣어주고 rsp를 +8 만큼 해준다는 뜻입니다. 그리고 현재 rsp값을 잘 살펴보면 toast 함수의 첫 부분에 저장했던 main함수의 rbp와 값이 같은것을 확인 할 수 있습니다.
결국 '이 함수의 사용이 끝났고 이전 함수로 돌아가야 하니 스택 시작 지점을 이전 함수의 스택 시작 지점으로 돌리겠다.' 라는 뜻입니다.
(gdb)ni
마지막으로 ret 입니다. ret는 말그대로 이전 함수로 돌아가는 명령어로 실제 명령어의 구성은 아래와 같습니다.
pop rip
jmp rip
현재 rsp에 있는 값을 rip에 넣은 뒤 해당 위치로 점프한다는 겁니다. 그리고 이전에 pop rbp를 통해 rsp가 +8이 되면서 rsp는 이전에 저장해둔 'main 함수의 다음 명령어의 주소'를 갖고 있죠. 결국 rsp에 저장되어 있는 위치로 이동(점프)한다고 보시면 되겠습니다. 그럼 다시 main함수로 돌아가게되고 main 함수의 leave 부분을 살펴보도록 하겠습니다.
leave는 이전 함수로 돌아가기전 현재 함수의 스택을 정리하는 명령어입니다. toast 함수에서 pop rbp를 했던것을 기억하시나요? leave는 그 앞에 mov rsp, rbp를 추가한 녀석입니다. 결국 leave의 명령어 구성은 아래와 같죠.
mov rsp, rbp
pop rbp
스택 영역 어딘가에서 날뛰고 있는 rsp를 현재 함수의 스택 지점으로 부르고 그 뒤에 rbp에 rsp가 갖고 있는 값(이전 함수의 스택 시작점)을 넘기게 됩니다. 그리고 rsp는 +8이 되고 ret를 하게되어 프로그램은 종료됩니다.
너무 자세하게 쓰려고 하다보니 글이 정리가 안되어있다는 느낌이 강하게 드는데 나중에 천천히 다시 읽어보면서 정리해야겠습니다.
간단 요약
'Pwnable > Tech' 카테고리의 다른 글
OOB(Out Of Boundary) (0) | 2020.06.14 |
---|---|
Return to Shellcode (0) | 2019.08.09 |