Pwn iNt All! Находим уязвимости в скриптовых движках и вскрываем usermode-механизмы защиты Windows. Часть 1

Pwn iNt All! Находим уязвимости в скриптовых движках и вскрываем usermode-механизмы защиты Windows. Часть 1

Операционные системы – дело тонкое. И очень защищенное! Обойти их механизмы защиты — дело сложное и трудоемкое, даже слегка похожее на волшебство. Но говорят, что сын маминой подруги уже нашел несколько RCE-уязвимостей , написал эксплойт, и обошел все защитные механизмы. Попробуем и мы!

Сделаем это на примере задания № 11 из прошедшего NeoQUEST-2018 . Задачка реалистичная — рассмотрим “живой” сценарий удаленного выполнения кода, похожий на тот, что возникает в процессе эксплуатации Javascript-движков в браузерах. Будем искать уязвимости в некотором интерпретаторе, реализовывать их с получением ARW-примитива, а в результате — обойдем защиту ОС Windows пользовательского режима. Задание оказалось крайне сложнопроходимым интересным, поэтому посвятим ему целый цикл статей с подробным описанием пути к успеху.

По легенде, у нас имеется бинарник программы, а также адрес, на котором крутится подобный сервис. Также упомянуто о том, что первой ступенькой к успеху является некая «ключевая» функция.

Итак, приступим!

«Ключевой» поиск


Рассмотрим исходные данные. Целевая программа представляет собой некий интерпретатор команд для работы с тремя типами данных: числами, векторами и матрицами. Воспользуемся командой help — это даст нам представление о наборе поддерживаемых команд. Интерпретатор позволяет:

  • Создавать переменные векторов, матриц и чисел различных размеров.
  • Переназначать и копировать переменные.
  • Работать с отдельными ячейками матриц векторов как с числами, производить с ними простейшие арифметические операции.
  • Искать значение в векторах/матрицах, узнавать тип переменной и её значение.
  • Создавать циклы с переменными границами и шагом.




Программа реализована в виде бесконечного цикла обработки команд:



И каждая команда представлена отдельным регулярным выражением.


Итак, в поисках “ключевой” функции проверим бинарник на предмет характерных строк, связанных со словом “ключ” на всех языках мира. Быстро находим нужный фрагмент:




Похоже, что в бинарнике ключ отсутствует (что логично, черт побери!). Судя по всему, на сервере крутится тот же бинарник, и в строке аргумента aFirstKeyIs2c7f там находится верный ключ! Но как получить содержимое измененной строки с удаленного сервера?

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

  • В коде отсутствуют прямые вызовы (direct calls) этой функции и функции-заглушки sub_40E120.
  • Вызов sub_40E120 лежит в списке разрешенных с точки зрения механизма Control Flow Guard , что характерно для виртуальных методов – эти методы вызываются через косвенные вызовы (indirect calls).





Итак, фронт работ ясен: необходимо научиться вызывать нужный виртуальный метод в бинарнике на серверной стороне. Есть два варианта развития событий:

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


С чего же начать?

Закладки сладки


Для начала проанализируем код на предмет недокументированных возможностей. Судя по всему, функция обработки входной строки их не содержит: используемые здесь регулярные выражения относятся лишь к вышеперечисленным командам. Да и прямых вызовов данной функции в коде нет. Может быть, среди документированных команд имеются скрытые возможности по косвенному вызову нужной нам “ключевой” функции?

Ан нет. Некоторые команды (find, what) содержат в себе косвенные вызовы виртуальных методов, но повлиять на выбор вызываемого метода невозможно — смещения в таблице vtable везде фиксированы.





Увы, неудача! Теперь самое время заняться поиском оправданий уязвимостей, которые позволят нам вызвать “ключевую” функцию.

Кто ищет, тот всегда найдет, что искать


Мы у мамы программисты, поэтому попробуем написать простейший фаззер , генерирующий случайные, синтаксически верные наборы команд для интерпретатора. По такому же принципу пишутся фаззеры Javascript и DOM при поиске уязвимостей в браузерах. Наш фаззер будет генерировать различные последовательности команд в надежде “уронить” интерпретатор. Принцип работы фаззера при генерации очередной последовательности команд следующий:

  1. Сначала создается некоторое количество переменных различных типов и размеров. Имена и типы переменных сохраняются для последующего использования.
  2. Далее каждая команда генерируется случайным образом в соответствии со списком имеющихся переменных:
    • Выбирается произвольная команда (без учета параметров) из списка: команда создания новой переменной, арифметическая операция, команда присвоения, команды find, print.
    • В зависимости от вида команды, к ней добавляются произвольные аргументы. К примеру, для команды find выбирается произвольное имя из списка доступных переменных + произвольное число, являющееся вторым аргументом. Для команды арифметической операции число параметров велико (знак операции, переменные, индексы векторов и матриц), и каждый из них также выбирается произвольно.
    • Сформированная команда добавляется в текущую последовательность. Поскольку некоторые команды могут создавать новые переменные и менять тип уже существующих, то списки с переменными в этих случаях обновляются. Это позволит задействовать новые/измененные переменные в процессе генерации последующих команд.
  3. Интерпретатор запускается под отладчиком. Сформированная последовательность передается на вход интерпретатору. Ведется регистрация возможных падений при обработке переданных команд.

Иии… Победа! После запуска фаззера достаточно быстро удалось найти уязвимые последовательности команд, на которых интерпретатор “упал”. Результат более чем удовлетворительный — целых две различные уязвимости!

Уязвимость №1 (UaF)


Получив первую последовательность и очистив её от лишних команд (которые не влияют на падение интерпретатора), получаем следующий Proof-Of-Concept:



Посмотрим повнимательнее. Узнаете? Перед нами классическая уязвимость типа Use-After-Free ! Вот что происходит в данной последовательности команд:

  1. Создается большой вектор vec и целочисленная переменная intval.
  2. Создается копия вектора vec — переменная vec2.
  3. Переменной vec2 присваивается переменная другого типа (целочисленного).
  4. Попытка работать с исходным вектором vec приводит к записи в освобожденную память и, соответственно, к падению программы.




Похоже, что в интерпретаторе реализована концепция Copy-On-Write, вследствие чего переменные vec и vec2 содержали ссылку на один и тот же буфер в памяти. При переназначении одной из переменных буфер освобождался без учета того факта, что на него имелась ссылка и в другой переменной. После этого переменная vec содержала указатель на освобожденную память, и имела к ней доступ на чтение и запись. Как итог, данная уязвимость позволяет получить произвольный доступ к участку памяти, в которой могут находиться другие объекты программы.

Уязвимость №2 (Integer Overflow)


Вторая проблема связана с конструктором объекта матрицы. При выделении памяти под численный массив некорректно реализована проверка ширины W и высоты H матрицы.



В конструкторе предварительно выделяется память под W*H целочисленных ячеек. Если произведение W *H было больше, чем 232-1, то это приводит к выделению очень маленького буфера вследствие целочисленного переполнения. Это можно заметить и непосредственно в бинарнике, в конструкторе класса.



Однако возможность последующего доступа к ячейкам этого массива определяется большими границами W и H, а не переполненным значением W *H:



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

Каждая из найденных уязвимостей позволяет достичь нужного результата – стать RCE-богом и исполнить произвольный код. Рассмотрим эту процедуру в обоих случаях.

Я тебя контролирую!


Возможность произвольного доступа к свободному участку памяти в куче является сильным примитивом эксплуатации. Необходимо использовать поврежденный объект vec (вектор в первом случае и матрицу во втором) для контролируемой модификации какого-то другого объекта в куче процесса (назовем его victim). Нужным образом изменяя внутренние поля объекта victim, мы добьёмся выполнения произвольного кода.
Сначала поймем, что из себя представляют объекты числа, вектора и матрицы в памяти. Вновь заглянем в бинарник и недалеко от функции обработки входной команды обнаружим вызовы конструкторов для соответствующих классов. Все они являются наследниками базового класса и имеют схожие конструкторы. Например, так выглядит конструктор объекта вектора:



При создании объекта вызывается конструктор базового класса, затем поля объекта заполняются в соответствии с его типом. Можно увидеть назначение и порядок полей: адрес таблицы виртуальных функций, размер вектора, адрес буфера, тип объекта в виде численного идентификатора.
Как добиться того, чтобы в освобожденном участке памяти, доступном с помощью поврежденного объекта vec, оказалась переменная victim? Будем создавать новые объекты в куче процесса сразу после приведения объекта vec в поврежденное состояние. А для поиска объекта, который таки попадет в освобожденную память, воспользуемся командой интерпретатора find. Поместим в одно из полей у создаваемых объектов какое-либо редкое значение — своеобразный magic number. Очевидно, лучше всего для этого подходит размер буфера, т.к. он задается непосредственно пользователем при создании нового объекта. Если поиск этого значения в буфере поврежденного объекта vec увенчается успехом, значит, мы нашли нужный объект!
Продемонстрируем нахождение объекта victim для обеих уязвимостей. Действия на данном этапе будут несколько отличаться друг от друга.

В случае с первой уязвимостью типа UaF команда find осуществляет поиск в ограниченном участке памяти — его размер совпадает с размером освобожденного буфера у объекта vec. Выделение объекта в куче производится системным аллокатором и не является детерминированным процессом. Мы так подберем размеры буфера у изначального объекта vec и у создаваемых объектов, чтобы какой-нибудь из них попал в нужную память за небольшое число попыток:



Отлично, мы успешно нашли объект-жертву victim (он же v1 в данном случае)!

При выполнении того же сценария с использованием второй уязвимости все будет несколько сложнее. В этом случае поиск будет потенциально вестись во всей памяти, поскольку ширина W либо высота H матрицы будут велики. Однако если нужного числа в непосредственной близости от буфера не будет, то цикл поиска будет выходить за границы отображенной в адресное пространство памяти. Это приведет к падению процесса интерпретатора до нахождения заветного magic number. Эта проблема решается выделением большого количества объектов в памяти — хотя бы один из них попадет в память непосредственно вблизи буфера.





Бинго! И в данном случае мы также нашли объект victim!

А теперь самое интересное — необходимо понять, как именно изменить объект victim и как запустить с его помощью выполнение нужного нам кода. Итак, мы успешно поместили в освобожденную память объект класса “вектор”. Схематично это выглядит как-то так:



Объект victim находится в доступном нам с помощью объекта vec участке кучи, и мы можем как читать поля объекта victim, так и изменять их. Также нам известно смещение числа bufsize в буфере объекта vec. Теперь модифицируем объект victim следующим образом:

  1. Прочитаем адрес таблицы vtable, тем самым раскрывая исполняемый адрес в памяти (и побеждая защиту ASLR).
  2. Изучив бинарник интерпретатора, найдем константную разницу между адресом таблицы vtable для объекта вектора и адресом “ключевой” функции. Это позволит вычислить адрес “ключевой” функции на стороне сервера:


  3. Сформируем ложную таблицу vtable прямо в буфере объекта victim, куда запишем только что вычисленный адрес “ключевой” функции.
  4. Заменим адрес истинной таблицы vtable у объекта victim на адрес ложной.
  5. Вызовем команду what у объекта victim. Благодаря манипуляции с таблицей vtable вместо подлинного виртуального метода вызовется наша, “ключевая” функция. Поскольку мы вызываем целую функцию, а не отдельные ROP-гаджеты, защита Contol Flow Guard вызову функции никак не препятствует.



Данная схема вызова нужной нам функции является идентичной для обеих уязвимостей.

Мы в шаге от успеха, осталось собрать все воедино и получить ключ! Ниже рассмотрим оба примера.
Так выглядит код работающего эксплойта для уязвимости типа Use-After-Free:



Код эксплойта для уязвимости типа Use-After-Free
var vec[4096] var m2:=vec var va=3 m2:=va va=find(vec,256) var v1[256] va=find(vec,256) var vtb=0 vtb=va-1 var buf=0 buf=va+1 var func=0 func=vec[vtb]-1547208 func=func+57632 v1[0]=func vec[vtb]=vec[buf] what v1



А так — для уязвимости типа Integer Overflow:



Код эксплойта для уязвимости типа Integer Overflow
var m[65537][65536] var v1[256] var v2[256] var v3[256] var v4[256] var v6[256] var v5[256] var v7[256] var v8[256] var v9[256] var v10[256] var v11[256] var v12[256] var v13[256] var v14[256] var v16[256] var v15[256] var v17[256] var v18[256] var v19[256] var v20[256] var v21[256] var v22[256] var v23[256] var v24[256] var v25[256] var v26[256] var v27[256] var v28[256] var v29[256] var v30[256] var v31[256] var v32[256] var v33[256] var v34[256] var v35[256] var v36[256] var v37[256] var v38[256] var v39[256] var v40[256] var varr=0 var varc=0 varr=find(m,256).row varc=find(m,256).col var vtb=0 vtb=varc-1 var buf=0 buf=varc+1 var func=0 func=m[varr][vtb]-1547208 func=func+57632 v1[0]=func v2[0]=func v3[0]=func v4[0]=func v5[0]=func v6[0]=func v7[0]=func v8[0]=func v9[0]=func v10[0]=func v11[0]=func v12[0]=func v13[0]=func v14[0]=func v15[0]=func v16[0]=func v17[0]=func v18[0]=func v19[0]=func v20[0]=func v21[0]=func v22[0]=func v23[0]=func v24[0]=func v25[0]=func v26[0]=func v27[0]=func v28[0]=func v29[0]=func v30[0]=func v31[0]=func v32[0]=func v33[0]=func v34[0]=func v35[0]=func v36[0]=func v37[0]=func v38[0]=func v39[0]=func v40[0]=func m[varr][vtb]=m[varr][buf] what v1 what v2 what v3 what v4 what v5 what v5 what v6 what v7 what v8 what v9 what v10 what v11 what v12 what v13 what v14 what v15 what v16 what v17 what v18 what v19 what v20 what v21 what v22 what v23 what v24 what v25 what v26 what v27 what v28 what v29 what v30 what v31 what v32 what v33 what v34 what v35 what v36 what v37 what v38 what v39 what v40 


Ура! Получен ключ, а бонусом к нему — важная информация. Оказывается, имеется и второй ключ, но он лежит в файле рядом с бинарником на стороне сервера. Для решения данной задачи требуется строить ROP-цепь, а значит, придется обходить CFG. Но об этом — в следующей статье!

А мы напоминаем, что всем, кто прошел полностью хотя бы одно задание на NeoQUEST-2018 , полагается приз! Проверяйте почту на наличие письма, а если вдруг оно вам не пришло — пишите на support@neoquest.ru!
Alt text