Я решил написать об этом упражнении (calc) с сайта pwnable.tw по двум причинам:

  • Во время моей попытки решить эту задачу я потратил некоторое время на поиск уязвимости и обнаружил совершенно не связанное с ней переполнение, которое привело меня в бешенство!
  • Ну что, нужно же с чего-то начинать?

Итак, как любой хороший исследователь, прежде чем что-то попробовать, я запустил программу и посмотрел, что она делает… какой шок, это же калькулятор:

После некоторого реверсирования с использованием IDA вы можете увидеть 3 функции, которые нас интересуют:

  • get_expr — Получение выражения для вычисления из стандартного ввода.
  • parse_expr — Выполняет фактические вычисления.
  • eval — операция Preform над двумя числами, функция parse_expr() вызывает эту функцию, пока есть еще операции.

Прежде чем мы углубимся в код, стоит помнить одну вещь: время выполнения программы ограничено 1 минутой из-за вызовов signal() и alarm() в начале программы:

  • Тайм-аут — это обработчик, который регистрируется на это событие. Выглядит так:

  • 0Eh — сигнал таймера от тревоги.
  • А будильник вызывается с параметром 3Ch, который равен 60 (в секундах).

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

Реверс программы расчета

Таким образом, поток программы calc демонстрирует следующий псевдокод:

Итак, мы знаем, что есть цикл, который получает выражение от пользователя (stdin), вычисляет результат и выводит его обратно пользователю (stdout).

Мы получаем выражение с помощью функции get_expr(), которая фильтрует символы, вставленные пользователем, используя белый список символов, которым она позволяет находиться внутри выражения. Белый список: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -, +, /, *, %].

Существует также переменная nums, представляющая собой массив из 100 чисел (каждое DWORD), вся цель которого состоит в том, чтобы удерживать числа в выражении и ждать, пока не наступит их очередь для вычисления (идея состоит в том, чтобы учитывать приоритет некоторых математические операции над другими).

И последнее, это переменная Operations, это массив из 100 символов, который содержит операции, которые должны быть рассчитаны для коррелированных чисел в переменной nums, после выполнения операции она удаляется из массива, а следующая операция заменяет ее.

Вот и все, давайте углубимся в функцию parse_expr():

Как следует из названия функции, она анализирует выражение, которое мы получили от пользователя, есть основной цикл функции, этот цикл для каждого числа является выражением:

После того, как число полностью обнаружено, оно преобразует число в int с помощью функции atoi() и, пока число не равно нулю, добавляет его в наш любимый массив чисел (nums). Следующее, что проверяется, это действительно ли выражение:

Приведенное выше проверяет, не закончилось ли выражение, что означает наличие математической операции сразу после текущего числа, и число после математической операции.

Предполагая, что выражение допустимо, мы проверяем, является ли это первое число, с которым мы столкнулись, есть две вещи, которые я должен сказать об этой проверке:

  • Нам нужно знать, что это первое число, чтобы не производить никаких вычислений, так как еще не поступило никакого дополнительного числа, чтобы выполнить над ним операцию.
  • Помните, что проверка будет важна позже.

Если это первое число, то операция добавляется и следующая итерация цикла идет своим чередом, с другой стороны, если нет, то делаем некоторые вычисления:

Он просто проверяет, какое вычисление делать, если мы получили операцию X и она одна из следующих: '*', '%' или '/' иоперация перед ней была '+' или '- ' выполните операцию X раньше (добавив X в список операций), в противном случае просто вычислите полученную операцию предварительного просмотра и добавьте ту, которую вы получили только что.

Последняя оставшаяся функция — это функция eval(), которая фактически вычисляет операцию:

Я могу сказать об этой функции только одно: по какой-то причине она не поддерживает операцию «%», что странно, поскольку мы видели, что можем вставить ее в наше выражение, верно?

Переполнение, которое потратило мое время

Как я уже говорил ранее, я обнаружил переполнение, которое, как оказалось, не имело значения и заставило меня потерять сон из-за него без уважительной причины:

В parse_expr() есть условие, при котором текущая операция добавляется в список операций и никакая операция не выполняется, это происходит, когда мы получаем один из '*', '/', '%' как текущая операция, так и предыдущая операция "-" или "+". Это означает, что если в моем выражении я заставлю условие выполниться более 100 раз (размер массива операций, который находится в стеке), мы можем переполнить стек.

Одна вещь, которую следует отметить, это то, что у нас есть канарейка сразу после массива операций, что означает, что если мы хотим переопределить что-то, это должно помочь нам управлять указателем инструкции, прежде чем мы вернемся из функции (где канарейка проверяется и завершается наша программа).

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

Схема стека выглядит следующим образом:

операции[100] смещение: 0

канареечное смещение: 100

[4 байта заполнения] смещение: 104

предыдущее смещение ebx: 108

предыдущее смещение ebp:112

смещение обратного адреса: 116

смещение pointer_to_expression: 120

смещение pointer_to_numbers: 124

[16 байтов заполнения] смещение: 140

nums.count смещение: 144

числа.значения[100]

Я остановился на смещении nums.count, потому что это поможет нам контролировать eIP. После того, как мы переопределим поле count переменной nums, функция eval() будет вычислять операцию над числами, которые находятся на этом смещении от nums.count, устанавливая это число меньше, чем размер фактического массива nums, который мы можем чтобы заставить функцию eval() выполнять '+' или '-' для DWORD в стеке, где сохраняется адрес возврата eval(). Давайте сбросим стек при первом вызове eval() после запуска следующего скрипта и увидим:

Мы можем видеть массив 0x2d, что означает операцию «-», и сразу после 0x2d есть 0x25, который является операцией «%», точно так, как показывает сценарий (причина, по которой мы видим только операции «-» в нашем массиве операций, заключается в том, что каждый раз, когда мы вставляем операцию «-», «%» выполняется и заменяется нашей операцией «-», операция «-» никогда не выполняется, потому что операция «%» всегда имеет приоритет). После этого числа, которые в нашем случае 50 (0x32). Теперь, когда мы переопределили nums.count на 0x25, мы уменьшили количество элементов, изменив член count, и eval() будет продолжать работать до тех пор, пока не выполнит следующее вычитание: число в 0xffffc9ec (которое останется прежним : 0x32, потому что операция перед ней будет 0x32–0x32, что равно 0) от числа по адресу 0xffffc9e8 (что равно 0x01, потому что с каждой операцией счетчик уменьшается на единицу, и это (предположительно) последняя операция) и результат будет 0x01–0x32 = -49. Причина, по которой я выбрал 50, заключается в том, что если вы посмотрите на дамп еще раз, вы заметите, что адрес возврата находится на 0xffffc91c (в настоящее время 0x08049350), что на 204 байта меньше, чем nums.count. теперь позвольте мне еще раз привести вас к коду eval:

Если я хочу, чтобы этот код изменил адрес возврата, расположенный за 204 байта до счетчика nums-›, то счетчик num-› должен быть равен -(204 / 4–2), что в точности равно -49. Причина, по которой я делю на 4, заключается в том, что мы говорим о целых числах, которые имеют размер DWORD. На самом деле он помещает значение 0x08049350–0xffffc9e8, которое равно 0x804C968, по адресу возврата функции eval() (внутри функции parse_expr()), круто, а?!

Пока нам удалось изменить обратный адрес, но указать, где именно? здесь все становится сложнее, конечно, в приведенном выше примере я вычитаю, но я мог бы также добавить, если бы я добавил, хотя программа рухнет, поскольку полученный адрес недействителен. Можно подумать (как я думал), что я могу получить больше контроля над адресом возврата, выполнив некоторые вычисления над 4-байтовыми «переменными» перед адресом возврата, проблема в том, что прямо перед адресом возврата находится 0xffffc9e8, который — это локальная переменная, указывающая на наш любимый nums.count, как только мы его переопределим, все наши вычисления пойдут в другом направлении.

Вот и все о неуместном переполнении. давайте посмотрим, где проблема на самом деле сидит.

Уязвимость

Итак, после нескольких часов застревания, так как предыдущий перелив не дал никакого результата, я принял холодный освежающий душ и начал с самого начала:

Помните, я говорил, что есть чек, который стоит запомнить? если не обращал внимания. Проверяется, является ли текущее число первым. Проверка осуществляется по количеству операций в массиве Operations, если операции нет, то она считается первой, в противном случае - нет. что произойдет, если мы скормим нашей программе следующее выражение «+300»?

Обратите внимание, что выражение допустимо с точки зрения белого списка get_expr(), и parse_expr() не выдаст никакой ошибки. Так что же произойдет?

Поскольку мы не вводили никаких цифр перед «+», число, отправляемое в функцию atoi(), представляет собой пустую строку, что приведет к тому, что atoi() вернет ноль, и это число не будет вставлено в строку. массив чисел. Поскольку в настоящее время в массиве операций нет операций, операция добавляется и продолжается синтаксический анализ. Далее предполагается, что число 300 является вторым числом в выражении, потому что у нас есть операция внутри массива операций, что произойдет, так это то, что функция eval() фактически выполнит следующую операцию:

nums.count = nums.count + nums.values[0];

Эта операция напрямую изменит счетчик массива nums и приведет к тому, что следующая операция будет выполняться между двумя последовательными байтами в выбранном вами месте.

Прежде чем я покажу вам, что нам печатается, давайте найдем какое-нибудь интересное значение, которое мы хотели бы напечатать, скажем, канарейку (просто для удовольствия, потому что мы сможем контролировать значения any 4 байта в стеке, поэтому нам не нужно знать, что такое канарейка).

Прежде чем мы сможем отладить и проанализировать программу, нам нужно отключить ограничение по времени, которое мы обсуждали ранее, я делаю это, используя скрипт python в интерпретаторе gdb:

Следующий скрипт просто пропускает вызовы ssignal() и alarms(), как они отображались ранее. Вы можете запустить этот скрипт Python из gdb, используя исходную команду:

Хорошо, мы отключили ограничение по времени, приступаем к отладке:

Наша цель - увидеть значение канарейки и иметь возможность вывести его на экран. Сначала нам нужно узнать его смещение от nums.count, поскольку это наш базовый адрес, к которому мы добавляем, я снова покажу макет стека, только это время с точки зрения функции calc():

Как мы видим в IDA:

смещение number_count: 0

смещение number_values: 4

смещение выражения: 404

канареечное смещение: 1428

Хорошо, у нас есть макет стека, так какое значение мне нужно указать в выражении, чтобы увидеть канарейку? Есть пара вещей, которые нам нужно помнить:

  • После любой операции (включая ту, которую мы делаем для управления nums.count) eval() уменьшает nums.count на единицу (в конце концов, это имеет смысл).
  • Перед печатью результата функция calc() уменьшает значение nums.count на единицу.
  • Базовый адрес, по которому извлекается результат, — это nums.values ​​(numbers_values ​​в макете стека), а не nums.count.

Последние два пункта, указанные выше, нейтрализуют друг друга, первый уменьшает конечный адрес на 4, а второй увеличивает его.

Таким образом, для достижения нашей цели eax должно быть (1428–4) / 4 = 356, но помните, что у нас все еще есть внешнее уменьшение на единицу, которое мы должны принять во внимание, поэтому 357 — это окончательное значение. Давайте отладим и проверим, работает ли это:

Успех! Одна вещь, которую нужно уточнить, это то, что точка останова 3 по адресу 0x8049388 — это место, где мы помещаем канарейку в стек, и мы можем увидеть это, отобразив eax, сравнивая его с выводом программы, мы видим, что значения одинаковы.

Таким образом, мы можем виртуально указать на любую желаемую ячейку памяти, сместив ее от адреса nums.count, но как насчет записи значений в память?

Воспользуйтесь уязвимостью

Самое интересное здесь! мы заставляем программу вести себя так, как мы приказываем 🙂

Сначала давайте объясним, как работает запись в произвольную память, потому что это может быть не так тривиально, как чтение канарейки. Сначала я покажу вам несколько тестов, которые я выполнил, а затем объясню, почему программа ведет себя так:

Мы напечатали канарейку как раньше, потом добавили в выражение единицу и получили канарейку +1, потом снова отобразили канарейку и увидели, что она действительно изменилась. Так почему это произошло? Причина в том, что функция parse_expr() выполняет операции по мере прохождения выражения и не ждет, пока полностью проанализирует выражение, причина такого поведения заключается в том, что при добавлении второго числа (в нашем случае «1») к nums, мы на самом деле делаем это после первой операции, которая, как вы знаете, изменила nums.count. Это означает, что второе число вставляется в nums.values[nums.count + 1] и увеличивает nums.count на единицу.

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

Здесь я хотел бы поговорить о двух вещах:

  • Во-первых, почему значение 356 не меняется?
  • Во-вторых, почему печатается сообщение «обнаружено разрушение стека», если мы не меняли канарейку? (мы использовали адрес со смещением 356, а не 357).

Из-за способа вставки следующего числа в следующие 4 байта к тому, которое мы переопределяем, нам просто нужно будет записать байты в правильном порядке (от младших к старшим адресам стека).

Конечно, например, если вы хотите написать 100 в местоположении, что его текущее значение равно 50, вам нужно будет добавить 50. Операция изменяется на текущее значение, хранящееся в местоположении.

Вот пример кода, который записывает в определенное место:

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

Поскольку у нас есть такой контроль над памятью, я подумал просто поместить наш шелл-код в стек и выполнить его, изменив адрес возврата, проблема в следующем:

Бит NX включен, и мы начинаем… (барабаны), цепляясь:

Как обычно, мы хотим выполнить /bin/sh для просмотра содержимого файла флагов, я сделаю это с помощью системного вызова execve(). Давайте посмотрим, что этот системный вызов принимает в качестве аргументов:

Источник: http://man7.org/linux/man-pages/man2/execve.2.html

  • filename — Указатель на строку ascii, содержащую путь к исполняемому файлу.
  • argv
argv is an array of argument strings passed to the new program.  By
       convention, the first of these strings (i.e., argv[0]) should contain
       the filename associated with the file being executed.
  • envp
envp is an
       array of strings, conventionally of the form key=value, which are
       passed as environment to the new program.
Theargv and envp arrays must each include a null pointer at the end of the array.

Я знаю, что нам не нужно включать все параметры так, как это представлено в документации, чтобы это работало, но это хорошее упражнение для техники связывания, так что давайте сделаем это.

И еще, чтобы узнать ABI для этого системного вызова (как и где ядро ​​ожидает получить аргументы), я зашел на http://syscalls.kernelgrok.com/ и увидел это:

Теперь мы знаем, что eax должен быть 0x0b (номер системного вызова ядра), ebx — имя файла, ecx — argv и edx — envp.

Наш стек выглядит следующим образом (на этот раз я использую наше смещение выражения-представления):

канареечное смещение: 357

смещение заполнения: 358

prev_ebp смещение: 360

смещение ret_addr: 361

Теперь нам нужно найти rop-гаджеты, которые помогут нам собрать нужный нам код. мы знаем, что нам нужно поместить значения в eax, ebx, ecx, edx и выполнить известную инструкцию «int 0x80» (вызвать ядро). После некоторого поиска с помощью утилиты ROPgadget я нашел:

0x0805c34b: поп-еах; ret
0x080701aa : pop edx ; ret
0x080701d1 : pop ecx ; поп ebx ; рет
0x08049a21 : целое 0x80

Пример поиска с помощью ROPgadget:

Теперь у нас есть наши адреса, чтобы делать то, что нам нужно, чтобы не видеть, как должен выглядеть наш макет стека:

Довольно просто, верно? Но откуда нам знать адреса 374,372 и 270? Нам нужно каким-то образом получить адрес стека.

Решение заключается в использовании prev_ebp в макете стека, который мы видели ранее, в конце концов, он содержит значение prev_ebp, которое указывает на стек. Но где в стеке? Теперь нам нужно, где точка prev_ebp по отношению к нашей цепочке связывания. давайте отладим и посмотрим:

Во-первых, обратите внимание, где я сломался, в конце calc() прямо перед инструкцией ret, что означает, что esp указывает на адрес возврата, я выгружаю esp-4, потому что именно здесь хранится значение prev_ebp (см. структуру стека ранее).

  • esp указывает на 0xffffcf3c (это также наше смещение 361).
  • prev_ebp равен 0xffffcf58.

Расстояние между ними равно 28, поэтому для вычисления адресов 374 372 и 270 нам нужно сделать следующее:

  • Получить значение по смещению 360 (prev_ebp).
  • Уменьшаем 28 от значения, теперь у нас есть адрес смещения 361.
  • Наконец, добавьте расстояние между желаемым адресом и началом rop-цепочки (смещение 361).

Вот код:

ФЛАГ{************}

До следующего раза.. 🙂

Первоначально опубликовано на http://unravelit.net 16 августа 2017 г.