서론
매번 format string bug를 할 때마다 어떻게 작동하는지 원리를 제대로 알지 못하고 방식만 익혀 문제를 푸는데만 집중했는데, 최근 문제를 풀면서 정말 어떻게 작동하길래 이렇게 format string bug가 작동하는지 의문이 들었다.
이에 조금 분석을 해보려한다.
source code
무엇보다 단순히 libc 파일 내의 함수라고 생각했는데, 직접 찾아보니 사실은 kernel 내에 존재하는 함수였다는데서 놀랐다.
더불어 architecture마다 해당 함수가 정의되어있는데, 대동소이한 것을 볼 수 있다.
printf.c - arch/x86/boot/printf.c - Linux source code (v6.5.5) - Bootlin
해당 소스파일 내에는 vsprintf, sprintf, printf 함수가 함께 존재하는데,
sprintf 함수와 printf 함수는 결국 vsprintf 함수를 사용하는 것을 볼 수 있다.
sprintf와 printf 함수의 가장 큰 차이는 마지막에 puts 함수를 통해 출력을 해주느냐 마느냐이기에 결국은 vsprintf 함수만 분석하면 되겠다.
고 생각할 수 있겠지만, 다른 부분도 조금 보자.
printf
소스코드는 아래와 같다.
int printf(const char *fmt, ...)
{
char printf_buf[1024];
va_list args;
int printed;
va_start(args, fmt);
printed = vsprintf(printf_buf, fmt, args);
va_end(args);
puts(printf_buf);
return printed;
}
위에서 설명한 것과 같이 일련의 동작 후에 puts 함수를 통해 문자열을 출력하는 것을 볼 수 있다.
vsprintf
위 printf 함수에서 printf_buf, fmt, args를 인자로 vsprintf 함수를 실행한다.
여기서 printf_buf는 말 그대로 printf 함수 내에서 확보한 1024 bytes의 buffer이고
fmt은 출력할 문자열이 될 것이며
args는 va_list가 된다.
여기서 va_list는 일단 그러려니 하고 넘어가자.
아래부터는 vsprintf 함수의 주요 부분을 조금씩 잘라서 분석했다.
우선 str 변수에 buf 값, 즉 printf_buf 변수의 주소를 넣어두고,
fmt 변수, 즉 입력한 값의 끝까지 for문을 통해 일련의 확인을 한다.
이후 마지막에 \0을 통해 끝을 표기한 다음, str-buf, 즉 총 출력할 크기를 return 한다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
for (str = buf; *fmt; ++fmt) {
...
}
*str = '\0';
return str - buf;
}
동적 분석에서도 볼 수 있듯이 이 return 값은 곧 rax가 되며, 문자열의 길이가 왜 rax 값이 되는지 알 수 있다.
이후 for문 내에서 제일 먼저 수행하는 것이 % 문자 여부이다.
이 문자에 따라 format string 포함 여부를 판단하기 때문이다.
만일 %가 아니라면 해당 문자열을 str, 즉 print_buf에 넣고 continue를 통해 그냥 넘어간다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
if (*fmt != '%') {
*str++ = *fmt;
continue;
}
...
}
만일 %가 있다면, %이후에 값을 확인하여 어떻게 출력할지 확인하여 printf_buf 변수에 미리 넣어둔다.
예를 들어 음수 및 양수 출력 (+,-), 공란 추가 (' ') 등이다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
/* process flags */
flags = 0;
repeat:
++fmt; /* this also skips first '%' */
switch (*fmt) {
case '-':
flags |= LEFT;
goto repeat;
case '+':
flags |= PLUS;
goto repeat;
case ' ':
flags |= SPACE;
goto repeat;
case '#':
flags |= SPECIAL;
goto repeat;
case '0':
flags |= ZEROPAD;
goto repeat;
}
...
}
또한 필드의 폭을 확인한다.
예를 들자면 %10d, %-10c와 같이 숫자가 포함되어있다면 이를 확인해서 필드의 폭을 설정해준다.
format string bug에서 %31337c와 같이 쓰면 총 출력되는 문자열의 길이가 31337이 되는 이유이다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
/* get field width */
field_width = -1;
if (isdigit(*fmt))
field_width = skip_atoi(&fmt);
else if (*fmt == '*') {
++fmt;
/* it's the next argument */
field_width = va_arg(args, int);
if (field_width < 0) {
field_width = -field_width;
flags |= LEFT;
}
}
...
}
다음으로 정확도이다.
이는 float 형 자료일때 어떻게 출력할지를 설정한다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
/* get the precision */
precision = -1;
if (*fmt == '.') {
++fmt;
if (isdigit(*fmt))
precision = skip_atoi(&fmt);
else if (*fmt == '*') {
++fmt;
/* it's the next argument */
precision = va_arg(args, int);
}
if (precision < 0)
precision = 0;
}
...
}
컨버전을 설정한다.
h, l, L을 통해 shor / long / long double의 여부를 판단한다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
/* get the conversion qualifier */
qualifier = -1;
if (*fmt == 'h' || *fmt == 'l' || *fmt == 'L') {
qualifier = *fmt;
++fmt;
}
...
}
마지막 부분으로 가장 중요한 부분이다.
base 변수를 10으로 두고, format string인 c, d, s, p 등에 따라 어떻게 출력할지를 지정하고,
마지막에 l, h, flags 값 등에 따라 num 변수를 setting 한다.
여기서 base 변수는 출력 방식이다. (ex. 10진수, 8진수, 16진수)
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
/* default base */
base = 10;
switch (*fmt) {
case 'c':
if (!(flags & LEFT))
while (--field_width > 0)
*str++ = ' ';
*str++ = (unsigned char)va_arg(args, int);
while (--field_width > 0)
*str++ = ' ';
continue;
case 's':
s = va_arg(args, char *);
len = strnlen(s, precision);
if (!(flags & LEFT))
while (len < field_width--)
*str++ = ' ';
for (i = 0; i < len; ++i)
*str++ = *s++;
while (len < field_width--)
*str++ = ' ';
continue;
case 'p':
if (field_width == -1) {
field_width = 2 * sizeof(void *);
flags |= ZEROPAD;
}
str = number(str,
(unsigned long)va_arg(args, void *), 16,
field_width, precision, flags);
continue;
case 'n':
if (qualifier == 'l') {
long *ip = va_arg(args, long *);
*ip = (str - buf);
} else {
int *ip = va_arg(args, int *);
*ip = (str - buf);
}
continue;
case '%':
*str++ = '%';
continue;
/* integer number formats - set up the flags and "break" */
case 'o':
base = 8;
break;
case 'x':
flags |= SMALL;
case 'X':
base = 16;
break;
case 'd':
case 'i':
flags |= SIGN;
case 'u':
break;
default:
*str++ = '%';
if (*fmt)
*str++ = *fmt;
else
--fmt;
continue;
}
if (qualifier == 'l')
num = va_arg(args, unsigned long);
else if (qualifier == 'h') {
num = (unsigned short)va_arg(args, int);
if (flags & SIGN)
num = (short)num;
} else if (flags & SIGN)
num = va_arg(args, int);
else
num = va_arg(args, unsigned int);
...
}
마지막으로 입력된 문자가 숫자라면 이렇게 셋팅된 값들을 토대로 number 함수를 실행하여 진수에 맞게 적절히 변환한 뒤 str 값이 설정된다.
int vsprintf(char *buf, const char *fmt, va_list args)
{
...
str = number(str, num, base, field_width, precision, flags);
...
}
즉, vsprintf는 출력할 값을 처음부터 한글자씩 읽어와서 어떤 기능을 할지 확인한 다음 특정 영역에 값을 써 두는 것을 알 수 있다.
format string bug 주요 format string 분석
이제 우리가 format string bug에서 주로 사용하는 부분만 확인해보자.
%c
결국 각 format string에서 중요한 부분은 switch 내에서 어떻게 작동하는가이다.
보통 얼마의 값을 쓸 것인가에 사용하는 %c의 경우 아래와 같다.
case 'c':
if (!(flags & LEFT))
while (--field_width > 0)
*str++ = ' ';
*str++ = (unsigned char)va_arg(args, int);
while (--field_width > 0)
*str++ = ' ';
continue;
초기에 flags 값과 LEFT 값을 and 연산하여 참이면 field_width 값을 1씩 줄이며 ' ', 즉 공란을 str에 넣고
입력된 값을 str에 넣은 다음
field_width에 따라 우측에 공란을 추가로 넣는다.
여기서 주요 사항은 va_arg 함수인데, 해당 함수의 인자는 출력할 문자열의 주소와 offset을 의미한다.
아래에서 다시 설명할 것이기에 우선은 그냥 그렇구나 하고 넘어가자.
%s
이 또한 크게 다르지 않다.
case 's':
s = va_arg(args, char *);
len = strnlen(s, precision);
if (!(flags & LEFT))
while (len < field_width--)
*str++ = ' ';
for (i = 0; i < len; ++i)
*str++ = *s++;
while (len < field_width--)
*str++ = ' ';
continue;
va_arg를 통해 문자열의 주소를 s 변수에 넣고,
그 길이를 확인하여 len에 넣는 다음,
만일 LEFT flag가 활성화되어있다면 len 만큼 str 변수에 공란을 넣고, s의 길이만큼 str에 넣고,
field_width 만큼 우측에 공란을 넣는다.
%p
마찬가지이다.
case 'p':
if (field_width == -1) {
field_width = 2 * sizeof(void *);
flags |= ZEROPAD;
}
str = number(str,
(unsigned long)va_arg(args, void *), 16,
field_width, precision, flags);
continue;
조금 다른 부분이라면 number 함수를 통해 str에 넣을 값을 설정하는데,
인자로 str, va_arg, 16, field_width, precision, flags를 사용한다.
이 때 number 함수의 리턴 값은 결국 주어진 인자에 맞게 꾸며진 arg 값이 될 것이다.
%n
가장 중요한 n이다.
case 'n':
if (qualifier == 'l') {
long *ip = va_arg(args, long *);
*ip = (str - buf);
} else {
int *ip = va_arg(args, int *);
*ip = (str - buf);
}
continue;
qualifier의 값에 따라 long형인지 int형인지를 확인하고, str - buf 값을 ip에 넣는다.
for문의 시작이 str = buf이고, 루프를 돌면서 str 값이 1씩 늘어나기 때문에 결국 총 문자열의 개수가 된다.
출력 예제를 통한 분석
예를 들어
printf("hello %s, nice %d meet you", "wyv3rn",2);
와 같이 출력한다고 가정하자.
printf 함수를 call 하기 전에 전체 문자열의 주소, %s에 해당하는 wyv3rn의 주소와 %d에 해당하는 2를 스택에 넣어두고 함수를 실행한다.
이는 printf 함수 내부로 들어가면 스택에 쌓이게 된다.
마찬가지로 vsprintf 함수를 call 하기 전에 레지스터에 이 값들을 가지고 실행하게 된다.
vsprintf 함수 내부에서 우선 %를 만나기 전인 "hello " 까지는
if (*fmt != '%') {
*str++ = *fmt;
continue;
}
를 통해 그냥 str 변수에 문자를 하나씩 집어 넣는다.
이후 %를 만나면 그 다음에 오는 값으로 flag와 field_width, precision을 확인한 다음
s를 만나서 switch문으로 들어가게 된다.
case 's':
s = va_arg(args, char *);
len = strnlen(s, precision);
if (!(flags & LEFT))
while (len < field_width--)
*str++ = ' ';
for (i = 0; i < len; ++i)
*str++ = *s++;
while (len < field_width--)
*str++ = ' ';
continue;
va_arg 함수를 통해 args의 주소를 s에 가져오는데, 이는 곧 wyv3rn 문자열의 주소가 된다.
이 문자열의 길이를 확인한 다음, 그 길이만큼 str 변수에 넣는다.
마찬가지로 ", nice "까지는 그냥 str 변수에 넣고,
다시 %d를 만나면
case 'd':
case 'i':
flags |= SIGN;
case 'u':
break;
를 통해 아무것도 하지 않고 switch문을 탈출한 다음,
if (qualifier == 'l')
num = va_arg(args, unsigned long);
else if (qualifier == 'h') {
num = (unsigned short)va_arg(args, int);
if (flags & SIGN)
num = (short)num;
} else if (flags & SIGN)
num = va_arg(args, int);
else
num = va_arg(args, unsigned int);
str = number(str, num, base, field_width, precision, flags);
를 통해 숫자를 적절히 가공하여 str 변수에 넣는다.
그 다음엔 동일하게 남은 문자열인 " meet you"를 str 변수에 넣는다.
마지막으로 끝에 \0을 삽입한 다음 길이를 리턴한다.
*str = '\0';
return str - buf;
format string bug 관점에서의 분석
앞선 예제는 "printf 함수에서 인자가 존재할 때"의 경우이다.
format string bug에서는 인자 없이 format string을 사용하기에 발생하는 문제이다.
가장 중요한 부분은 바로 va_arg 부분이다.
va_arg 함수에서의 리턴 값은 결국 printf 함수에서의 인자의 위치를 나타낸다.
예를 들어 위의 예제에서와 같이
printf("hello %s, nice %d meet you", "wyv3rn",2);
를 실행하려하면 wyv3rn이라는 문자열과 2라는 숫자를 스택에 넣어둔 뒤, printf 함수를 실행하는데,
switch case 's'에서와 같이 제일 처음 실행되는
va_arg(args, char *);
는 wyv3rn 문자열의 주소를 가져와서 s 변수에 넣고, args 변수는 다음 인자인 2를 가리키게 되는 것이다.
그러므로 format string bug에서 주소 확인을 위해 자주 사용되는
printf("%p%p%p%p%p%p");
의 경우에도 마찬가지이다.
switch case 'p'에서도
str = number(str,
(unsigned long)va_arg(args, void *), 16,
field_width, precision, flags);
와 같이 va_arg가 사용되는데, 이 때 최초 args는 printf 함수가 call 되기 전의 rdi 값이 될 것이며,
매 실행마다 void * 크기만큼 이동되기에 rsi, rdx, ..., %rsp, %rsp+8, ...가 출력되는 것이다.
'Tips & theory' 카테고리의 다른 글
apt-get certificate verification failed 해결 방법 (0) | 2023.12.14 |
---|---|
Dynamic Allocator Exploitation - payload (0) | 2023.11.22 |
gef - for kernel debuging (0) | 2023.09.10 |
file structure __lll_lock_wait_private 우회 (0) | 2023.09.06 |
heap safe linking (0) | 2023.08.29 |