아래 내용은 dreamhack.io System Exploitation Fundamental를 정리 및 학습한 것.
2. Memory Corruption - C(1)
1) 버퍼 오버플로우
버퍼 오버플로우란?
C언어에서 버퍼란 지정된 크기의 메모리 공간이라는 뜻이다. 버퍼 오버플로우 취약점은 그 이름에서 나타나듯이 버퍼가 허용할 수 있는 양의 데이터보다 더 많은 값이 저장되어 버퍼가 넘치는 취약점이다.
일반적으로 버퍼 오버플로우는 발생하는 위치에 따라 스택 버퍼 오버플로우, 힙 오버플로우와 같이 나눠서 부른다. 버퍼 오버플로우는 인접한 메모리를 오얌시키는 취약점이기 때문에 어떤 메모리를 오염시킬 수 있는지에 따라 공격 방법이 달라지기 때문이다. 그중 스택 버퍼 오버플로우는 가장 초기에 연구되었던 형태의 버퍼 오버플로우로, 지역 변수가 할당되는 스택 메모리에서 오버플로우가 발생하는 경우이다.
먼저 8바이트의 버퍼 A와 8바이트 데이터 버퍼 B가 메모리에 선형적으로 할당되었다고 생각해 보자. 여기서 버퍼 A에 16바이트의 데이터를 복사한다면, 이 데이터의 뒷부분은 버퍼 A를 넘어 뒤에 있는 데이터 영역인 B에 쓰여지게 된다.
이때 우리는 버퍼 오버플로우가 발생했다고 하고, 이는 프로그램의 Undefined Behavior를 이끌어낸다. 만약 데이터 영역 B에 나중에 호출될 함수 포인터를 저장하고 있다면 이 값을 "AAAAAAAA"와 같은 데이터로 덮었을 때 Segmentation Fault를 발생시킬 것이다. 만약 공격자가 이를 악용한다면 어딘가에 기계어 코드를 삽입한 후 함수 포인터를 공격자의 코드의 주소로 덮어 코드를 실행할 수도 있다.
예제 코드를 통해 자세히 알아보자.
예제 1.
// stack-1.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char buf[16];
gets(buf);
printf("%s", buf);
}
stack-1.c는 16바이트 버퍼 buf를 스택에 할당한 후, gets 함수를 통해 사용자로부터 데이터를 입력받아 이를 그대로 출력하는 코드이다. gets 함수는 사용자가 개행을 입력하기 전까지 입력했던 모든 내용을 첫 번째 인자로 전달된 버퍼에 저장하는 함수이다. 그러나 gets 함수에는 별도의 길이 제한이 없기 때문에 16바이트가 넘는 데이터를 입력한다면 스택 버퍼 오버플로우가 발생한다.
이처럼 버퍼 오버플로우 취약점은 프로그래머가 버퍼의 길이에 대한 가정을 올바르지 않게 하여 발생한다. 이는 보통 길이 제한이 없는 API 함수들을 사용하거나 버퍼의 크기보다 입력받는 데이터의 길이가 더 크게 될 때 자주 일어나는 실수이다.
https://dreamhack.io/learn/2/14#3에서 오른쪽 페이지의 모듈을 사용해 실습한다. 버퍼를 오버플로우시켜 ret 영역을 ox41414141로 만들면 성공이다.
hex-encode로 41이 나타내는 것을 찾아보면 대문자 A이다. 아스키코드인것 같다. 앞의 내용엔 아무거나 쭉 입력해주고 AAAA를 입력해주면 ret 영역을 0x41414141로 만들 수 있따.
예제 2.
stack-2.c의 main 함수는 argv[1]을 check 함수의 인자로 전달한 후 그 리턴 값을 받아온다. 리턴 값이 0이 아니라면 "Hello Admin!"을, 0이라면 "Access Denied!"라는 문자열을 출력한다.
핵심이 되는 check 함수에서 16바이트 크기의 temp 버퍼에 입력받은 패스워드를 복사한 후 "SECRET_PASSWORD"문자열과 비교한다. 문자열이 같다면 auth 변수를 1로 설정하고 auth를 리턴한다.
그러나 line 10에서 strncpy 함수를 통해 temp 버퍼를 복사할 때, temp의 크기인 16바이트가 아닌 인자로 전달된 password 문자열의 길이만큼을 복사한다. 따라서 argv[1]에 16바이트가 넘는 문자열을 전달한다면 길이 제한 없이 문자열이 복사되어 스택 버퍼 오버플로우가 발생하게 된다.
temp 버퍼 뒤에 auth 값이 존재하므로, 오버플로우가 발생해 공격자의 데이터가 auth 값을 바꾼다면 auth가 0이 아닌 다른 값이 될 수 있다. 이 경우 실제 인증 여부와는 상관 없이 line 24의 if(check_auth(argv[1])) 문은 항상 참을 반환하게 된다.
// stack-2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int check_auth(char *password) {
int auth = 0;
char temp[16];
strncpy(temp, password, strlen(password));
if(!strcmp(temp, "SECRET_PASSWORD"))
auth = 1;
return auth;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: ./stack-1 ADMIN_PASSWORD\n");
exit(-1);
}
if (check_auth(argv[1]))
printf("Hello Admin!\n");
else
printf("Access Denied!\n");
}
실제로 입력해보자.
16자리만 입력하면 "Access Denied!"가 출력된다. 하지만
버퍼의 크기보다 큰 문자열을 입력하면 "Hello, Admin!"이 출력된다. 물론 올바른 비밀번호인 SECRET_PASSWORD를 입력해도 되긴 하다.
예제 3.
stack-3.c의 main 함수는 24바이트 크기의 버퍼 buf를 할당한다. scanf 함수를 통해 size 변수에 값을 입력받고, size만큼 buf에 데이터를 입력받는다.
stack-1.c에서는 길이 검증이 없는 함수를 사용해 스택 버퍼 오버플로우가 발생했고, 이번에는 고정된 크기의 버퍼보다 더 긴 데이터를 입력받아 스택 버퍼 오버플로우가 발생했다.
// stack-3.c
#include <stdio.h>
#include <unistd.h>
int main(void) {
char win[4];
int size;
char buf[24];
scanf("%d", &size);
read(0, buf, size);
if (strncmp(win, "ABCD", 4)){
printf("Theori{-----------redeacted---------}");
}
}
사이즈를 32로 잡고 32개의 숫자를 입력하니 size 버퍼와 win 버퍼까지 들어찼다.
예제 4.
stack-4.c는 32바이트 크기 buf를 초기화한 후 데이터를 31바이트 입력받고, sprintf 함수를 통해 출력할 문자열을 저장한 뒤 출력하는 코드다.
read 함수에서 받는 입력이 32바이트를 넘진 않지만, sprintf 함수를 통해 버퍼에 값을 쓸 때 "Your Input is: "문자열을 추가한다는 사실을 생각해야 한다. 만약 buf에 31바이트를 꽉 채운다면 해당 문자열이 앞에 붙어 총 길이가 32바이트를 넘게 된다.
// stack-4.c
#include <stdio.h>
int main(void) {
char buf[32] = {0, };
read(0, buf, 31);
sprintf(buf, "Your Input is: %s\n", buf);
puts(buf);
}
버퍼 오버플로우는 프로그래머가 길이에 대한 검증을 정확히 수행하지 못해 발생한다. 만약 공격 벡터로부터 데이터를 입력받고 이를 버퍼에 저장하는 코드가 있다면 이를 유심히 살펴봐야 한다. 데이터를 버퍼에 입력받을 때는 입력받은 데이터가 버퍼의 범위를 초과하지 않는지 항상 정확히 검사해야 한다.
입력받을 때 길이 제한이 없는 함수를 사용한다면 이는 잠재적으로 취약하다. 입력받은 데이터가 버퍼에 저장되기까지의 흐름을 따라가 버퍼의 크기를 넘는 양을 저장할 수 있는지 가능성을 검토해야 한다. 만약 길이를 명시하는 함수를 사용한다면, 명시된 길이가 버퍼의 크기를 넘을 수 있는지를 검토해야 한다.
버퍼 오버플로우는 스택에서만 발생하는 취약점이 아니다. 프로그래머가 동적으로 메모리를 관리할 수 있는 힙에서도 똑같이 발생할 수 있다. 이들은 단지 발생하는 메모리 영역의 차이만 있을 뿐이고 취약점이 발생하는 원인이 본질적으로 다르진 않다.
힙 영역은 스택 영역과 사용 목적이 다르기 때문에, 스택 버퍼 오버플로우와는 다른 방법으로 익스플로잇해야 한다. 힙 오버플로우를 익스플로잇하는 방법은 Linux Exploitation & Mitigation 강의에서 다룬다. 여기서는 힙 오버플로우가 어떻게 발생하는지에 대해서만 간단히 알아보겠다.
힙 오버플로우
heap-1.c는 40바이트 크기의 힙 버퍼 input과 hello를 할당한 후, hello 버퍼에는 "HI!"문자열을 복사하고 read 함수를 통해 input에 데이터를 입력받는 코드이다. 그러나 read 함수를 통해 입력받는 길이인 100바이트가 input 버퍼의 크기인 40바이트보다 크기 때문에 힙 오버플로우가 발생한다.
힙 오버플로우가 발생했을 때의 힙 메모리 상태는 아래의 그림으로 간략히 나타낼 수 있다.
input 영역에서 버퍼 오버플로우가 발생해 hello의 메모리 영역까지 침범할 경우, line 16에서 hello 메모리를 출력할 때 "HI!" 문자열이 아니라 공격자에게 오염된 데이터가 출력된다.
// heap-1.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *input = malloc(40);
char *hello = malloc(40);
memset(input, 0, 40);
memset(hello, 0, 40);
strcpy(hello, "HI!");
read(0, input, 100);
printf("Input: %s\n", input);
printf("hello: %s\n", hello);
}
2) Out of boundary
예제 1.
OOB(Out Of Boundary)는 버퍼의 길이 범위를 벗어나는 인덱스에 접근할 때 발생하는 취약점이다.
oob1.c에서는 int형 배열 buf를 선언하고 idx 값을 입력받는다. 그다음 buf[idx]에 정수를 입력받고 idx와 buf[idx] 값을 출력한다.
여기서 주의해야 할 점은 buf의 길이는 10이므로 buf의 인덱스로 사용될 수 있는 올바른 값은 0 이상 10 미만의 정수라는 것이다. 그러나 코드에서는 line 10에서 입력받은 idx 값을 인덱스로 사용할 때 해당 값이 올바른 범위에 속해 있는지 검사하지 않는다. C언어는 인덱스를 이용해 버퍼에 접근할 때 인덱스의 범위에 대한 별도의 경계 검사가 존재하지 않기 때문에 올바르지 않은 값을 사용한다면 buf의 영역 밖에 있는 값에 접근할 수 있다.
그렇다면 idx와 buf[idx]에 적당한 값을 줘서 win 변수를 31337로 만들어보자.
인덱스 값에 11을 입력해 원래 buf 버퍼가 접근 가능한 영역 밖의 영역인 win 에 접근해 내용을 수정했다.
예제 2.
// oob-2.c
#include <stdio.h>
int main(void) {
int idx;
int buf[10];
int win;
printf("Which index? ");
scanf("%d", &idx);
idx = idx % 10;
printf("Value: ");
scanf("%d", &buf[idx]);
printf("idx: %d, value: %d\n", idx, buf[idx]);
if(win == 31337){
printf("Theori{-----------redeacted---------}");
}
}
oob-2.c는 oob-1.c와는 달리 line 10에 idx = idx % 10 이란 코드가 추가되었다. 이 코드로 OOB의 취약점을 막을 수 있을까?
OOB의 발생 여부를 판단할 때는 버퍼의 인덱스로 사용할 수 있는 올바를 값의 범위와 버퍼의 인덱스가 될 수 있는 모든 값의 범위를 비교하면 된다. 인덱스가 될 수 있는 값의 범위가 사용할 수 있는 올바른 값의 범위의 부분집합이라면 안전하다고 할 수 있따. 이를 oob2.c에 적용해 보자.
- buf의 인덱스로 써야 하는 값의 범위: 0~9
- buf의 인덱스로 쓸 수 있는 값의 범위: int 형의 범위 % 10
양의 정수를 10으로 나눈 나머지로 가능한 값은 0에서 9까지기 때문에 이는 얼핏 보면 안전해 보인다. 그러나 C언어에서는 피연산자가 음수라면 나머지 연산의 결과도 음수가 될 수 있다. 따라서 이 경우에 buf의 인덱스로 쓸 수 있는 값의 범위는 -9 ~ 9이므로 나머지가 음수가 되게 한다면 OOB를 발생시킬 수 있다.
예제 3.
//oob-3.c
#include <stdio.h>
int main(void) {
int idx;
int buf[10];
int dummy[7];
int win;
printf("Which index? ");
scanf("%d", &idx);
if(idx < 0)
idx = -idx;
idx = idx % 10; // No more OOB!@!#!
printf("Value: ");
scanf("%d", &buf[idx]);
printf("idx: %d, value: %d\n", idx, buf[idx]);
if(win == 31337){
printf("Theori{-----------redeacted---------}");
}
}
oob-3.c는 oob-2.c와는 다르게 idx가 음수일 경우 이를 양수로 바꿔주는 코드가 추가되었다. line 14에 들어가게 되는 idx 값은 양수가 되고, 10으로 나머지 연산을 했을 때 값의 범위는 0부터 9까지기 때문에 아무 문제가 없어 보인다.
그러나 C언어의 정수 표현에 대해 생각해보면, 뭔가 이상한 부분이 있다는 것을 알 수 있다. 몇 가지 idx 값에 따라 line 14가 어떻게 실행되는지 자세히 살펴보자.
idx = 1 (line 14) -> idx = 1 (line 15)
idx = -100 (line 14) -> idx = 100 (line 15)
idx = -10000 (line 14) -> idx = 10000 (line 15)
idx = -pow(2, 31) (line 14) -> idx = -pow(2, 31) (line 15)
마지막에 있는 예시가 직관적으로 이해되지 않을 수 있다. 왜 그런지 생각해보자.
아마도 정수 자료형이 표현할 수 있는 범위를 벗어난 크기의 자료라서 조건문이 실행되지 않은 것 같다..?
정답 : C언어에서 int형으로 표현 가능한 정수의 범위는 -pow(2, 31) ~ pow(2, 31) - 1입니다. int형은 32비트이기 때문에 총 pow(2, 32)개의 수를 표현할 수 있습니다. int형은 0을 포함하기 때문에 표현할 수 있는 음의 정수의 갯수와 양의 정수의 갯수는 다릅니다.
int 형에서 -pow(2,31)은 표현 가능하지만 pow(2,31)은 표현 가능하지 않습니다. pow(2,31)은 표현 가능한 최대 정수보다 하나 더 크기 때문에 이는 -pow(2,31)과 같은 값이 됩니다.
oob-3.c를 다시 살펴보겠습니다. 지금까지 논의한 바에 따르면, idx에 -pow(2, 31)을 넣었을 경우 line 14에서 절대값을 구하는 연산을 수행한 후에도 -2**31이 그대로 저장됩니다. 그렇다면 line 15에서 idx = idx % 10을 할 때 idx에 음수가 저장되고, 이는 buf 배열의 올바른 인덱스 범위를 벗어나기 때문에 OOB가 발생합니다.
이를 근본적으로 막기 위해서는 idx를 int형이 아닌 unsigned int형으로 선언하거나, 인덱스를 입력받은 이후에 if(idx < 0 || idx >= 10)과 같은 경계 검사 구문을 추가해야 합니다.
3) Off by one
// off-by-one-1.c
#include <stdio.h>
void copy_buf(char *buf, int sz) {
char temp[16];
for(i = 0; i <= sz; i++)
temp[i] = buf[i];
}
int main(void) {
char buf[16];
read(0, buf, 16);
copy_buf(buf, sizeof(buf));
}
Off by one 취약점은 경계 검사에서 하나의 오차가 있을 때 발생하는 취약점이다. 이는 버퍼의 경계 계산 혹은 반복문의 횟수 계산 시 < 대신 <=를 쓰거나, 0부터 시작하는 인덱스를 고려하지 못할 때 발생한다.
off-by-one-1.c는 buf에 16바이트의 문자열을 입력받은 후 buf와 sizeof(buf)의 값을 copy_buf 함수의 인자로 전달한다. copy_buf 함수에선 임시 버퍼 temp를 할당하고 반복문을 통해 buf의 데이터를 복사한다. 그러나 반복문은 i가 0일 때부터 sz일 때까지 총 sz + 1번 반복하게 된다. 따라서 sz + 1만큼 데이터가 복사되고, off by one 취약점이 발생한다.
4) Epilogue
스택 버퍼 오버플로우
스택 버퍼 오버플로우는 가장 초기에 등장한 버퍼 오버플로우 형태 중 하나로, 지역 변수가 할당되는 스택 메모리에서 발생하는 취약점입니다. 이는 데이터를 입력받거나 복사하는 부분에 대한 길이 검증이 존재하지 않거나 미흡할 경우에 발생합니다.
힙 오버플로우
힙 버퍼 오버플로우는 동적으로 할당된 힙 메모리 영역에서 발생하는 취약점입니다. 이는 데이터를 입력받거나 복사하는 부분에 대한 길이 검증이 존재하지 않거나 미흡할 경우에 발생합니다.
Out Of Boundary
Out-Of-Boundary는 버퍼의 길이 범위를 벗어나는 인덱스에 접근할 때 발생하는 취약점입니다. 이는 올바르지 않은 값이 버퍼의 인덱스로 사용될 경우 발생합니다.
Off by one
Off-by-one은 버퍼의 경계 계산 혹은 잘못된 반복문의 연산자를 사용하는 등의 인덱스를 고려하지 않을 때 발생하는 취약점입니다.
'포너블 스터디' 카테고리의 다른 글
달고나 요약 (~ 33p) (0) | 2020.04.11 |
---|---|
System Exploitation Fundamental (3) (0) | 2020.04.10 |
System Exploitation Fundamental (1) (0) | 2020.04.10 |
bandit level 0~ (0) | 2020.04.03 |