Приложение Т—Ж
В нем читать удобнее

Виртуализация JavaScript как метод обфускации: как я написал свою виртуальную машину

Обсудить

Этот текст написан в Сообществе, в нем сохранены авторский стиль и орфография

Предыстория

Я всегда интересовался тем, как устроены языки программирования — как исходный код проходит путь от синтаксиса до инструкций, которые исполняет машина, как работают компиляторы и интерпретаторы, а также что происходит с кодом после компиляции, будь то JVM Bytecode в случае Java или CIL, исполняемый средой CLR, в случае C#.

Ещё в далёком 2020 году, параллельно с основной работой, я начал писать собственный обфускатор для C#-приложений. Главной целью было не столько создать готовый коммерческий инструмент, сколько разобраться в том, как на практике устроена защита приложений от анализа, модификации и реверс-инжиниринга.

В то время найти действительно полезные и подробные материалы по этой теме было заметно сложнее, особенно если речь заходила о виртуализации кода. Поэтому, вооружившись энтузиазмом и желанием разобраться во всём самостоятельно, я начал писать свою виртуальную машину на C#.

Выглядела она как-то так
Выглядела она как-то так

Правда, надолго меня тогда не хватило: спустя совсем непродолжительное время проект был полностью заброшен.

И вот спустя 6 лет я возвращаюсь к этой идее, только теперь уже с большим опытом и со своим актуальным стеком: TypeScript, JavaScript, Go и Rust.

Термины для лучшего погружения

Обфускация — преобразование программы с целью усложнить её анализ, при этом сохранив исходное поведение.

Reverse Engineering — процесс исследования уже готовой программы с целью понять её внутреннее устройство и восстановить логику работы.

AST (Abstract Syntax Tree) — абстрактное синтаксическое дерево, то есть представление исходного кода в виде структуры, с которой программе гораздо удобнее работать, чем с обычным текстом.

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

Opcode — идентификатор конкретной инструкции виртуальной машины.

Virtual Machine (VM) в контексте этой статьи — собственный интерпретатор, который получает созданный нами ByteCode, последовательно разбирает его инструкции и выполняет соответствующие им операции.

WASM (WebAssembly) — компактный бинарный формат и среда выполнения, которую поддерживают современные браузеры и Node.js.

Что и зачем. Что такое виртуализация?

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

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

После виртуализации в итоговом файле остаётся только небольшая обёртка. Сама же логика переносится в бинарный контейнер с набором нестандартных инструкций (иногда мутабельных). Чтобы понять её, исследователю придётся сначала разобраться не только с функцией, но и с форматом байт-кода, устройством VM и связью между инструкциями. Это не замена серверной безопасности.

Уточню важное в виртуализацию всё также не стоит прятать API-ключи, пароли или и подобное, всё, что исполняется на клиенте, пользователь в конечном счёте контролирует.

Зато она подходит для защиты алгоритмов антифрод-проверок, лицензионной логики и кода, который не хочется отдавать в удобном для чтения виде.

Главные преимущества виртуализации как средства обфускации

Обычная обфускация усложняет чтение, но не меняет сам принцип исполнения.

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

Добавляет новый слой исполнения.

Код или его отдельные участки преобразуются в собственное промежуточное представление, условный байт-код. Этот байт-код уже не исполняется напрямую JavaScript-движком, а передаётся собственной виртуальной машине.

Архитектуру VM можно изменять между сборками.

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

VM может отчасти работать как ускоритель кода.

Виртуальная машина не обязана быть медленнее JavaScript. Для длинных числовых вычислений, циклов. VM может работать быстрее нативного JavaScript.

Нюансы виртуализации

Приходиться платить отчасти производительностью и размером bundle. Исполнение через VM обычно медленнее нативного JavaScript; дополнительно в сборку попадают рантайм и защищённые контейнеры. Поэтому защищать стоит только действительно ценные участки.

Сложно сохранить все особенности JavaScript. this, замыкания, исключения, асинхронность, классы и встроенные объекты имеют много тонких правил. Ошибка в VM или компиляторе может незаметно изменить поведение исходной программы.

Я постарался максимально сжато дать основную теорию о виртуализации, чтобы дальше контекст был более понятен, для дополнительного ознакомления можно почитать тут

Вот что получилось у меня

Пример VM-кода
Пример VM-кода

Идея и первый прототип

Перейдём к моему решению обфускации с виртуализацией! А точнее к моему небольшому рассказу то, как это зарождалось и что получилось в итоге.

Изучая разные источники на тему виртуализации я стал придумывать то, что можно было бы реализовать в короткое время, чтобы проверить мою идею на работоспособность, в целом даже пригодился опыт изучения Java,.NET.

Выбор технологий

И вот я начал выбирать на чём всё писать. Моим главным критерием конечно же была производительность, особенно когда речь зашла о VM (виртуальной машине). Недолго думая я выбрал Rust, всё ядро приложения и его модули написаны на Rust. После этого я понял, что если разрабатывать свой парсер и лексер JS/TS я потрачу на MVP невероятно много времени, и тут мне пригодился опыт экспериментов с SWC (подробнее о SWC)

Первый MVP

Посидев пару-тройку дней у меня получилось собрать первый MVP для выборочной виртуализации JavaScript (на тот момент пока без TypeScript).

Там ещё не было оптимизатора инструкций и поддержки большего количества фич синтаксиса, но главное! Оно работает.

Как проект развился

С тех пор проект заметно вырос. Появился полноценный конвейер преобразования: анализ исходного кода, собственное промежуточное представление, оптимизатор, байт-код, защищённый контейнер и VM, собранная в WASM.

Также появилась поддержка TypeScript и более широкого набора возможностей JavaScript: асинхронных функций, генераторов, исключений, this, методов классов, BigInt, типизированных массивов и других конструкций.

Что происходит с кодом

Важная функция отмечается для виртуализации. Компилятор разбирает её, переводит в байт-код и помещает результат в защищённый бинарный контейнер.

В итоговом JavaScript остаётся небольшая обёртка, которая передаёт вызов в рантайм. Сама логика выполняется внутри виртуальной машины, а не остаётся в исходнике в привычном виде.

Где вы уже могли сталкиваться с виртуализаций в качестве защиты

Если вы играете в игры купленные через какую-либо площадку будь то Steam/Epic Games, вы уже могли быть пользователем виртуализации невербально. Большинство AAA-игр имеют в себе DRM вида Denuvo и подобных.

Denuvo тоже использует один из методов защиты чувствительно кода игры (проверка лицензии/и т.п.) — виртуализацию и имеет свою VM с мутабельными инструкциями

Ограничения

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

Только ES-модули. Сейчас защищаемый код должен собираться как ESM; отдельный режим для обычных script/CommonJS-модулей не предусмотрен.

Нельзя динамически создавать код. eval, конструктор Function, with и динамический import() не виртуализируются: их поведение невозможно надёжно определить ещё на этапе сборки.

Не вся синтаксическая экосистема JavaScript покрыта. Внутри виртуализируемых функций пока ограничены объявления классов, приватные поля, вычисляемые ключи объектов, tagged templates, object rest и некоторые варианты spread, call/apply/bind.

Итоги и планы

  1. Расширение покрытия языка. Первая большая цель — довести покрытие поддерживаемого подмножества JavaScript до >80%. Хочется, чтобы виртуализировать можно было больше реального прикладного кода, не заставляя разработчика переписывать функцию под ограничения VM.
  2. Развитие оптимизатора. На длинных вычислениях это уже даёт интересный результат: в ряде сценариев VM работает быстрее native JavaScript. Теперь задача — расширить этот эффект на большее число распространённых паттернов кода используя известный опыт оптимизаций других виртуальных машины.
  3. Эксперимент в виртуализации как метода оптимизации высоконагруженного кода. Хочется попробовать сделать lightweight версию VM для оптимизаций объективно нагруженных частей кода, к примеру в играх/canvas вычислениях и других операциях.
  4. Сделать SDK для обфускации ваших приложений при сборки будь то Vite/Webpack/etc.
  5. Открыть часть VM-кода в Open Source

Продолжение эксперимента

Проект для меня остаётся площадкой для экспериментов с компиляторами, WASM, производительностью и защитой клиентского кода.

В свободное время я продолжу его развивать и стараться довести до Production

Спасибо за внимания! Задавайте вопросы в комментариях буду рад всем ответить:)

сервисыприложения