Сегодня мы поговорим о приложениях, созданных при помощи среды 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, которое выполняется так же, как и сам скрипт. Этот формат имеет расширение .
и частенько используется для обфускации как в самом пакете 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
) попытаюсь рассказать, как самостоятельно без декомпилятора исследовать и отлаживать бинарный JS-байт‑код произвольной версии при помощи отладчика x64dbg и IDA.
Запустив программу из этого отладчика, мы обнаруживаем, что она не останавливается ни на одном брейк‑пойнте из заботливо размеченных нами по результатам изучения кода в IDA. Даже на тех, под которыми по всем резонам приложение должно останавливаться обязательно (например, бряки на вывод сообщений в консоль). Посмотрев в список активных задач, вспоминаем еще одну недобрую фишку компилированных скриптовых языков: программа для распараллеливания каких‑то внутренних процессов запускает несколько собственных копий с разными параметрами. Так делает и браузер, урезанной версией которого наша программа, по сути, и является.
Как мы выкручивались из подобного затруднения в прошлые разы? Тупо аттачились ко всем копиям программы (в приоритете процессы с заголовком активного окна или Chrome_Widget
) и проверяли, какая копия исполняет нужный нам код, останавливаясь на бряках. Или же варварски останавливали код в нужной точке, вбивая туда короткий jmp
, тем самым зацикливая программу в данном месте.
Второй способ подходит нам даже больше, поскольку нужное место мгновенно проскакивает при загрузке и руками притормозить в нем программу мы никак не успеваем. А так приложение просто зависает в данной точке, и заторможенный процесс достаточно отследить, приаттачившись к нему отладчиком, или разблокировать, вернув исходный код на место и продолжив после этого выполнение процесса.
Я назвал описанный способ варварским, потому что так делать можно далеко не всегда. Запущенные процессы взаимодействуют между собой: посылают запросы, получают ответы, и, если ответ не поступает через определенное время, процесс вполне может совершить сэппуку по тайм‑ауту. В частности, исследуемый нами 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
...
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»