Mitigation : Stack canary

뒬량·2025년 2월 18일
post-thumbnail

들어가며

  • 버퍼 오버플로우는 기본적인 지식이기에 개념 설명은 생략함
  • 실습 코드의 바이너리는 실습자의 바이너리가 아닌 더 직관적인 바이너리로 대체하였음

목차

  1. Return Address Overwrite

  2. Canary 란?

  3. Canary 작동 원리(간단 설명)

  4. 실습을 통한 작동 원리 이해


Return Address Overwrite

  • ret(return address)을 내가 원하는 주소로 조작해 overwrite(덮는)하는 것
  • 버퍼 오버플로우를 기본 베이스로 하여 ret을 조작함

ex) 아래 발췌한 코드에서 get_shell() 함수로 ret을 조작하려면?

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;
}
pwndbg> disass main
Dump of assembler code for function main:
   0x00000000004006e8 <+0>:		push   rbp
   0x00000000004006e9 <+1>:		mov    rbp,rsp
   0x00000000004006ec <+4>:		sub    rsp,0x30
   0x00000000004006f0 <+8>:		mov    eax,0x0
   0x00000000004006f5 <+13>:	call   0x400667 <init>
   0x00000000004006fa <+18>:	lea    rdi,[rip+0xbb]        # 0x4007bc
   0x0000000000400701 <+25>:	mov    eax,0x0
   0x0000000000400706 <+30>:	call   0x400540 <printf@plt>
   0x000000000040070b <+35>:	lea    rax,[rbp-0x30]
   0x000000000040070f <+39>:	mov    rsi,rax
   0x0000000000400712 <+42>:	lea    rdi,[rip+0xab]        # 0x4007c4
   0x0000000000400719 <+49>:	mov    eax,0x0
   0x000000000040071e <+54>:	call   0x400570 <__isoc99_scanf@plt>
   0x0000000000400723 <+59>:	mov    eax,0x0
   0x0000000000400728 <+64>:	leave
   0x0000000000400729 <+65>:	ret
End of assembler dump.
  • 제일 중요한 것은 스택 프레임을 그려서 구조 파악을 하는 것

  • 이것을 통해서 버퍼를 오염시키고 ret 조작 가능

    Canary 란?

옛날에 탄광에 들어갈 때 일산화탄소 중독의 위험을 알려주는 새로 이용됨
컴퓨터에서는 ? -> 버퍼 오버플로우를 감지하는 도구로 이용됨

이렇게 중간에 버퍼와 ret 사이에 canary가 위치해 있어 ret을 변조하려면 카나리까지 덮어야 하는 상황이 나오게 됨.


Canary 작동 원리(간단 설명)

위의 그림과 같이, 위의 스택 프레임의 순서는 낮은 스택부터 buffer, canary, sfp, return address 순이다. (실제는 많이 다를 수 있음)
그런데 버퍼 오버플로우를 일으키려고 카나리를 덮는 순간, 컴퓨터는 카나리가 변조되었음을 확인하고, 그 즉시 프로그램을 종료한다.


실습을 통한 Canary 작동 원리 이해

  1. 먼저 코드에 canary를 적용시킨 것과, 적용시키지 않은 것의 바이너리 비교를 통해 차이점을 비교한다

  2. 그 차이점이 어떤 결과를 만드는지(어떻게 BOF를 보호하는지) 분석한다

Canary 정적 분석
// Name: canary.c

#include <unistd.h> 

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

위의 간단한 예시 코드를 이용해서 하나는 그냥 실행 파일을 만들고, 다른 하나는 -fno-stack-protector gcc 옵션을 이용해서 canary를 제거한다.


둘의 차이가 보인다.
canary를 적용하여 다시 컴파일하고, 긴 입력을 주면 Segmentation fault가 아니라 stack smashing detected와 Aborted라는 에러가 발생한다. 이는 스택 버퍼 오버플로우가 탐지되어 프로세스가 강제 종료되었음을 의미한다.

no_canary와 디스어셈블 결과를 비교하면, main 함수의 프롤로그와 에필로그에 각각 다음의 코드들이 추가되었다.

0x00000000000006b2 <+8>:     mov    rax,QWORD PTR fs:0x28    
0x00000000000006bb <+17>:    mov    QWORD PTR [rbp-0x8],rax
0x00000000000006bf <+21>:    xor    eax,eax
0x00000000000006dc <+50>:    mov    rcx,QWORD PTR [rbp-0x8]  
0x00000000000006e0 <+54>:    xor    rcx,QWORD PTR fs:0x28     
0x00000000000006e9 <+63>:    je     0x6f0 <main+70>
0x00000000000006eb <+65>:    call   0x570 <__stack_chk_fail@plt>

  • canary를 disass 한 코드
   push   rbp
   mov    rbp,rsp
   sub    rsp,0x10
+  mov    rax,QWORD PTR fs:0x28
+  mov    QWORD PTR [rbp-0x8],rax
+  xor    eax,eax
+  lea    rax,[rbp-0x10]
-  lea    rax,[rbp-0x8]
   mov    edx,0x20
   mov    rsi,rax
   mov    edi,0x0
   call   read@plt
   mov    eax,0x0
+  mov    rcx,QWORD PTR [rbp-0x8]
+  xor    rcx,QWORD PTR fs:0x28
+  je     0x6f0 <main+70>
+  call   __stack_chk_fail@plt
   leave
   ret

그러면 이제 각각 추가된 코드가 어떤 역할을 하는지 알아보자


Canary 저장

Canary 동적 분석

0x00000000000006b2 <+8>: mov rax,QWORD PTR fs:0x28
0x00000000000006bb<+17>: mov QWORD PTR [rbp-0x8],rax
0x00000000000006bf <+21>: xor eax,eax

$ gdb -q ./canary
pwndbg> break *main+8
Breakpoint 1 at 0x6b2
pwndbg> run
► 0x5555555546b2 <main+8>   mov   rax, qword ptr fs:[0x28] <0x5555555546aa>
  0x5555555546bb <main+17>  mov   qword ptr [rbp - 8], rax
  0x5555555546bf <main+21>  xor   eax, eax

main+8은 fs:0x28의 데이터를 읽어서 rax에 저장한다. fs는 세그먼트 레지스터의 일종으로, 리눅스는 프로세스가 시작될 때 fs:0x28에 랜덤 값을 저장한다. 따라서 main+8의 결과로 rax에는 리눅스가 생성한 랜덤 값이 저장된다.

pwndbg> print /a $rax
$1 = 0xf80f605895da3c00

rax에 저장된 값을 확인해보니 첫번째는 널바이트로 저장되어 있고, 랜덤 값이 저장되어 있음을 확인할 수 있다.

그리고 rax에 있는 값은 main+17에서 rbp - 8에 저장됨을 알 수 있다.

pwndbg> ni
   0x5555555546b2 <main+8>  mov  rax, qword ptr fs:[0x28] <0x5555555546aa>
   0x5555555546bb <main+17> mov  qword ptr [rbp - 8], rax
 ► 0x5555555546bf <main+21> xor  eax, eax
pwndbg> x/gx $rbp-0x8
0x7fffffffe238:	0xf80f605895da3c00

Canary 검사

추가된 에필로그의 코드에 중단점을 설정하고 바이너리를 계속 실행시켜보자

main+50은 rbp-8에 저장한 카나리를 rcx로 옮긴다. 그 뒤, main+54에서 rcx를 fs:0x28에 저장된 카나리와 xor 하는데 두 값이 동일하면 연산 결과가 0이 되면서 je의 조건을 만족하게 되고, main 함수에서 정상적으로 반환된다. 그러나 두 값이 동일하지 않으면 __stack_chk_fail이 호출되면서 프로그램이 강제로 종료된다.

16개의 H를 입력해서 카나리를 변조하고, 실행 흐름이 어떻게 되는지 살펴보자

pwndbg> break *main+50
pwndbg> continue
HHHHHHHHHHHHHHHH
Breakpoint 2, 0x00000000004005c8 in main ()
 ► 0x5555555546dc <main+50>  mov  rcx, qword ptr [rbp - 8] <0x7ffff7af4191>
   0x5555555546e0 <main+54>  xor  rcx, qword ptr fs:[0x28]
   0x5555555546e9 <main+63>  je   main+70 <main+70>
    ↓
   0x5555555546f0 <main+70>  leave
   0x5555555546f1 <main+71>  ret

코드를 한 줄 실행시키면, rbp-0x8에 저장된 카나리 값이 버퍼 오버플로우로 인해 “0x4848484848484848”이 된 것을 확인할 수 있다.

pwndbg> ni
   0x5555555546dc <main+50>    mov    rcx, qword ptr [rbp - 8] <0x7ffff7af4191>
 ► 0x5555555546e0 <main+54>    xor    rcx, qword ptr fs:[0x28]
   0x5555555546e9 <main+63>    je     main+70 <main+70>
pwndbg> print /a $rcx 
$2 = 0x4848484848484848

main+54의 연산 결과가 0이 아니므로(xor 값이 동일하지 않으므로) main+63에서 main+70으로 분기하지 않고 main+65의 __stack_chk_fail을 실행하게 된다.

pwndbg> ni
pwndbg> ni
pwndbg> ni
   0x5555555546dc <main+50>    mov    rcx, qword ptr [rbp - 8] <0x7ffff7af4191>
   0x5555555546e0 <main+54>    xor    rcx, qword ptr fs:[0x28]
   0x5555555546e9 <main+63>    je     main+70 <main+70>
 ► 0x5555555546eb <main+65>    call   __stack_chk_fail@plt <__stack_chk_fail@plt>

이 함수가 실행되면 다음의 메세지가 출력되며 프로세스가 강제로 종료된다.

*** stack smashing detected ***: <unknown> terminated

Program received signal SIGABRT, Aborted.

0개의 댓글