Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Когда‑то компания Positive Technology разработала собственный плагин для декомпилятора Ghidra, позволяющий исследовать веб‑приложения на Electron, в которых используется скомпилированный бинарный код V8 JSC. C тех пор сменилось много версий Node.js, формат кардинально поменялся, в результате существующие перестали работать. В этой статье я покажу, как самостоятельно без декомпилятора отлаживать бинарный JS-байт‑код произвольной версии.
Сегодня мы поговорим о приложениях, созданных при помощи среды Electron. Этот фреймворк — классический пример инструмента, с помощью которого любой продвинутый верстальщик может почувствовать себя полноценным разработчиком кросс‑платформенных программных пакетов. То есть Electron может сконвертировать сайт в самодостаточное (хотя и несколько неуклюжее) приложение. Надо ли говорить, что технология получила достаточно широкое распространение. Если верить статье на Хабре, на этой платформе реализовано множество популярных приложений — мессенджеры Skype и Discord, редакторы для кода Visual Studio Code и Atom и другие.
Про этот фреймворк написано множество статей (желающие могут изучить, например, руководство для начинающих или краткое руководство по Electron). Однако наш интерес к нему лежит несколько в иной плоскости. По традиции мы попробуем препарировать готовое приложение Electron на предмет исследования его внутренней структуры, отладки, реверса, возможной доработки и исправления багов.
Итак, у нас имеется приложение, при открытии исполняемого модуля которого (надо сказать, весьма упитанного — больше сотни мегабайт) Detect It Easy выдает следующую информацию.
Если ты уже слегка знаком с пакетом Electron или успел бегло прочитать предложенные выше статьи, то чудовищный размер исполняемого файла тебя не удивит — ведь, по сути, исполняемый модуль представляет собой слегка оптимизированную версию браузера Chromium + Node. В ней запускается код JavaScript, на котором, собственно, и написана основная логика приложения. Этот код и прочие HTML-потроха могут лежать в открытом и прозрачном для исследования виде (этот случай нам неинтересен, как тривиальный). Также они могут быть собраны или упакованы в отдельный ресурс специального вида asar — тут возможны сложности, но мы пока не будем их касаться. Еще ресурсы могут быть частично откомпилированы в натив в исполняемом файле (обычно так и есть). А иногда они откомпилированы целиком, сейчас мы этот вариант также подробно рассматривать не будем. Мы начнем с простого случая: ядро Node скомпилировано в натив, а JS-скрипты интерпретируются ядром в виде байт‑кода.
Остановимся поподробнее на этом моменте. Для тех, кто не читал мои предыдущие статьи (например, «Реверсинг .NET. Как искать JIT-компилятор в приложениях» или «Беззащитная Java. Ломаем Java bytecode encryption»), поясню, что актуальные скриптовые платформы (Java, .NET и другие) работают по такому основному алгоритму. Для удобства и скорости скрипт компилируется в промежуточный кросс‑платформенный байт‑код, который уже при работе программы, по мере вызова методов и классов, или интерпретируется интерпретатором, или конвертируется в исполняемый нативный код встроенным JIT-компилятором. Это справедливо и для JavaScript, для которого придумано множество интерпретаторов: V8, SpiderMonkey, Chakra, Rhino, KJS, Nashorn и другие. Даже в Adobe создали свой собственный движок, про который я тоже в свое время написал статью.
Нас же интересует встроенный в Electron движок Chrome V8. В нем имеется пакет bytenote, позволяющий сохранить исходный скрипт в виде сериализованного представления байт‑кода V8, которое выполняется так же, как и сам скрипт. Этот формат имеет расширение .JSC
и частенько используется для обфускации как в самом пакете Electron, так и в других серверных приложениях, написанных на языке JavaScript.
Упомянутый формат малоизучен: три года назад группа Positive Technologies озаботилась его реверсом и провела исследование байт‑кода версии 8.16.0, результатом чего стали несколько статей. Я рекомендую ознакомиться с ними самостоятельно для пущего понимания предметной области.
Еще одним результатом исследования стало появление плагина для декомпилятора Ghidra, способного работать с JSC-файлами версии 8.16.0. К сожалению, после этого интерес к теме у исследователей угас. Несмотря на то что с тех пор было выпущено еще несколько проектов для реверса бинарного кода JS (например, jsc-decompile-mozjs-34 или cocos2d-jsc-decompiler), версии Node.js меняются с такой быстротой, что на текущий момент все эти проекты безнадежно устарели. Поэтому я на примере байт‑кода версии 10.2.154.26 (JSC-сигнатура A905 DEC0 866CEBA8
) попытаюсь рассказать, как самостоятельно без декомпилятора исследовать и отлаживать бинарный JS-байт‑код произвольной версии при помощи отладчика x64dbg и IDA.
Запустив программу из этого отладчика, мы обнаруживаем, что она не останавливается ни на одном брейк‑пойнте из заботливо размеченных нами по результатам изучения кода в IDA. Даже на тех, под которыми по всем резонам приложение должно останавливаться обязательно (например, бряки на вывод сообщений в консоль). Посмотрев в список активных задач, вспоминаем еще одну недобрую фишку компилированных скриптовых языков: программа для распараллеливания каких‑то внутренних процессов запускает несколько собственных копий с разными параметрами. Так делает и браузер, урезанной версией которого наша программа, по сути, и является.
Как мы выкручивались из подобного затруднения в прошлые разы? Тупо аттачились ко всем копиям программы (в приоритете процессы с заголовком активного окна или Chrome_Widget
) и проверяли, какая копия исполняет нужный нам код, останавливаясь на бряках. Или же варварски останавливали код в нужной точке, вбивая туда короткий jmp self (0xEB,0xFE)
, тем самым зацикливая программу в данном месте.
Второй способ подходит нам даже больше, поскольку нужное место мгновенно проскакивает при загрузке и руками притормозить в нем программу мы никак не успеваем. А так приложение просто зависает в данной точке, и заторможенный процесс достаточно отследить, приаттачившись к нему отладчиком, или разблокировать, вернув исходный код на место и продолжив после этого выполнение процесса.
Я назвал описанный способ варварским, потому что так делать можно далеко не всегда. Запущенные процессы взаимодействуют между собой: посылают запросы, получают ответы, и, если ответ не поступает через определенное время, процесс вполне может совершить сэппуку по тайм‑ауту. В частности, исследуемый нами Electron отслеживает тайминг прохождения процессов (я так понимаю, не из каких‑то параноидальных побуждений, это обычная фича любого браузера). Но, по счастью, в нашем случае ничего катастрофического приложение не делает, ограничиваясь предупреждениями о подозрительной небезопасности происходящего.
Итак, у нас получилось остановиться в нужном месте программы. Открыв стек вызовов, мы обнаруживаем, что отлаживаемый нами EXE-модуль, несмотря на всю свою раздутость, является весьма небольшой частью программы (откомпилированным в натив ядром V8), которая вызывается из JIT-компилированного кода.
Адрес перехода между интерпретатором (выделено красным) и скомпилированными в натив библиотеками ядра Node (выделено синим) мы видим на стеке вызовов. Интерпретатор скомпилированного байт‑кода выглядит примерно так:
... 00007FF6BFE4BCDA | 4D:8BBD A8450000 | mov r15,qword ptr ds:[r13+45A8] // r12 — указатель на байт-код метода; r9 — программный счетчик // r9 — текущий опкод 00007FF6BFE4BCE1 | 47:0FB6140C | movzx r10d,byte ptr ds:[r12+r9] // Таблица обработчиков опкодов 00007FF6BFE4BCE6 | 4B:8B0CD7 | mov rcx,qword ptr ds:[r15+r10*8] // Вызов обработчика опкода 00007FF6BFE4BCEA | FFD1 | call rcx // Восстановить указатель на байт-код 00007FF6BFE4BCEC | 4C:8B65 E0 | mov r12,qword ptr ss:[rbp-20] // Восстановить zigzag-coded программный счетчик 00007FF6BFE4BCF0 | 44:8B4D D8 | mov r9d,dword ptr ss:[rbp-28] 00007FF6BFE4BCF4 | 41:D1E9 | shr r9d,1 // bl — текущий байт-код 00007FF6BFE4BCF7 | 43:0FB61C0C | movzx ebx,byte ptr ds:[r12+r9] 00007FF6BFE4BCFC | 4D:8BC1 | mov r8,r9 // Указатель на количество параметров 00007FF6BFE4BCFF | 49:8B8D 281B0000 | mov rcx,qword ptr ds:[r13+1B28] 00007FF6BFE4BD06 | 80FB 03 | cmp bl,3 // Если команда не префикс — переход 00007FF6BFE4BD09 | 77 1D | ja 7FF6BFE4BD28 // Следующий байт за префиксом 00007FF6BFE4BD0B | 41:FFC1 | inc r9d // Префикс Wide или ExtraWide? 00007FF6BFE4BD0E | F6C3 01 | test bl,1 // Получаем опкод, следующий за префиксом 00007FF6BFE4BD11 | 43:0FB61C0C | movzx ebx,byte ptr ds:[r12+r9] 00007FF6BFE4BD16 | 75 09 | jne 7FF6BFE4BD21 // Префикс Wide — смещение в таблице параметров +0xC6 00007FF6BFE4BD18 | 48:81C1 C6000000 | add rcx,C6 00007FF6BFE4BD1F | EB 07 | jmp 7FF6BFE4BD28 // Префикс ExtraWide — смещение в таблице параметров +0xC6 00007FF6BFE4BD21 | 48:81C1 8C010000 | add rcx,18C // Выход из метода, если опкод Return 00007FF6BFE4BD28 | 80FB A9 | cmp bl,A9 00007FF6BFE4BD2B | 0F84 1D000000 | je 7FF6BFE4BD4E // Выход из метода, если опкод SuspendGenerator 00007FF6BFE4BD31 | 80FB AF | cmp bl,AF 00007FF6BFE4BD34 | 0F84 14000000 | je 7FF6BFE4BD4E // Если опкод JumpLoop, то никуда не двигаться 00007FF6BFE4BD3A | 80FB 89 | cmp bl,89 00007FF6BFE4BD3D | 75 05 | jne 7FF6BFE4BD44 00007FF6BFE4BD3F | 4D:8BC8 | mov r9,r8 00007FF6BFE4BD42 | EB 08 | jmp 7FF6BFE4BD4C // r10 — количество параметров команды 00007FF6BFE4BD44 | 44:0FB61419 | movzx r10d,byte ptr ds:[rcx+rbx] // Сдвигаем программный счетчик на него и переходим к обработке следующего опкода 00007FF6BFE4BD49 | 45:03CA | add r9d,r10d 00007FF6BFE4BD4C | EB 8C | jmp 7FF6BFE4BCDA 00007FF6BFE4BD4E | 48:8B5D E0 | mov rbx,qword ptr ss:[rbp-20] ...
Как видишь, отсюда можно получить длины всех обрабатываемых интерпретатором опкодов. Для начала чуть проясню, что такое префиксы Wide
и ExtraWide
и почему на опкоды с ними положены разные таблицы. Как сказано в упомянутой мной статье, в зависимости от длины операндов каждая инструкция имеет три варианта — обычный (1-байтовые операнды), Wide (2-байтовые операнды) и ExtraWide (4-байтовые операнды). В новой версии добавилось еще два префикса — отладочные, соответственно, тоже в вариантах Wide
и ExtraWide
.
Попробуем вытащить мнемонику новой системы команд. Конечно же, интерпретатор содержит таблицу внутренних имен всех инструкций, ведь они нужны для отладочной печати при обработке ошибок. Немного покурив код обработчиков таких ошибок в IDA, натыкаемся на такой сложный case
, в котором каждому опкоду соответствует имя инструкции:
... .text:00144A71AE0 cmp cl, 0C5h ; switch 198 cases .text:00144A71AE3 ja def_144A71B01 ; jumptable 0000000144A71B01 default case .text:00144A71AE9 lea rax, aWide_0 ; "Wide" .text:00144A71AF0 movzx ecx, cl .text:00144A71AF3 lea rdx, jpt_144A71B01 .text:00144A71AFA movsxd rcx, ds:(jpt_144A71B01 - 144A72130h)[rdx+rcx*4] .text:00144A71AFE add rcx, rdx .text:00144A71B01 jmp rcx ; switch jump .text:00144A71B03 loc_144A71B03: ; CODE XREF: sub_144A71AE0+21↑j .text:00144A71B03 lea rax, aExtrawide ; jumptable 0000000144A71B01 case 1 .text:00144A71B0A locret_144A71B0A: ; CODE XREF: sub_144A71AE0+21↑j .text:00144A71B0A retn ; jumptable 0000000144A71B01 case 0 .text:00144A71B0B loc_144A71B0B: ; CODE XREF: sub_144A71AE0+21↑j .text:00144A71B0B lea rax, aDebugbreak4 ; jumptable 0000000144A71B01 case 8 .text:00144A71B12 retn ...
Источник: xakep.ru