🔐
땡칠로그/🔐시스템 보안 - BOF 대책

시스템 보안 - BOF 대책

태그
CS
수업
정리
완료 일시
Jun 14, 2024
BoF 공격 발견 이후 창과 방패의 대결이 펼쳐짐

NX

메모리 영역(PTE)에 실행 가능 여부를 표시한다.
실행할 때 검사하므로 스택/힙 영역을 실행하는 일을 막을 수 있다.
💡
NX 비트는 어디에 있는가?
NX 비트도 프리빌리지 체킹처럼 GDT 엔트리에 있다고 생각할 수 있는데, 아니다.
NX 비트는 세그먼트 프로텍션의 개념이 아니다. 따라서 세그먼트를 기술하는 GDT 엔트리에 있지가 않다.
그럼 어디있냐? 페이지 테이블 엔트리(PTE)에 있다. 그마저도 32비트에는 R/W만 표기되어 있기 때문에 PAE(Physical Address Extension)으로 64비트 PTE를 사용해야 NX 비트를 사용할 수 있다.
PAE-enabled 기준 0x0000000000000001(=0x1) 이라는

여담이지만 위에서 얘기한건 논리적인 PTE(human readable)고,
little endian으로 메모리 기록되는 x86 아키텍쳐의 특성을 생각하면 실제 메모리에 저장된 바이트들은 0x01 0x00 0x00 … 0x00 이 된다.
메모리 읽을 땐 항상 엔디언에 조심해야 한다.
💡
Privilege Checking이 있는데 NX 비트가 왜 필요한가?
Privilege Checking이 있는데 왜 쉘코드가 실행되었는가?
Privilege checking은 Segment Protection.
접근 권한만 제어할 뿐 R/W/X를 제어하는 것이 아니다.
또한 세그먼트 디스크립터(GDT Entry)에는 R/W에 대한 제어까지만 있기 때문에 실행을 막을 수 있는 방법은 아니다.
따라서 BoF에 취약했고, 이에 NX 비트가 도입되었다.
notion image

RTL

“NX? 그럼 쉘코드 안쓰고 실행할게”
하지만 BoF 자체가 막힌건 아니다.
여전히 취약한 코드를 통해 리턴 주소를 조작할 수 있다.
다만 스택, 힙은 더 이상 실행이 어렵기 때문에 실행 가능 영역에 있는 C 라이브러리를 활용하도록 한다.

여기도 /bin/sh 있는데?

libc 안에 이미 /bin/sh 문자열이 있다…
notion image
notion image
지금까지 이거 넣으려고 별짓을 다했는데;;

system(char* command)

내부적으로 fork 및 execl을 사용해서 쉘에 커맨드를 쳐주는 시스템 콜 wrapper다.
뭐 이거랑 같은 효과라고 할 수 있다.
뭐 이거랑 같은 효과라고 할 수 있다.
그럼 system 의 주소만 알면 인자 때려넣고 call 하면 되는거 아니겠음?

실습

목적은 /bin/sh 넣고 system 호출.
libc 안의 /bin/sh 주소 찾아서 인자로 넣고, system 주소 찾아서 BOF 사용해서 리턴시키면 된다.
인자를 어떻게 넣을까? 실제로 넣은적은 없더라도 그냥 인자가 스택에 있으면 장땡이다.
정상 스택 기준으로 인자는 스택 프레임 바닥으로부터 2칸 밑에 있다 (ebp+8)
main 함수 실행 중 스택 상태
main 함수 실행 중 스택 상태
leave - mov %ebp, %esp
leave - mov %ebp, %esp
leave - pop %ebp
leave - pop %ebp
이렇게 되고 난후 마침내 ret - pop %eip 가 실행될거다.
그런데 ret이 어떤 함수의 주소라면?
함수는 당연히 프롤로그를 실행할거고, 지금 ret이 있는 위치에 ebp가 들어갈거다. (esp는 argc에 가있으니까)
그러면 인자는 어디있다고 간주되겠는가?
ret 이후 스택 프레임 상태
ret 이후 스택 프레임 상태
argv 위치를 인자라고 간주하고, argc 위치를 복귀 주소라고 간주할거다.
따라서, argc 위치에 exit 주소를, argv 위치에 /bin/sh 주소를 넣어주면 되겠다.
이제 공격계획을 짜보자.
main 함수 실행 상태에서, 버퍼에 A를 12개 채우고,
system주소, exit 주소, /bin/sh주소를 넣어주면 되겠다.
먼저 각 주소를 알아내자.
  1. /bin/sh 주소
    1. notion image
      라이브러리에서의 상대적 위치를 알아낸다. 0x001bd0d5
      notion image
      라이브러리 베이스 주소에 오프셋을 더한다. 0xf7c00000+0x001bd0d5 = 0xf7dbd0d5
      너무 정확하게 찾아서 놀랐다
      너무 정확하게 찾아서 놀랐다
      0xf7dbd0d5 가 우리가 찾는 주소다.
  1. system, exit 주소
    1. EZ
      EZ
      각각 0xf7c48170, 0xf7c3a460 이다.
이제 exploiting을 해보자.
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> int main(int argc, char *argv[]) { char buf[12 + sizeof(long) * 3 + 1] = "AAAAAAAAAAAA"; long system = 0xf7c48170; long exit = 0xf7c3a460; long binsh = 0xf7dbd0d5; *((long *)(buf + 12)) = system; *((long *)(buf + 12 + sizeof(long))) = exit; *((long *)(buf + 12 + sizeof(long) * 2)) = binsh; buf[12 + sizeof(long) * 3] = '\0'; execl("./취약.c.out", "취약.c.out", buf, NULL); }
notion image
💡
GDB에서는 잘됐는데… 외않되?
notion image
GDB가 그 사이에 뭐 넣나보다. 그래서 ASLR이 꺼져있음에도 메모리 레이아웃이 달라져서 실패했다.
위에서 계산한 주소들에서 차이를 빼고 다시 시도해봐야겠다.
notion image

Ascii Armor

잘은 모르겠지만 시스템 라이브러리들이 0x00______ 번지에 로드됨으로서 널이 포함되고,
따라서 시스템 라이브러리를 호출 및 인자로 전달하는 BoF 공격(RTL)이 막혔다.
(인자에 널이 들어있어 strcpy 중 끊김)

ASLR

 
ASLR ON 하면, 동적 라이브러리들이 켤 때마다 다른 위치에 로드된다.
ASLR ON 하면, 동적 라이브러리들이 켤 때마다 다른 위치에 로드된다.
그러면 이런 의문이 든다. 왜 프로그램 자체는 항상 같은 곳에 로드됩니까?
→ PIE(Position Independent Executable)이 아니기 때문이다.
컴파일 단계부터 모든 어셈이 정적 주소 기준으로 static하게 쓰였기 때문에, 프로그램을 멋대로 다른 위치에 로드할 수 없다.
아마 segfault나 illegal instruction 오류를 즉시 만나게 될거다.
💡
PIE vs PIC?
둘은 같은 개념이지만, 적용 대상이 조금 다르다.
PI ‘Executable’ → 프로그램 대상이며,
PI ‘Code’ → 라이브러리 대상이다.
정적 주소 임베딩 대신 Offset 기준으로 어셈블리가 쓰여 있다. (자세한건 학습 필요 - 선택)
실제로 clib 같은 shared library들은 현대의 ASLR 개념과 맞물려 PIC로 컴파일되어 있다.
프로그램을 PIE로 컴파일할 수도 있는데, 그럼 아래와 같은 일이 생긴다. (-fPIE -pie)
PIE 옵션을 주고 컴파일을 했더니 - 바이너리가 DYN - PIE가 되었다
PIE 옵션을 주고 컴파일을 했더니 - 바이너리가 DYN - PIE가 되었다
이 상태로 프로그램을 실행하면 프로그램 자체도 하나의 라이브러리로 취급되어 임의의 주소에 로드된다.
(load_elf_binary가 하겠죠?)
notion image
0x615cxxxx 에 로드되었으며, 다른 라이브러리와 마찬가지로 실행할 때 마다 바뀐다.
0x615cxxxx 에 로드되었으며, 다른 라이브러리와 마찬가지로 실행할 때 마다 바뀐다.

Canary

컴파일러 + OS 차원의 방어
여전히 BOF 자체가 막히지는 않았다.
BOF 공격을 하면 대상 버퍼부터 공격 대상 메모리 번지까지 끊김없이 덮어써지게 된다.
이 점을 이용해 그 사이에 미리 랜덤값을 넣어뒀다가 검사할 수 있다.
스택 조작 자체 보다는, 스택 프레임 바로 밑에 있는 복귀 주소의 조작(return address overwrite)을 감지하는게 목적이라고 봐야 맞다.
💡
근데 얘도 exploit 가능 ㅋㅋ
canary 값의 주소는 %gs:0x14 에 있다.
이렇게 넣나보다
이렇게 넣나보다
그러면 사실 한 프로세스 내내 같은 값을 유지하는 것이 아닌가 하는 생각이 들었는데…
실제 스택 프레임마다 모두 동일한 카나리 값을 넣어놓은 것이 아니겠는가?
물론 스택 프레임마다 다른 카나리 값을 넣어두고 비교한다는게 말이 안되는 거긴 하니 이해한다.
그렇게 하려면 카나리 스택이 따로 있어야 할거고, 그마저도 금방 뚫릴거다.
야침차게 만든게 맨날 뚫리는게 좀 웃프다.

Secure Programming

프로그래머가 코드 자체를 안전하게 짠다.
strcpy(buffer,str); gets(buffer); scanf(”%s”, str);
strncpy(buffer, str, sizeof(buffer)-1); buffer[sizeof(buffer)-1] = 0; fgets(buffer, sizeof(buffer) -1, stdin); scanf(”%79s”, str);
strncpystrcpy와 다르게 끝에 널문자를 자동으로 추가해주지 않는다. 따라서 프로그래머가 직접 삽입해야 한다.
char *buffer = "csec\n"; printf(buffer); return 0;
지금은 고정된 문자열이라 큰 상관 없지만, 신경쓰지 못하고 수정할 가능성이 있다.
그러다가 형식 지정자가 들어가면 엉뚱한 곳에서 인자를 읽어 출력해줄 수도 있다.
사용자가 형식 지정자를 삽입할수도 있게 된다면 쓰기 형식 지정자 %n, %hn 으로 바로 BOF 공격이 가능하다.
char *buffer = "csec"; printf("%s\n", buffer); return 0;
그래서 이렇게 작성하는게 안전하다

그런데 나는 이게 좋은 방법이라고 생각 안한다.
보안조치 조치… 근데 프로그래머가 어떻게 매번 여기까지 신경을 쓰나?
설사 보안에 뛰어난 인재가 있더라도, 누가 한 번 놓치면 그게 그 프로그램, 프로덕트의 구멍이며 보안 수준이다.
그만큼 휴먼 에러의 가능성이 있다는 거다. 그래서 시스템으로 강제해야 한다고 생각한다.
예를 들면 컴파일 옵션이 강제된 컴파일러가 있다던지, 빌드 및 배포 자동화에 과정이 추가된다던지…
이렇게 사람이 놓칠 수 있는 부분을 잡아줘야한다. 설마 아직도 당신의 코드가 완벽할거라고 믿는가?

의문점

💡
*을 썼다가 안썼다가, 뭔 차이죠?
jmp *0x08048458 ← 이런 인스트럭션을 쓰기도 하고, jmp 0x08048458 ← 이런 인스트럭션을 쓰기도 한다.
call *%gs:0x10 ← 이런 인스트럭션을 쓰기도 하고, call 0x08048458 ← 이렇게 쓰기도 한다.
무슨 차이가 있는지 궁금할텐데, 먼저 간단하게 얘기하자면 역참조(dereferencing)다. (한글 용어가 맘에 안들긴 한다. 참조의 반댓말도 아닌데 왜 역참조야;)
그 메모리나 레지스터 안에 들어있는 값을 참조해서(꺼내서) 연산하는 행위고, unboxing 같은 개념으로 이해하면 좀 그림이 그려질 것이라고 생각한다.
위에서 언급 인스트럭션들을 각각 1,2,3,4라고 하고 어떤 동작을 하는지 설명해보겠다.
  1. jmp *0x08048458
    1. 메모리 0x08048458번지에 저장된 주소를 읽어 그곳으로 점프한다.
  1. jmp 0x08048458
    1. 0x08048458으로 점프한다
  1. call *%gs:0x10
    1. 세그멘테이션 어드레싱이다.
      gs 레지스터에는 어떤 영역에 대한 GDT Entry가 있다. (TLS)
      CPU가 이놈을 알아서 참조해서 Base Address를 얻는다. 그러면 해당 영역에 Offset이 더해져 원했던 메모리 주소를 얻을 수 있게 된다.
      따라서 TLS 영역의 0x10(=16번지, 5번째 칸의 시작 부분)에 접근하고, 그곳에 저장된 주소를 읽어 호출하게 된다.
  1. call 0x08048458
    1. 0x080848458을 함수로서 바로 호출한다.
      알다시피 call 은 push %eip + jmp addr 이기 때문에 스택 작업 후 해당 주소로 점프된다.
💡
GDT 해석하기
세그멘테이션 어드레싱을 해보자.
과정 미리보기
과정 미리보기
예시 상황
예시 상황
call *%gs:0x10 을 예로 들어보자.
CPU는 eip 기준으로 위 인스트럭션을 읽고 총 48비트를 대상으로 주소를 연산해야한다.
먼저 gs 세그먼트에 접근해보자.
세그먼트 레지스터(셀렉터)는 16비트인데, 이중 13비트는 인덱스, 1은 TI, 2는 RPL이다.
권한 체킹은 위에서 했으니까, 인덱스만 따져보자.
gs에 0x0033(=0000000000110011)이 들어있다고 하면, 이 중 인덱스는 13비트이므로, 0110 = 0x6다.
GDT에서 0x6번째 엔트리를 찾아보자.
GDT Base Address 는 GDTR 상위 32비트에서 읽어온다
GDT Base Address 는 GDTR 상위 32비트에서 읽어온다
GDTR에 0x000000007C00 이 들어있다고 가정하면 0x00007C00 이 Base Addr.
0x00007C00 + (0x6 * 8 =0x30) = 0x00007C30
이제 0x00007C30의 메모리를 읽으면 그것이 GDT 엔트리다.
해당 GDT 엔트리의 내용이 0x10CF9A000000FFFF 이라고 가정해보자.
GDT Entry Structure
GDT Entry Structure
= 00010000 11001111 10011010 00000000 00000000 00000000 11111111 11111111
= 0x10000000
이제 마침내 여기다가 %gs:0x10에서의 offset 0x10을 더하면 목적지다 😥
= 0x10000010
0x10000010에는 0x00145e37이 들어있다고 해보자.
그럼 dereferencing(*)에 의해 메모리를 참조해서 call 하므로, call *%gs:0x10 한 결과 마침내 PC가 0x00145e37로 이동한다.
실제로는 이렇다
실제로는 이렇다
💡
JE는 zero flag를 참조해서 동작하는거겠지?
그렇다. eflags 레지스터의 ZF를 참조해서 동작한다.
또한 JE 인스트럭션의 인자는 점프할 곳의 주소다.
따라서 JE 인스트럭션 직전에 CMP, TEST 등으로 ZF가 의미있게 설정되는 연산을 해줘야 한다.

참고로 얘기하면 대부분의 산술 연산은 ZF를 건드리는 방식으로 동작한다.
따라서 이걸 피하고 싶다면 LEA를 오용(?)해 볼 수 있겠다.
더 자세한 내용은 lea Instruction에서 확인해볼 수 있다.