Пример программы, уязвимой к переполнению буфера
Пример программы, уязвимой к переполнению буфера
После изучения основ переполнения буфера пришло время рассмотреть пример программы, извлекающей из него пользу, – программы переполнения буфера. В интересах простоты изучения программа должна быть понятной, а каждый шаг ее работы подробно исследован. Программа написана для платформ Windows NT и Linux.
Программа, уязвимая к переполнению буфера
Главная цель – привести в этой секции пример программы, уязвимой к переполнению буфера. Рассмотренная в этой секции программа очень похожа на последний пример, но вместо постоянной строки входных данных она использует ввод данных пользователя. Это позволило загружать в регистр EIP нужные данные.
Исходный текст программы, уязвимой к переполнению буфера
На последующих рисунках, начиная с рис. 8.17, представлена программа, предназначенная для считывания входных данных из файла в локальную переменную, размещенную в области стека. В результате присваивания этой переменной входных данных происходит переполнение буфера. Управляя входными данными программы, появляется идеальная возможность изучить возможности использования переполнения буфера. В программе вызывается специально написанная для примера функция bof(), которая открывает файл «badfile», считывает из него входные данные программы размером 1024 байта, записывает их в восьмибайтовый буфер и закрывает файл. При записи данных в буфер происходят переполнение буфера и порча данных стека, а по завершении функции bof() в регистр EIP загружается значение из файла «bad-file». Исследуем работу этой программы в Linux и Windows, приводя для обеих платформ соответствующие примеры.
Рис. 8.17. Пример программы, уязвимой к переполнению буфера
Дизассемблерование
На рисунке 8.18 представлен дизассемблерный вид функции bof(). Дизассемблерный вид всей программы на рисунке не показан, поскольку она аналогична предыдущей программе и отличается от нее только функцией bof(). При большом размере файла «badfile» во время работы функции fread() произойдет переполнение буфера, а команда ret функции bof() загрузит в регистр EIP величину из входных данных.
Дамп стека после переполнения
Главное предназначение этой программы заключается в анализе уязвимостей переполнения буфера, поэтому на рис. 8.19 показан дамп стека после выполнения функции fread(). Для примера был создан файл «badfile» с двадцатью символами «Л». После выполнения функции fread() область стека изменена так же, как и в предыдущей программе, но дополнительно появилась возможность управлять записью данных в буфер с помощью файла «badfile». Запомним, что в функции определена дополнительная переменная стека – указатель дескриптора файла (дескриптор файла – уникальный идентификатор, присваиваемый системой Windows файлу в момент его открытия или создания и существующий до момента его закрытия), которая размещена в старших адресах памяти стека сразу за областью буфера.
Рис. 8.19. Дамп стека после выполнения функции fread()
Программа переполнения буфера
После ознакомления с примером программы, уязвимой к переполнению буфера при чтении файла «badfile», пришло время познакомиться с программой, извлекающей из этого пользу, – программой переполнения буфера. Программа переполнения буфера написана на ANSI C, поэтому она может быть откомпилирована любым компилятором ANSI C. Для приведенных в книге примеров использованы компиляторы Visual C++ for Windows NT и GCC for Linux.
Вначале рассмотрен более простой случай – программа переполнения буфера для Linux, а затем для Windows NT и отмечены различия используемых способов переполнения буфера для других платформ.
Основные принципы построения программ переполнения буфераВ секции подробно описаны принципы построения работоспособной программы переполнения буфера для различных платформ. В предыдущем примере было показано, каким образом переполнение буфера используется для контроля содержимого регистра EIP. Теперь нужно разобраться, как этим можно воспользоваться вообще и для управления компьютером в частности.
Контролируя содержимое регистра EIP, можно выполнить нужный программный код. Обычно это достигается путем прямого или косвенного указания на специально написанный для этих целей программный код – программный код полезной добавки, или payload-код. Программный код полезной добавки описываемой программы переполнения буфера очень прост. Он только демонстрирует возможность осуществления задуманного. Более изощренные примеры программного кода полезной нагрузки будут рассмотрены позднее.
В основе современных программ переполнения буфера лежат несколько идей, но будут рассмотрены только некоторые из них, применимые к большинству типов рассматриваемых программ.
Создание программ переполнения буфера подразумевает несколько этапов. Во-первых, нужно получить доступ к буферу данных, то есть найти способ размещения в нем данных. Во-вторых, для того чтобы выполнить нужный код, следует найти способ контроля содержимого регистра EIP. Таких способов несколько. И наконец, в-третьих, нужен программный код полезной нагрузки, выполняющий возложенные на него функции.
Структура программы переполнения буфераПервый этап разработки программы переполнения буфера заключается в поиске способа переполнения буфера. Обычно это несложная задача, решаемая автоматизированными сетевыми средствами записи в буфер или записью в файл нужных данных, которые позже прочитает программа, уязвимая к переполнению буфера. Но иногда не все так просто.
Загрузчики и программный код полезной нагрузки. В военном деле широко используются два связанных понятия: средства доставки и полезный груз. Аналогичные понятия применимы и для переполнения буфера. Говоря о переполнении буфера, подразумевают наличие средства доставки – загрузчика (injection vector) и полезного груза – программного кода полезной нагрузки. Загрузчик – выполнимый программный код, который позволяет управлять указателем на текущую команду удаленной машины. Это код целиком определяется компьютером, на котором он будет выполняться, и преследуемыми целями. Главная задача загрузчика заключается в том, что он должен заставить выполниться программный код полезной нагрузки. Программный код полезной нагрузки подобен вирусу: он должен работать везде, в любое время и независимо от того, как он попал на удаленную машину. Если программный код полезной нагрузки не удовлетворяет перечисленным требованиям, то он неработоспособен. Рассмотрим условия его создания.
Условия работоспособности программного кода полезной нагрузки. Проще всего загрузчик и программный код полезной нагрузки разместить в одном стеке, но так обычно не делается. При использовании стека для хранения загрузчика и программного кода полезной нагрузки следует позаботиться об их взаимодействии и учесть ограничения на допустимый размер программного кода полезной нагрузки. Если программный код полезной нагрузки загружается в память раньше загрузчика, то следует убедиться, что они не конфликтуют друг с другом. Если программы перекрываются, то в программном коде полезной нагрузки следует предусмотреть команду перехода, которая позволяет обойти код загрузчика и продолжить выполнение программного кода полезной нагрузки. Если вопросы их взаимодействия трудноразрешимы, то рекомендуется размещать программный код полезной нагрузки отдельно от загрузчика.
Все программы вводят данные пользователя и хранят их где-нибудь. Любой буфер в программе является кандидатом на хранение программного кода полезной нагрузки. Вопрос только в том, как заставить процессор выполнить его.
Чаще всего для размещения программного кода полезной нагрузки используются:
• файлы на диске, которые загружаются в память;
• доступные локальному пользователю переменные окружения;
• передаваемые через Web-запрос общие переменные окружения;
• доступные пользователю поля сетевого протокола.
После размещения программного кода полезной нагрузки в памяти компьютера остается правильно загрузить в регистр EIP-адрес кода. При размещении программного кода полезной нагрузки не в области стека появляется ряд преимуществ, и ранее невозможное становится возможным. Например, сразу исчезает ограничение на размер кода. До сих пор для установления контроля над компьютером используется ошибка занижения или завышения на единицу числа подсчитываемых объектов (off-by-one error).
Способы передачи управления программному коду полезной нагрузкиВ последующих секциях рассматриваются способы передачи управления программному коду полезной нагрузки. Особое внимание уделяется поиску не зависимых от платформы решений и способам подмены сохраненного в стеке содержимого регистра EIP, которые позволяют выполнить нужный код. Для этого мало знать адрес размещения программного кода полезной нагрузки в памяти.
Явный переход. Если программе переполнения буфера известен адрес программного кода полезной нагрузки, то можно передать ему управление при помощи явного перехода (direct jump). Программный код полезной нагрузки может быть загружен в область стека. Несмотря на то что адрес стека определить нетрудно, при явном переходе возможны ошибки. Во-первых, адрес стека может оказаться нулевым, поэтому может потребоваться размещение в памяти программного кода полезной нагрузки до загрузчика, сокращая доступную коду память. Во-вторых, адрес размещения в памяти программного кода полезной нагрузки может меняться, поэтому нужно учитывать возможные изменения адреса перехода. Способ явного перехода прост для использования. К тому же существуют хитроумные способы, позволяющие упростить определение адреса (см. секцию «Последовательность команд NOP»). В большинстве случаев в операционной системе UNIX адрес стека ненулевой, поэтому для нее описываемый способ подходит. И наконец, если программный код полезной нагрузки размещен вне стека, то способ явного перехода вне конкуренции.
Неявный переход. Регистр ESP указывает на текущее положение в стеке. Способ неявного перехода(blind return) основан натом, что любая команда ret, выполнив так называемое выталкивание данных из стека, загрузит в регистр EIP значение из области, на которую указывает регистр ESP. Существенно то, что команда ret загрузит в регистр EIP значение с вершины стека, которое будет проинтерпретировано как адрес программного кода. Если атакующий сможет подменить сохраненное в стеке содержимое регистра EIP на адрес своей программы, то по команде ret ей будет передано управление.
В ряде способов передачи управления программному коду полезной нагрузки используются регистры процессора для указания на область данных в стеке. Регистр EIP программно недоступен, поэтому нельзя подмененный указатель текущей команды непосредственно загрузить в регистр EIP и воспользоваться им для передачи управления в программе, как это показано на рис. 8.20. Для загрузки указателя текущей команды в регистр EIP указатель текущей команды должен указывать на реальную команду, как это показано на рис. 8.21.
Скрытый переход. Если хранимое на вершине стека значение не является адресом атакуемого буфера, то для передачи управления программному коду полезной нагрузки можно воспользоваться способом скрытого перехода (pop return). Способ скрытого перехода позволяет загрузить в регистр EIP нужный адрес при помощи последовательности команд pop, завершающейся командой ret, как это показано на рис. 8.22. Последовательность команд pop выталкивает из стека несколько значений до тех пор, пока не придет очередь нужного адреса, который и загружается командой ret в регистр EIP. Способ целесообразно использовать, если искомый адрес находится недалеко от вершины стека. Насколько известно, способ скрытого перехода использован в общедоступной программе переполнения буфера информационного сервера Интернет IIS.
– pop EAX 58
– pop EBX 5B
– pop ECX 59
– pop EDX 5A
– pop EBP 5D
– pop ESI 5E
– pop EDI 5F
– ret C3Переход по содержимому регистра. Способ перехода по содержимому регистра (call register) применяется, если в регистре содержится адрес необходимого программного кода полезной нагрузки. В этом случае в регистр EIP загружается указатель на команду при выполнении команды call EDX, call EDI или ee эквивалента (в зависимости от регистра, в который загружен указатель на программу).
– call EAX FF D0
– call EBX FF D3
– call ECX FF D1
– call EDX FF D2
– call ESI FF D6
– call EDI FF D7
– call ESP FF D4При просмотре памяти процесса из библиотеки KERNEL32. DLL были найдены следующие подходящие пары шестнадцатеричных байтов:
77F1A2F7 FF D0 call EAX
77F76231 FF D0 call EAX
7FFD29A7 FF D0 call EAX ; a whole block of this pattern exists
7FFD2DE3 FF E6 jmp ESI ; a whole block of this pattern exists
7FFD2E27 FF E0 jmp EAX ; a whole block of this pattern exists
77F3D793 FF D1 call ECX
77F7CEA7 FF D1 call ECX
77F94510 FF D1 call ECX
77F1B424 FF D3 call EBX
77F1B443 FF D3 call EBX
77F1B497 FF D3 call EBX
77F3D8F3 FF D3 call EBX
77F63D01 FF D3 call EBX
77F9B14F FF D4 call ESP
77F020B0 FF D6 call ESI
77F020D5 FF D6 call ESI
77F02102 FF D6 call ESI
77F27CAD FF D6 call ESI
77F27CC2 FF D6 call ESI
77F27CDB FF D6 call ESI
77F01089 FF D7 call EDI
77F01129 FF D7 call EDI
77F01135 FF D7 call EDIЭти пары шестнадцатеричных байтов могут быть использованы практически в любой программе. Но поскольку найденные пары шестнадцатеричных байтов – часть интерфейса ядра динамически подключаемой библиотеки DLL, то обычно они находятся по фиксированным адресам памяти, которые можно жестко запрограммировать. Имейте в виду, что в различных версиях Windows и, возможно, версиях служебных пакетов Service Pack они могут отличаться. Переход по только что записанному в стек адресу. Способ перехода по только что записанному в стек адресу (push return) слегка отличается от предыдущего, хотя и в нем используется значение, сохраненное в регистре. Различие состоит в использовании вместо команды ret команды call. Если известно, что адрес перехода загружен в регистр EAX, EBX, ECX, EDX, EBP, ESI или EDI, но команду call найти не удается, то попробуйте найти в двоичном коде пару команд push <регистр> и ret.
– push EAX 50
– push EBX 53
– push ECX 51
– push EDX 52
– push EBP 55
– push ESI 56
– push EDI 57
– ret C3В динамически подключаемой библиотеке Kernel32.DLL содержатся следующие подходящие пары шестнадцатеричных байтов:
77F3FD18 push EDI
77F3FD19 ret
(?)
77F8E3A8 push ESP
77F8E3A9 retПрограмма поиска точек перехода Findjmp. На рисунке 8.23 представлена небольшая программа, которая сканирует двоичный код динамически подключаемой библиотеки. Входными параметрами программы являются имя динамически подключаемой библиотеки и название регистра из командной строки. Программа ищет характерные для поддерживаемых способов комбинации шестнадцатеричных цифр в размещенном в памяти двоичном коде заданной динамически подключаемой библиотеки. Она поддерживает способы передачи управления по только что записанному в стек адресу (push return), по содержимому регистра (call register) и явный переход по содержимому регистра (jump register).
Программа ищет в динамически подключаемой библиотеке команды перехода, которые можно использовать в своих целях. Вполне вероятно, что для экспериментов с переполнением буфера потребуется подходящее место для передачи управления специальному коду, адрес которого загружен в какой-либо регистр. Программа подскажет потенциальное место загрузки в регистр EIP адреса нужной программы.
Программа легко адаптируется для поиска других способов перехода или образцов программного кода в динамически подключаемой библиотеке DLL. В настоящее время программа поддерживает поиск следующих команд:
1) jmp reg;
2) call reg;
3) push reg / ret.
Всех их объединяет общий результат: в регистр EIP загружается содержимое регистра reg. Программа также распознает следующие регистры:
• EAX;
• EBX;
• ECX;
• EDX;
• ESI;
• EDI;
• ESP;
• EBP.
Программа компилируется как консольное приложение на любой платформе, поддерживающей интерфейс 32-разрядных Windows-приложений. Приложение может быть найдено в разделе сайта издательства www.syngress.com/solutions, посвященном книге.
Подпрограмма usage() выводит в стандартное устройство вывода (консоль или принтер) краткую инструкцию по использованию программы.void usage()
{
printf(“FindJmp usage findjmp DLL reg Ex: findjmp
KERNEL32.DLL ESP ”);
exit (0);
}
/*The findjmp function is the workhorse. It loads the
requested dll, and searches for specific patterns for jmp
reg, push reg ret, and call reg.*/
void findjmp(char *dll,char *reg)
{
/* patterns for jmp ops */
BYTE jmppat[8][2]= {{0xFF,0xE0},{0xFF,0xE3},{0xFF,0xE1},
{0xFF,0xE2},{0xFF,0xE6},{0xFF,0xE7},
{0xFF,0xE4},{0xFF,0xE5}};
/* patterns for call ops */
BYTE callpat[8][2]= {{0xFF,0xD0},{0xFF,0xD3},{0xFF,0xD1},
{0xFF,0xD2},{0xFF,0xD6},{0xFF,0xD7},
{0xFF,0xD4},{0xFF,0xD5}};
/* patterns for pushret ops */
BYTE pushretpat[8][2]= {{0x50,0xC3},{0x53,0xC3},{0x51,0xC3},
{0x52,0xC3},{0x56,0xC3},{0x57,0xC3},
{0x54,0xC3},{0x55,0xC3}};
/*base pointer for the loaded DLL*/
HMODULE loadedDLL;
/*current position within the DLL */
BYTE *curpos;
/* decimal representation of passed register */
DWORD regnum=GetRegNum(reg);
/*accumulator for addresses*/
DWORD numaddr=0;
/*check if register is useable*/
if(regnum == -1)
{
/*it didn’t load, time to bail*/
printf(“There was a problem understanding the
register. ”
“Please check that it is a correct IA32 register
name ”
“Currently supported are: ”
“EAX, EBX, ECX, EDX, ESI, EDI, ESP, EBP ”
);
exit(-1);
}
loadedDLL=LoadLibraryA(dll);
/* check if DLL loaded correctly*/
if(loadedDLL == NULL)
{
/*it didn’t load, time to bail*/
printf(“There was a problem Loading the requested
DLL. ”
“Please check that it is in your path and readable ” );
exit(-1);
}
else
{
/*we loaded the dll correctly, time to scan it*/
printf(“Scanning %s for code useable with the %s
register ”,
dll,reg);
/*set curpos at start of DLL*/
curpos=(BYTE*)loadedDLL;
__try
{
while(1)
{
/*check for jmp match*/
if(!memcmp(curpos,jmppat[regnum],2))
{
/* we have a jmp match */
printf(“0x%X jmp %s ”,curpos,reg);
numaddr++;
}
/*check for call match*/
else if(!memcmp(curpos,callpat[regnum],2))
{
/* we have a call match */
printf(“0x%X call %s ”,curpos,reg);
numaddr++;
}
/*check for push/ret match*/
else if(!memcmp(curpos,pushretpat[regnum],2))
{
/* we have a pushret match */
printf(“0x%X push %s –“
“ ret ”,curpos,reg);
numaddr++;
}
curpos++;
}
}
__except(1)
{
printf(“Finished Scanning %s for code
useable with”
“ the %s register ”,dll,reg);
printf(“ Found %d usable addresses ” ,numaddr);
}
}
}
DWORD GetRegNum(char *reg)
{
DWORD ret=-1;
if(!stricmp(reg,“EAX”))
{
ret=0;
}
else if(!stricmp(reg,“EBX”))
{
ret=1;
}
else if(!stricmp(reg,“ECX”))
{
ret=2;
}
else if(!stricmp(reg,“EDX”))
{
ret=3;
}
else if(!stricmp(reg,“ESI”))
{
ret=4;
}
else if(!stricmp(reg,“EDI”))
{
ret=5;
}
else if(!stricmp(reg,“ESP”))
{
ret=6;
}
else if(!stricmp(reg,“EBP”))
{
ret=7;
}
/*return our decimal register number*/
return ret;
}Смещение. Термин смещение (offset) в основном относится к переполнению буфера на локальной машине. Поскольку на многопользовательских машинах традиционно установлена операционная система Unix, то замечено, что при рассмотрении переполнения буфера термин «смещение» в системе Unix используется гораздо чаще, чем в какой-либо другой. Злонамеренный пользователь машины UNIX всегда располагает какими-то учетными записями пользователя и, как правило, обычно стремится приобрести права суперпользователя root. У пользователя Unix всегда имеется возможность откомпилировать любую программу, в том числе и программу переполнения буфера. На локальной машине программа переполнения буфера иногда вычисляет базовый адрес собственного стека, предполагая, что он совпадает с базовым адресом атакованной программы. В результате у злоумышленника появляется удобная для него возможность указать в команде явного перехода (direct jump) смещение относительно вычисленного базового адреса. Если все сделано правильно, то величина «базовый адрес+смещение» (base+offset) в коде злоумышленника будет указывать на код жертвы.
Данный текст является ознакомительным фрагментом.