
소스코드 분석부터 진행하겠다.
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
struct over {
void (*table)(); // 함수 포인터 1개를 멤버로 가지는 구조체
};
void alarm_handler() {
puts("TIME OUT"); // 타임아웃 시 메시지 출력
exit(-1); // 즉시 종료
}
void initialize() {
// 표준입출력 버퍼링 제거: 입출력 동작이 즉시 반영되도록 설정
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
// 30초 타임아웃 설정
signal(SIGALRM, alarm_handler);
alarm(30);
}
void get_shell() {
// 쉘 획득 함수: /bin/sh 실행
system("/bin/sh");
}
void table_func() {
// 기본적으로 구조체의 함수 포인터가 가리키는 함수
printf("overwrite_me!");
}
int main() {
// 힙에 0x20(32) 바이트 크기의 버퍼 할당
char *ptr = malloc(0x20);
// 힙에 0x20 바이트 크기의 구조체(over) 할당
struct over *over = malloc(0x20);
initialize();
// 함수 포인터를 정상 함수(table_func)로 초기화
over->table = table_func;
// 취약 지점: 길이 제한 없는 %s로 입력을 받아 ptr로 복사
// ptr는 0x20 바이트 크기인데, 더 긴 문자열을 넣으면 힙 오버플로우 발생
scanf("%s", ptr);
// 함수 포인터가 NULL이면 종료
if( !over->table ){
return 0;
}
// 함수 포인터 호출
over->table();
return 0;
}
힙(Heap) 오버플로우
동적 할당된 버퍼 ptr에 대해 입력 길이 검증 없이 scanf("%s", ptr)를 사용하므로,
32바이트를 초과하는 입력이 들어오면 바로 뒤에 이어서 할당된 힙 청크의 메타데이터 및 사용자 영역까지 덮어쓸 수 있다.
함수 포인터 오버라이트
바로 다음에 할당된 struct over *over는 내부에 함수 포인터 table을 가진다.
ptr에서 시작된 오버플로우가 힙 상에서 over의 사용자 영역까지 도달하면,
over->table 값을 임의 주소로 바꿀 수 있다.
여기에 바이너리 내의 get_shell 주소를 덮으면 over->table() 호출 시 /bin/sh가 실행된다.-
메모리 배치 가정
glibc malloc(64비트) 기준으로, 요청 크기 0x20은 내부적으로 0x10 헤더를 포함해 청크 크기 0x30으로 정렬된다.
힙 배치(전형적 시나리오)는 다음과 같다.
[ ptr 청크 헤더 0x10 ][ ptr 사용자영역 0x20 ][ over 청크 헤더 0x10 ][ over 사용자영역(구조체) >= 0x20 ]
대략 0x30 이후가 over→table 일 것이라고 유추가 가능하다.
어떤 보안 정책들이 걸려있는지 확인해본다.

get_shell의 주소 또한 파악해둔다.

이제 중요한 포인트는 오프셋이 얼마인가 인데, 확인을 위해 scanf@plt 직후에 breakpoint를 걸어 확인해줄 것이다.

메인 함수 확인 결과 main+74에서 scanf@plt가 호출된다.
이 후, gdb에서 heap 명령어로 청크를 확인해보면,

두개의 작은 청크가 보인다.
Addr: 0x804b198 Size: 0x30 ← 첫 malloc(0x20): ptr
Addr: 0x804b1c8 Size: 0x30 ← 둘째 malloc(0x20): over
32비트 glibc에서 Size: 0x30이면 헤더 0x8+ 사용자영역 0x28로 정렬 되기 때문에, 주소로 확인이 가능하다.
ptr 사용자 시작: 0x804b198 + 0x8 = 0x804b1a0