Buffer overflow와 같은 메모리 관련 취약점을 막기 위해 gcc와 같은 컴파일러에서 제공하는 보호기법이다.
이는 소스코드에서 잠재적인 취약점을 탐지하고 런타임에 추가 검사를 통해 보안 향상을 한다.
버퍼 오버플로우가 감지되면 프로그램을 종료한다. SSP와 비슷한 느낌이지만 동작방식은 아예 다르다.
동작 방식
Fortify의 동작 방식은 buffer overflow 가 발생할 때 감지할 수 있는 특정 C 라이브러리 함수의 향상된 버전을 제공하는 방식으로 작동한다. 다시말해 strcpy, memcpy, sprintf, memset, fprintf 등의 함수를 사용하면 컴파일 과정에서 __strcpy_chk, __memcpy_chk 로 변경한다.
이러한 취약한 함수가 호출되면 fortify는 사용중인 버퍼의 크기를 확인하고 오버런 되지 않도록 한다.
만약 overflow 등이 발생하면 fortify는 추가 손상을 방지하기 위해 즉시 프로그램을 종료한다.
예시
memcpy를 사용하여 코드를 짜면 __memcpy_chk 가 호출이된다. __memcpy_chk 함수 구현체를 보자.
void *
__memcpy_chk (void *dstpp, const void *srcpp, size_t len, size_t dstlen)
{
if (__glibc_unlikely (dstlen < len))
__chk_fail ();
return memcpy (dstpp, srcpp, len);
}
libc_hidden_builtin_def (__memcpy_chk)
기본 memcpy에 비해 인자 하나가 더 추가되었는데 이는 dest buffer 의 크기를 나타낸다. 만약 3번째 인자 len이 dstlen보다 크다면 buffer overflow로 간주하고 __chk_fail 을 일으킨다.
적용 범위의 상세는 다음과 같다.
stack
dest가 stack인 경우 패딩된 버퍼의 크기가 아닌 내가 코드상에서 명시한 크기가 dstlen이 된다.
heap
dest가 heap인 경우 chunk mem의 size가 아닌 내가 요청한 패딩되지 않은 값이 dstlen이 된다.
전역 변수
dest가 전역변수여도 overflow를 감지해낸다.
포인터를 사용하여 calle 함수에서 caller에서 선언된 buffer에 접근할 때
fortify 보호는 컴파일러가 객체 크기를 정확히 추론할 수 있는 경우에만 작동한다. 그래서 caller에서 선언된 변수를 callee 함수에서 접그하는 경우는 컴파일러가 크기를 명확히 알 수 없기 때문에 보호가 적용되지 않는다.
이는 __FORTIFY_SOURCE=3 에서도 해결되지 않았다.
한계
_FORTIFY_SOURCE=2 까지의 경우 __builtin_object_size 를 사용하는데 이는 가리키는 객체의 최대 혹은 최소 크기의 추정치가 컴파일 타임에 상수로 반환된다. 즉 타겟의 예상되는 크기를 컴파일 타임에 반환되어 최적화 여부를 판단하는 것이다. 그러나 다음과 같은 경우에 문제가 생긴다.
#include <string.h>
#include <stdbool.h>
#include <stdlib.h>
char *b;
char buf1[21];
char *__attribute__ ((noinline)) do_set (bool cond)
{
char *buf = buf1;
if (cond)
buf = malloc (42);
memset (buf, 0, 22);
return buf;
}
int main (int argc, char **argv)
{
b = do_set (false);
return 0;
}
해당 코드는 buffer overflow 가 발생함에도 불구하고 fortify가 이를 감지하지 못한다. 왜냐하면 gcc는 해당 객체(buf1)의 추정 최대치를 42로 반환한다. 그렇기 때문에 gcc는 컴파일 시 memset 작업이 안전하다고 가정하여 길이 검증 호출을 추가하지 않는다.
이러한 한계점은 _FORTIFY_SOURCE=3 에서 해결된다.
옵션
0 : Fortify 비활성화
-O2 -U_FORTIFY_SOURCE
1 : 기본 Fortify 보호
-O2 -D_FORTIFY_SOURCE=1
2 : 강화된 Fortify보호
-O2 -D_FORTIFY_SOURCE=2
-O2 는 최적화 옵션인데 _FORTIFY_SOURCE는 컴파일러 최적화 수준에 따라 보호를 제공한다. 그렇기 때문에 기본적으로 최적화가 없으면(-O0)이면 fortify가 비활성화 된다. 구체적인 이유는 fortify는 컴파일러가 객체 크기 정보를 추론하여 이를 기반으로 보호를 추가하는데 -O0 일 경우 객체 크기를 분석하거나 추론하지 않는다.
3 : 새로운 옵션
-O2 -D_FORTIFY_SOURCE=3
gcc 12 이후에 추가된 옵션으로 __builtin_dynamic_object_size라는 새로운 내장형을 사용한다. 이전 __FORTIFY_SOURCE=2 에서 사용된 __builtin_object_size 내장형보다 더 강력하다.
__builtin_object_size 는 프로그램에서 포인터가 가리키는 객체의 최대 혹은 최소 크기의 추정치를 컴파일 타임에 상수로 반환한다. 그러나 __builtin_dynamic_object_size 는 실행 시에 구한 값을 반환할 수 있다.
그래서 2에 비해 더 많은 곳에서 버퍼 오버플로우를 감지한다. 그러나 더 높은 보안 검사를 하기 때문에 성능에 영향을 주거나 바이너리 크기가 커질 수 있다.
__buitin_object_size , __builtin_dynamic_object_size 와 같은 것은 GCC 및 Clang 컴파일러가 제공하는 내부 빌트인 함수를 의미한다.
보호기법 확인하는 법
checksec 으로 확인 가능한 fortify 보호기법 수준은 다음과 같다.
- FORTIFY : fortify 보호기법이 적용되었는지 아닌지. (단계 상관 없이)
- Fortified: Fortify 보호를 적용한 함수의 개수.
- Fortifiable: Fortify로 보호 가능했던 함수의 총 개수.
켜져있는지 꺼져있는지는 확인할 수 있지만 몇 단계인지는 확인할 수 없다.
#include <stdio.h>
int main() {
#ifdef __USE_FORTIFY_LEVEL
printf("Fortify level: %d\\n", __USE_FORTIFY_LEVEL);
#else
printf("Fortify is not enabled.\\n");
#endif
return 0;
}
이런 코드를 사용해서 몇 단계인지 확인은 할 수 있다.
만약 바이너리 밖에 없으면 Fortify 단계를 직접적으로 확인할 방법은 없다.
<틀린 부분이 있다면 비난과 욕설을 해주세요>