Dober'man
09.07.2008, 06:20
Нарыл статью Криса о защите программ от переполнения буфера.
Мне понравилась...
введение
Ошибки переполнения вездесущи — это факт. Буквально каждые несколько дней обнаруживается новая дыра, а сколько дыр остаются необнаруженными — приходится только гадать. Как с ними борются? Арсенал имеющихся средств довольно разнообразен и простирается от аппаратных защит типа NX/XD битов , до статических анализаторов наподобие Spilnt.
В последнее время в обиход вошел термин "secure programming" и вышло множество книг по безопасности, настоятельно рекомендующих использовать динамические средства защиты типа Stack-Guard, внедряющие в компилируемую программу дополнительный код, проверяющий целостность адреса возврата перед выходом из функции и предпринимающий другие действия, затрудняющие атаку.
Расплатой за "безопасность" становится снижение производительности (впрочем, довольно незначительное) и необходимость перекомпиляции всего кода. Но это только внешняя сторона проблемы. Понадеявшись на широко разрекламированные защитные средства, разработчики расслабляются и… начинают строчить небрежный код, который Stack-Guard (Stack-Shield/Pro-Police) все равно "исправит". Но что именно он правит? Давайте задвинем рекламу в сторону и посмотрим на защиту глазами хакера, который ломиться не в дверь (там замок), и не в окно (там — сигнализация), а проникает через никем не охраняемую вентиляционную/канализационную трубу или даже дымоход.
Все защитные механизмы, имеющиеся на рынке, спроектированы так, что дрожь берет. Сразу видно, что их создатели никогда не атаковали чужие системы, не писали shell-код и даже не общались с теми, кто всем этим занимается. Защита не только не останавливает атакующего, но в некоторых случаях даже упрощает атаку!
типы переполнения и типы защит
Существует множество типов ошибок переполнения, подробно рассмотренных в статье "ошибки переполнения буфера извне и изнутри как обобщенный опыт реальных атак"). Это: переполнение кучи (работающее как оператор POKE), целочисленное переполнение, ошибки форматированного вывода (PEEK и POKE в одном лице) и переполнение локальных стековых буферов.
Стековое переполнение — не только не единственное, но даже не самое популярное. Оператор new языка Си++ размещает переменные в динамической памяти, поэтому, актуальность атак на кучу все растет, а к стеку интерес снижается. Ложка — хороша к обеду. После драку кулаками не машут. Защитники стека явно опоздали и теперь подтасовывают факты и разводят рекламу. Вот цитата из документации на Stack-Guard: "…emits programs hardened against "stack smashing" attacks. Stack smashing attacks are the most common form of penetration attack. Programs that have been compiled with StackGuard are largely immune to stack smashing attack" ("Stack-Guard закаляет программы против срыва стека – наиболее популярного типа удаленных атак. Программы, откомпилированные со Stack-Guard'ом приобретают крепкий иммунитет против этого"). На самом деле, Stack-Guard всего затрудняет подмену адреса возврата, то есть противодействует подклассу стековых атак, причем, противодействует весьма неумело. Тоже самое можно сказать и про остальные защиты, устанавливая которые мы не должны забывать, что они сражаются лишь с определенным типом атак, а на остальные просто не обращают внимания.
Поскольку, из рекламных проспектов (по недоразумению называемых "технической документацией") ничего конкретного выяснить невозможно, используем дизассемблер, достоверно показывающий, что делает та или иначе защита и чем она реально занимается.
stack-guard
Первым, кто бросил вызов переполняющимся буферам, был Stack-Guard, представляющий собой заплатку для компиляторов gcc и egs, распространяемую по лицензии GPL. Раньше его было можно скачать с www.cse.ogi.edu/DISC/projects/immunix/StackGuard или immunix.org, но сейчас эти ссылки мертвы, а проект заброшен. Исходный код сохранился только у "коллекционеров", как например: www.packetstormsecurity.org/UNIX/utilities/stackguard.
Возьмем следующую программу, с умышленно допущенной ошибкой переполнения, и посмотрим, сможет ли Stack-Guard ее защитить.
// дочерняя функция
f(char *msg)
{
// объявляем локальные переменные
int a; char buf[0x66];
// копируем аргумент в буфер без контроля длины,
// что на определенном этапе приводит к его переполнению
a = *strcpy(buf, msg);
// выходим из функции
return a;
}
// материнская функция
int main(int argv, char **argc)
{
int x; x = f(argc[1]);
}
Листинг 1 демонстрационная программа с переполняющийся буфером, которую мы будем защищать
Откомпилируем файл компилятором gcc с настойками по умолчанию (то есть без оптимизации) и загрузим полученный elf в дизассемблер, чтобы посмотреть как выглядит стандартный пролог/эпилог функции f().
function_prologue:
push ebp ; // сохраняем старый указатель карда
mov ebp, esp ; // открываем новый кадр стека
sub esp, 98h ; // резервируем место под локальные переменные
; // тело программы
mov eax, [ebp+arg_0] ; // копируем аргумент в регистр eax
mov [esp+98h+var_94], eax ; // кладем eax в стек
; // (выглядит как засылка eax в лок. переменную
; // но в действительности это такая передача
; // аргументов, необычно но компилятору удобно)
lea eax, [ebp+var_88] ; // получаем указатель на лок. переменную var_88
mov [esp+98h+var_98], eax ; // кладем его в стек
call _strcpy ; // вызываем _strcpy(&arg_0[0], &var_88[0])
movsx eax, byte ptr [eax] ; // eax = *((signed char*) eax);
mov [ebp+var_C], eax ; // копируем eax в локальную переменную var_C
mov eax, [ebp+var_C] ; // копируем содержимое var_C в eax
function_epilogue:
leave ; // mov esp, ebp/pop ebp
retn ; // выходим в материнскую функцию
Листинг 2 дизассемблерный листинг исходной функции f с мыщъх'иными комментариями
Содержимое стека на момент вызова f() представляет конгломерат локальных переменных и служебных данных. На вершине стека лежит буфер, под ним располагается целочисленная переменная "a" (на самом деле, порядок размещения переменных не стандартизован и целиком зависит от воли компилятора, то есть может быть любым). За локальными переменными следует сохраненный регистр указателя карда стека (в x86 процессорах его роль обычно играет EBP), а за ним — адрес возврата и аргументы, переданные функции. Короче говоря, все это выглядит так:
[ buf ] ; переполняющийся буфер
[ a ] ; прочие локальные переменные
[ ebp ] ; сохраненный указатель кадра
[ retaddr ] ; адрес возврата в материнскую функцию
[ arg 1 ] ; аргументы, переданные функции
[ --------- ] ; \
[ --------- ] ; +- кадр стека материнской функции
[ --------- ] ; /
Листинг 3 состояние стека на момент вызова функции f
Переполняющийся буфер может воздействовать на следующие объекты: а) локальные переменные, расположенные ниже его; б) сохраненный указатель карда стека; в) адрес возврата; г) аргументы, переданные функции; д) на кадр материнской функции. Все эти атаки подробно описаны в статье "ошибки переполнения буфера извне и изнутри как обобщенный опыт реальных атак", поэтому не будем повторяться, а лучше пропустим программу через StackGuard 1.0 и посмотрим, что это даст.
function_prologue:
push 000AFF0Dh ; // забрасываем canary word на стек
; // (следовало это делать после сохранение ebp)
push ebp ; // сохраняем старый указатель карда в стеке
mov ebp, esp ; // открываем новый кадр
sub esp, 98h ; // резервируем место под локальные переменные
; // тело функции
; // (точно такое же, как и в прошлый раз)
function_epilogue:
leave ; // закрываем кадр стека
cmp esp,AFF0Dh ; // проверяем целостность canary word
jne canary_changed ; // если canary изменено, прыгаем на canary_changed
add esp,4 ; // удаляем canary из стека
ret ; // возвращаемся в материнскую процедуру
canary_changed: ; // завершаем выполнение программы
call __canary_death_handler
jmp . ; // если завершить не удалось — зациклившемся
Листинг 4 дизассемблерный листинг функции f(), защищенной Stack-Guard'ом (добавленные защитой строки выделены жирным шрифтом)
[ buf ]
[ a ]
[ ebp ]
[ 000aff0dh ]
[ retaddr ]
[ arg 1 ]
[ --------- ]
[ --------- ]
[ --------- ]
Листинг 5 состояние стека функции f() на момент завершения выполнения пролога и начала выполнения ее тела
Мне понравилась...
введение
Ошибки переполнения вездесущи — это факт. Буквально каждые несколько дней обнаруживается новая дыра, а сколько дыр остаются необнаруженными — приходится только гадать. Как с ними борются? Арсенал имеющихся средств довольно разнообразен и простирается от аппаратных защит типа NX/XD битов , до статических анализаторов наподобие Spilnt.
В последнее время в обиход вошел термин "secure programming" и вышло множество книг по безопасности, настоятельно рекомендующих использовать динамические средства защиты типа Stack-Guard, внедряющие в компилируемую программу дополнительный код, проверяющий целостность адреса возврата перед выходом из функции и предпринимающий другие действия, затрудняющие атаку.
Расплатой за "безопасность" становится снижение производительности (впрочем, довольно незначительное) и необходимость перекомпиляции всего кода. Но это только внешняя сторона проблемы. Понадеявшись на широко разрекламированные защитные средства, разработчики расслабляются и… начинают строчить небрежный код, который Stack-Guard (Stack-Shield/Pro-Police) все равно "исправит". Но что именно он правит? Давайте задвинем рекламу в сторону и посмотрим на защиту глазами хакера, который ломиться не в дверь (там замок), и не в окно (там — сигнализация), а проникает через никем не охраняемую вентиляционную/канализационную трубу или даже дымоход.
Все защитные механизмы, имеющиеся на рынке, спроектированы так, что дрожь берет. Сразу видно, что их создатели никогда не атаковали чужие системы, не писали shell-код и даже не общались с теми, кто всем этим занимается. Защита не только не останавливает атакующего, но в некоторых случаях даже упрощает атаку!
типы переполнения и типы защит
Существует множество типов ошибок переполнения, подробно рассмотренных в статье "ошибки переполнения буфера извне и изнутри как обобщенный опыт реальных атак"). Это: переполнение кучи (работающее как оператор POKE), целочисленное переполнение, ошибки форматированного вывода (PEEK и POKE в одном лице) и переполнение локальных стековых буферов.
Стековое переполнение — не только не единственное, но даже не самое популярное. Оператор new языка Си++ размещает переменные в динамической памяти, поэтому, актуальность атак на кучу все растет, а к стеку интерес снижается. Ложка — хороша к обеду. После драку кулаками не машут. Защитники стека явно опоздали и теперь подтасовывают факты и разводят рекламу. Вот цитата из документации на Stack-Guard: "…emits programs hardened against "stack smashing" attacks. Stack smashing attacks are the most common form of penetration attack. Programs that have been compiled with StackGuard are largely immune to stack smashing attack" ("Stack-Guard закаляет программы против срыва стека – наиболее популярного типа удаленных атак. Программы, откомпилированные со Stack-Guard'ом приобретают крепкий иммунитет против этого"). На самом деле, Stack-Guard всего затрудняет подмену адреса возврата, то есть противодействует подклассу стековых атак, причем, противодействует весьма неумело. Тоже самое можно сказать и про остальные защиты, устанавливая которые мы не должны забывать, что они сражаются лишь с определенным типом атак, а на остальные просто не обращают внимания.
Поскольку, из рекламных проспектов (по недоразумению называемых "технической документацией") ничего конкретного выяснить невозможно, используем дизассемблер, достоверно показывающий, что делает та или иначе защита и чем она реально занимается.
stack-guard
Первым, кто бросил вызов переполняющимся буферам, был Stack-Guard, представляющий собой заплатку для компиляторов gcc и egs, распространяемую по лицензии GPL. Раньше его было можно скачать с www.cse.ogi.edu/DISC/projects/immunix/StackGuard или immunix.org, но сейчас эти ссылки мертвы, а проект заброшен. Исходный код сохранился только у "коллекционеров", как например: www.packetstormsecurity.org/UNIX/utilities/stackguard.
Возьмем следующую программу, с умышленно допущенной ошибкой переполнения, и посмотрим, сможет ли Stack-Guard ее защитить.
// дочерняя функция
f(char *msg)
{
// объявляем локальные переменные
int a; char buf[0x66];
// копируем аргумент в буфер без контроля длины,
// что на определенном этапе приводит к его переполнению
a = *strcpy(buf, msg);
// выходим из функции
return a;
}
// материнская функция
int main(int argv, char **argc)
{
int x; x = f(argc[1]);
}
Листинг 1 демонстрационная программа с переполняющийся буфером, которую мы будем защищать
Откомпилируем файл компилятором gcc с настойками по умолчанию (то есть без оптимизации) и загрузим полученный elf в дизассемблер, чтобы посмотреть как выглядит стандартный пролог/эпилог функции f().
function_prologue:
push ebp ; // сохраняем старый указатель карда
mov ebp, esp ; // открываем новый кадр стека
sub esp, 98h ; // резервируем место под локальные переменные
; // тело программы
mov eax, [ebp+arg_0] ; // копируем аргумент в регистр eax
mov [esp+98h+var_94], eax ; // кладем eax в стек
; // (выглядит как засылка eax в лок. переменную
; // но в действительности это такая передача
; // аргументов, необычно но компилятору удобно)
lea eax, [ebp+var_88] ; // получаем указатель на лок. переменную var_88
mov [esp+98h+var_98], eax ; // кладем его в стек
call _strcpy ; // вызываем _strcpy(&arg_0[0], &var_88[0])
movsx eax, byte ptr [eax] ; // eax = *((signed char*) eax);
mov [ebp+var_C], eax ; // копируем eax в локальную переменную var_C
mov eax, [ebp+var_C] ; // копируем содержимое var_C в eax
function_epilogue:
leave ; // mov esp, ebp/pop ebp
retn ; // выходим в материнскую функцию
Листинг 2 дизассемблерный листинг исходной функции f с мыщъх'иными комментариями
Содержимое стека на момент вызова f() представляет конгломерат локальных переменных и служебных данных. На вершине стека лежит буфер, под ним располагается целочисленная переменная "a" (на самом деле, порядок размещения переменных не стандартизован и целиком зависит от воли компилятора, то есть может быть любым). За локальными переменными следует сохраненный регистр указателя карда стека (в x86 процессорах его роль обычно играет EBP), а за ним — адрес возврата и аргументы, переданные функции. Короче говоря, все это выглядит так:
[ buf ] ; переполняющийся буфер
[ a ] ; прочие локальные переменные
[ ebp ] ; сохраненный указатель кадра
[ retaddr ] ; адрес возврата в материнскую функцию
[ arg 1 ] ; аргументы, переданные функции
[ --------- ] ; \
[ --------- ] ; +- кадр стека материнской функции
[ --------- ] ; /
Листинг 3 состояние стека на момент вызова функции f
Переполняющийся буфер может воздействовать на следующие объекты: а) локальные переменные, расположенные ниже его; б) сохраненный указатель карда стека; в) адрес возврата; г) аргументы, переданные функции; д) на кадр материнской функции. Все эти атаки подробно описаны в статье "ошибки переполнения буфера извне и изнутри как обобщенный опыт реальных атак", поэтому не будем повторяться, а лучше пропустим программу через StackGuard 1.0 и посмотрим, что это даст.
function_prologue:
push 000AFF0Dh ; // забрасываем canary word на стек
; // (следовало это делать после сохранение ebp)
push ebp ; // сохраняем старый указатель карда в стеке
mov ebp, esp ; // открываем новый кадр
sub esp, 98h ; // резервируем место под локальные переменные
; // тело функции
; // (точно такое же, как и в прошлый раз)
function_epilogue:
leave ; // закрываем кадр стека
cmp esp,AFF0Dh ; // проверяем целостность canary word
jne canary_changed ; // если canary изменено, прыгаем на canary_changed
add esp,4 ; // удаляем canary из стека
ret ; // возвращаемся в материнскую процедуру
canary_changed: ; // завершаем выполнение программы
call __canary_death_handler
jmp . ; // если завершить не удалось — зациклившемся
Листинг 4 дизассемблерный листинг функции f(), защищенной Stack-Guard'ом (добавленные защитой строки выделены жирным шрифтом)
[ buf ]
[ a ]
[ ebp ]
[ 000aff0dh ]
[ retaddr ]
[ arg 1 ]
[ --------- ]
[ --------- ]
[ --------- ]
Листинг 5 состояние стека функции f() на момент завершения выполнения пролога и начала выполнения ее тела