Ques/Help/Req Приключения «Электрона». Отлаживаем JSC-код любой версии Electron без декомпилятора

XakeR

Member
Регистрация
13.05.2006
Сообщения
1 912
Реакции
0
Баллы
16
Местоположение
Ukraine
Когда‑то компания 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».

Присоединяйся к сообществу «Xakep.ru»!​


Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

-60%

1 год​


9990 рублей 4000 р.


[TD]

1 месяц​


920 р.
[/TD]

Я уже участник «Xakep.ru»
 
198 096Темы
635 067Сообщения
3 618 395Пользователи
ashot.Новый пользователь
Верх