← 목록으로

Stack Canary: 원리와 우회 방법

[Pwn] Pwnable · 작성: 2026-07-19 19:31:03 · 수정: 2026-07-21 23:10:24 · 조회 23

Stack Canary를 "랜덤 값 하나 넣어서 오버플로우를 막는 기법"이라고만 알면, 그 랜덤 값이 정확히 어디서 오고, 왜 이게 "오버플로우를 막는" 게 아니라 "오버플로우를 탐지만 하는" 기법인지, 그리고 왜 스레드 환경에서 이 값이 매번 새로 계산되지 않는지를 놓치게 된다.

커널/컴파일러 레벨: 카나리 값은 어디서 오는가

컴파일러(gcc/clang)가 스택 버퍼가 있는 함수를 컴파일할 때, 함수 진입부에 아래와 같은 코드를 자동으로 삽입한다.

mov rax, fs:0x28      ; TLS(Thread Local Storage) 영역에서 카나리 값을 읽어옴
mov [rbp-0x8], rax    ; 지역 변수 버퍼와 saved RBP/리턴 주소 "사이"에 저장

fs:0x28커널이 프로세스 시작 시(정확히는 glibc의 초기화 코드가 __stack_chk_guard를 채울 때) /dev/urandom에서 가져온 진짜 난수를 각 스레드의 TLS(Thread Control Block) 영역에 저장해둔 값이다. 이 값은 프로세스가 살아있는 동안 딱 한 번 정해지고, 그 프로세스의 모든 스레드가 같은 값을 공유한다 — 즉 카나리는 함수 호출마다 새로 랜덤화되는 게 아니라, 프로세스 실행 시점에 고정된 하나의 비밀값을 스택의 특정 위치에 복사해두는 방식이다.

함수가 끝날 때(epilogue) 이 저장해둔 값을 다시 fs:0x28의 원본과 비교한다.

mov rax, [rbp-0x8]
xor rax, fs:0x28
jz  .ok            ; 같으면 정상 — 그대로 리턴
call __stack_chk_fail  ; 다르면 프로세스를 즉시 종료

왜 "예방"이 아니라 "탐지"인가

카나리는 오버플로우 자체를 막지 않는다buf가 리턴 주소를 덮어쓰는 것 자체는 그대로 일어난다. 다만 리턴 주소로 가는 길목(스택 상의 물리적 위치)에 카나리 값을 함께 배치해뒀기 때문에, 순차적으로 메모리를 채워나가는 일반적인 오버플로우(strcpy, gets 등 앞에서부터 순서대로 덮어쓰는 함수)라면 리턴 주소를 덮기 전에 반드시 카나리도 함께 덮게 된다는 점을 이용해, 함수가 ret을 실행하기 직전에 "방금 실행 흐름이 하이재킹당할 뻔했다"는 걸 사후적으로 알아채고 프로세스를 강제 종료시키는 방식이다. 그래서 카나리가 있어도 오프셋 계산이나 크래시 자체는 똑같이 일어나고, 다만 "성공적인 하이재킹"까지는 못 가고 그 직전에 프로세스가 죽는다는 차이가 생긴다.

우회 방법: 카나리의 전제를 깨는 두 가지 각도

1) 카나리 값을 먼저 유출시키기 (정보 유출 우회)

카나리는 매 바이트 중 가장 낮은 바이트가 항상 0x00으로 고정되어 있다 — 이건 카나리 값이 우연히 문자열 함수(strcpy, printf("%s") 등)에 의해 도중에 잘려서 유출되는 걸 막기 위한 설계다. 만약 대상 프로그램에 1바이트씩 순차적으로 값을 읽어내거나 출력해주는 취약점(별도의 버퍼 오버리드, 또는 Format String 글에서 다룬 %x로 스택을 그대로 읽어내는 기법)이 있다면, 카나리 값을 한 바이트씩(또는 통째로) 미리 알아낸 뒤, 오버플로우 페이로드 안에 원래 값과 정확히 똑같은 카나리 값을 그대로 채워 넣고 그 다음에 리턴 주소를 덮으면 검사를 그대로 통과한다 — 카나리는 "값이 달라졌는가"만 검사하지, 애초에 그 값을 아는지 모르는지는 검사하지 않는다는 전제를 파고드는 우회다.

2) fork되는 서버에서의 브루트포스 (Byte-by-byte Brute Force)

fork()로 매 연결마다 자식 프로세스를 새로 만드는 네트워크 서비스(전통적인 유닉스 서버 구조)라면, 자식 프로세스가 부모의 메모리를 그대로 복사해서 시작하므로 카나리 값이 재사용된다. 이 경우 카나리를 한 바이트씩 브루트포스할 수 있다 — 첫 바이트를 0~255까지 넣어보며 크래시가 나는지(틀림) 안 나는지(맞음, 크래시 시그널 부재로 판별) 확인하고, 맞은 바이트는 고정한 채 다음 바이트로 넘어가는 식으로 최대 256×8번의 시도만으로 8바이트 카나리 전체를 알아낼 수 있다 — 무작위 256^8가지를 다 시도하는 게 아니라, "한 바이트씩 검증 가능하다"는 구조적 약점을 이용해 시도 횟수를 지수적으로 줄이는 전형적인 오라클(oracle) 기반 사이드채널 공격 패턴이다.

여기서 끝나지 않는다: 체이닝

카나리를 "막는 기법"이 아니라 **"신뢰가 깨졌는지 사후에 검증하는 기법"**으로 이해하면, 왜 정보 유출이 있는 순간 카나리의 전제 자체가 무너지는지, 그리고 왜 실전 익스플로잇에서 "카나리 우회"가 별도의 독립된 취약점이 아니라 거의 항상 다른 정보 유출 취약점과의 조합으로 이뤄지는지가 이해된다.

Pwnable 카테고리의 글 (4/14)

  1. 메모리 구조 기초: 스택, 힙, 레지스터부터 이해하기
  2. 스택 버퍼 오버플로우: 가장 기본적인 메모리 손상 공격
  3. Format String 취약점: printf가 위험해지는 순간
  4. Stack Canary: 원리와 우회 방법
  5. NX/DEP 우회와 Return-to-libc
  6. ASLR과 정보 유출(Leak)
  7. ROP (Return-Oriented Programming) 완전 정복
  8. 힙 익스플로잇 기초: Use-After-Free와 Double Free
  9. pwntools 실전 활용법
  10. UDF(User-Defined Function) 기반 DB 권한 상승: 원리부터 실전까지
  11. Pass-the-Hash / Pass-the-Ticket: 원리와 실전
  12. eBPF 기반 지속성(Persistence) 기법
  13. Full RELRO 환경에서의 GOT Overwrite와 우회
  14. ret2dlresolve: 심볼 정보 없이 임의 함수 호출하기
← Format String 취약점: printf가 위험해지는 순간 NX/DEP 우회와 Return-to-libc →