View
동아리에서 '포너블을 위한 어셈블리 기초 및 디버깅'을 주제로, 포너블 스터디에서 발표했는데
블로그에도 소개하면 좋을 것 같은 내용을 들고 왔다.
이번 내용은 스택과 스택 프레임에 대한 내용으로,
스택에 데이터가 어떠한 방식으로 쌓이는지를 이해할 수 있을 것이다.
먼저, 스택의 역할을 소개한다.
스택은 함수 호출을 위한 주소 값을 저장하고, 함수를 호출할 때의 인자 및 지역 변수가 이 세그먼트에 저장된다.
amd64 기준으로, 함수 인자는 RDI->RSI->RDX->RCX->R8->R9 순으로 레지스터를 사용한다.
만약, printf와 같이, 함수의 인자 개수가 제한이 없는 경우는 어떨까?
// https://code.woboq.org/userspace/glibc/stdio-common/printf.c.html
int __printf (const char *format, ...)
{
...
}
format 문자열이 고정 인자로 선언되어 있고, 그 뒤는 가변 인자로 정의되어 있다는 뜻으로 "..."가 붙었다.
이러한 경우, 함수 인자의 개수가 6개보다 더 많을 수 있고, 7번째 인자부터는 스택에 값을 저장하여 전달하게 된다.
Format String Bug를 할 때, 6번째부터 우리가 입력한 값이 보이는 이유는, 총 5개의 인자(1개는 포맷 스트링이므로)를 참조하고 난 뒤에, 스택을 확인하기 때문이고, 그 스택의 공간에 우리가 입력한 값이 존재하기 때문이다.
스택 프레임의 구조를 이해하고나면, 왜 우리가 입력한 값이 6번째 인덱스 이상부터 존재할 수밖에 없는지 이해하게 된다.
RSP와 RBP 레지스터는 각각 Stack Pointer, Base Pointer의 줄임말이다.
RSP 레지스터는 현재 스택 프레임의 최상단 및 스택의 최상단을 가리키고, WORD(CPU가 한 번에 처리할 수 있는 데이터 단위)는 32 비트면 4바이트, 64 비트면 8바이트 단위로 PUSH/POP으로 그 길이가 늘어나고 줄어든다. 또한, 지역 변수를 선언할 때는 RSP 레지스터를 add/sub 연산을 이용해 값을 빼서 스택의 길이를 늘린다.
RBP 레지스터는 현재 스택 프레임의 최하단 주소, 즉 Base 주소를 가지고 있다. 상대적으로 늘어나고 줄어드는 경우가 잦은 RSP보다는 RBP가 더 안정적이기 때문에, RBP를 기준으로 지역 변수 혹은 매개 변수에 접근한다.
Return Address(RET)는 함수가 종료되고, 스택 프레임을 정리하고 난 뒤, 복귀할 주소를 의미한다.
Saved/Stack Frame Pointer(SFP)는 스택 프레임의 포인터이다. 즉, 특정 스택 프레임의 주소를 가지고 있다는 것인데, 바로 현재 함수를 호출한 caller 함수의 스택 프레임의 Base 주소를 가지고 있다.
즉, 현재 스택 프레임에는 '현재 함수가 종료되고 난 후에 돌아갈 주소'와 '현재 함수를 호출한 함수의 스택 프레임 Base 주소'를 가지고 있는 것이다.
만약 이 값이 공격자에 의해 오염된다면? 프로그램의 실행 흐름을 조작할 수 있게 된다.
이제 Caller 함수에서 func 함수를 호출할 때, 어떤 방식으로 스택이 변화하는지 그림과 함께 살펴볼 것이다.
Caller 함수는 8바이트 정수인 var 지역 변수를 한 개 정의한 상태이기 때문에, RBP와 RSP 레지스터는 8바이트 이상 차이가 존재할 수 있다.
현재 상황은 RBP는 0x100, RSP는 0xF8 값을 가지고 있다고 가정하였다.
call func가 실행되었으며, RET에는 'next code'의 주소가 들어 있다.
'push rbp'와 'mov rbp, rsp'가 함수의 프롤로그에 해당하고, 'leave'와 'ret'는 함수의 에필로그에 해당한다.
먼저, 현재 RBP의 값을 스택에 삽입하였고, SFP1에는 Caller 함수의 Base 주소가 들어있다.
뒤이어, 'mov rbp, rsp'가 실행되면서, 함수의 프롤로그 실행이 끝난 상황이다.
이게 기본형이고, 만약 지역 변수가 선언되지 않았다면, 이 상황에서 함수 내부의 루틴을 수행할 것이다. 혹은 또 다른 함수가 호출되어 새로운 스택 프레임을 구축할 수도 있겠다.
그러나, 현재 상황에서 func는 0x20 크기의 버퍼를 사용한다고 가정하였고, 따라서, RSP 레지스터를 0x20만큼 빼서, 스택을 늘리고 Buffer가 할당되었다.
이제 함수의 에필로그에 도입하였다.
'leave'는 'mov rsp, rbp'+'pop rbp'와 같기 때문에,
현재 스택 프레임의 SFP 값을 RBP에 삽입하고, RSP는 RET를 가리키게 된다.
마지막으로 ret 명령을 수행함으로써, Caller 함수의 다음 명령어를 실행하도록 하였다.
스택 프레임과 함수의 프롤로그/에필로그는 아주 아름답고 깔끔하게 구성되었기 때문에,
func 함수의 스택 프레임을 정리하자마자, RSP와 RBP가 Caller 함수의 스택 프레임을 그대로 갖고 있는 것을 볼 수 있다.
왜 FSB를 할 때, 6번째 인덱스 이상을 참조해야, 우리가 입력한 값이 나올 수밖에 없는지 추가 설명하자면, 다음과 같다.
위에서는 설명하지 않았지만 함수의 매개변수로 스택을 이용할 때는 RET 밑에 미리 인자를 push 해 두고, Callee 함수는 RBP를 기준으로 값을 더해서 이를 참조한다.
printf도 마찬가지로 함수이기 때문에, 스택 프레임이 존재할 수밖에 없다. 따라서, 6번째 이상부터는 스택의 매개변수를 참조하므로, Caller 함수의 지역 변수를 참조하게 되는 것이다.
만약 위에서 설명한 그림에서 func가 printf이고, FSB를 수행하는 상황이었다면? 6번째 인덱스를 참조하면 var 변수가 보이게 되는 것이다!
'Security > System Hacking' 카테고리의 다른 글
포너블할 때, 잡다한 팁 (0) | 2022.04.26 |
---|---|
[Heap Exploit] House of Lore (a.k.a. Smallbin attack) (0) | 2022.02.24 |
인자 전달 방법 (함수 호출 규약) (0) | 2022.02.19 |