Что внутри у .wasm-файла? Знакомство с wasm-decompile

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



Проблема заключается в том, что в .wasm-файлах содержится довольно-таки низкоуровневый код, который сильно похож на настоящий ассемблерный код. В частности, в отличие, например, от JVM, все структуры данных компилируются в наборы операций load/store, а не в нечто такое, в чём имеются понятные имена классов и полей. Компиляторы, вроде LLVM, могут так изменить входной код, что то, что у них получается, и близко на него не похоже. 

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

Дизассемблирование или… декомпиляция?


Для преобразования .wasm-файлов в файлы .wat, содержащие стандартное текстовое представление Wasm-кода, можно воспользоваться инструментами наподобие wasm2wat (это — часть набора инструментов WABT). Результаты такого преобразования очень точны, но читать получившийся код не особенно удобно.

Вот, например, простая функция, написанная на C:

typedef struct { float x, y, z; } vec3;

float dot(const vec3 *a, const vec3 *b) {
    return a->x * b->x +
           a->y * b->y +
           a->z * b->z;
}

Код хранится в файле dot.c.

Воспользуемся следующей командой:

clang dot.c -c -target wasm32 -O2

Далее, чтобы преобразовать то, что получилось, в .wat-файл — применим следующую команду:

wasm2wat -f dot.o

Вот что это нам даст:

(func $dot (type 0) (param i32 i32) (result f32)
  (f32.add
    (f32.add
      (f32.mul
        (f32.load
          (local.get 0))
        (f32.load
          (local.get 1)))
      (f32.mul
        (f32.load offset=4
          (local.get 0))
        (f32.load offset=4
          (local.get 1))))
    (f32.mul
      (f32.load offset=8
        (local.get 0))
      (f32.load offset=8
        (local.get 1))))))

Код это маленький, но его, по многим причинам, крайне тяжело читать. Помимо того, что тут не используются выражения, и того, что он, в целом, выглядит достаточно многословно, нелегко разобраться в структурах данных, представленных в виде команд по загрузке данных из памяти. А теперь представьте, что вам нужно проанализировать подобный код гораздо большего размера. Такой анализ станет весьма сложной задачей.

Попробуем, вместо использования wasm2wat, выполнить следующую команду:

wasm-decompile dot.o

Вот что она нам даст:

function dot(a:{ a:float, b:float, c:float },
             b:{ a:float, b:float, c:float }):float {
  return a.a * b.a + a.b * b.b + a.c * b.c
}

Это выглядит уже гораздо лучше. Помимо того, что тут используются выражения, напоминающие уже известный вам язык программирования, декомпилятор разбирает команды, направленные на работу с памятью, и пытается воссоздать структуры данных, представленные этими командами. Затем система аннотирует каждую переменную, которая используется как указатель с «встроенным» объявлением структуры. Декомпилятор не создаёт именованное объявление структуры, так как он не знает о том, есть ли что-то общее между структурами, в которых используются по 3 float-значения.

Как видите, результаты декомпиляции оказались более понятными, чем результаты дизассемблирования.

На каком языке написан код, выдаваемый декомпилятором?


Инструмент wasm-decompile выводит код, пытаясь сделать этот код похожим на некий «усреднённый» язык программирования. При этом данный инструмент старается не уходить слишком далеко от Wasm.

Первая цель wasm-decompiler — формирование читабельного кода. То есть — такого кода, который позволит его читателю легко разобраться в том, что происходит в декомпилированном .wasm-файле. Вторая цель этого инструмента заключается в том, чтобы выдать как можно более точное представление .wasm-файла, сформировав код, который полно представляет то, что происходит в исходном файле. Очевидно то, что эти цели далеко не всегда хорошо друг с другом согласуются.

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

Команды загрузки и сохранения данных


Как показано выше, wasm-decompile ищет команды загрузки и сохранения данных, связанные с конкретным указателем. Если эти команды формируют непрерывную последовательность, декомпилятор выводит одно из «встроенных» объявлений структуры данных.

Если обращались не ко всем «полям», декомпилятор не может с уверенностью отличить структуру от некоей последовательности операций по работе с памятью. В таком случае wasm-decompile использует резервный вариант, применяя более простые типы вроде float_ptr (если типы являются одинаковыми), или, в худшем случае, формирует код, иллюстрирующий работу с массивом, наподобие o[2]:int. Такой код говорит нам о том, что o указывает на элементы типа int, и мы обращаемся к третьему такому элементу.

Эта вот последняя ситуация возникает гораздо чаще, чем можно подумать, так как локальные Wasm-функции больше ориентированы на использование регистров, а не переменных. В результате в оптимизированном коде один и тот же указатель может использоваться для работы с совершенно не связанными друг с другом объектами.

Декомпилятор стремится интеллектуально подходить к индексированию и способен выявлять паттерны наподобие (base + (index << 2))[0]:int. Источником таких паттернов являются обычные для C операции индексирования, наподобие base[index], где base указывает на 4-байтный тип. В коде это встречается очень часто, так как Wasm, в командах загрузки и сохранения данных, поддерживает лишь смещения, задаваемые в виде констант. В коде, формируемом wasm-decompile, подобные конструкции преобразуются к виду base[index]:int.

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

Управление потоком выполнения программы


Если говорить об управляющих конструкциях, то самой известной среди них является Wasm-конструкция if-then, которая превращается в if (cond) { A } else { B }, с дополнением того, что в Wasm такая конструкция может возвращать значение, поэтому она может представлять и тернарный оператор, вроде cond ? A : B, который есть в некоторых языках.

Другие управляющие конструкции Wasm основаны на блоках block и loop, а также на переходах br, br_if и br_table. Декомпилятор старается держаться как можно ближе к этим конструкциям. Он не стремится к тому, чтобы воссоздать конструкции while/for/switch, которые могли бы послужить основой для них. Дело в том, что такой подход лучше показывает себя при обработке оптимизированного кода. Например, обычная конструкция loop может выглядеть в коде, выдаваемом wasm-decompile, так:

loop A {
  // здесь будет тело цикла.
  if (cond) continue A;
}

Здесь A — это метка, которая позволяет строить вложенные друг в друга конструкции loop. То, что тут есть команды if и continue, используемые для управления циклом, может выглядеть несколько чужеродно для циклов while, но они соответствуют Wasm-конструкции br_if.

Блоки оформляются похожим образом, но тут условия находятся в начале, а не в конце:

block {
  if (cond) break;
  // здесь будет тело блока.
}

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

Самое необычное средство Wasm, использующееся для управления потоком выполнения программы, это br_table. Это средство представляет собой нечто вроде оператора switch, за исключением того, что тут используются встроенные блоки. Всё это усложняет чтение кода. Декомпилятор упрощает структуру подобных конструкций, стремясь к тому, чтобы немного облегчить их восприятие:

br_table[A, B, C, ..D](a);
label A:
return 0;
label B:
return 1;
label C:
return 2;
label D:

Это напоминает использование switch для анализа a, когда вариантом, используемым по умолчанию, является D.

Другие интересные возможности


Вот ещё некоторые возможности wasm-decompile:

  • Декомпилятор может извлекать имена из отладочных данных или из данных компоновки, а также может генерировать имена самостоятельно. При использовании существующих имён предусмотрено упрощение искажённых C++-имён.
  • Система уже поддерживает предложение, касающееся, кроме прочего, возврата из функции нескольких значений. Это немного усложняет превращение исходного кода в выражения и инструкции. Если функции возвращают несколько значений, используются дополнительные переменные.
  • Имена могут быть сгенерированы на основании содержимого раздела данных.
  • Декомпилятор формирует аккуратные объявления для всех типов разделов Wasm-файлов, а не только для кода. Например, wasm-decompile пытается улучшить читабельность разделов данных, выводя их, если это возможно, в виде текста.
  • Система пытается уменьшить количество скобок в выражениях, учитывая приоритет операторов (по правилам, которыми обычно пользуются в C-подобных языках).

Ограничения 


Декомпиляция Wasm-кода — это задача, которая гораздо сложнее, чем, например, декомпиляция байт-кода JVM.

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

В отличие от байт-кода JVM, код, попадающий в .wasm-файлы, сильно оптимизирован LLVM. В результате такой код часто теряет большую часть исходной структуры. Выходной код очень не похож на то, что написал бы программист. Это значительно усложняет задачу декомпиляции Wasm-кода с выводом результатов, способных принести программистам реальную пользу. Однако это не означает, что мы не должны стремиться к решению этой задачи!

Итоги


Если вам интересна тема декомпиляции Wasm-кода, то, пожалуй, лучший способ в этой теме разобраться — взять и декомпилировать собственный .wasm-проект! Кроме того, здесь вы можете найти более подробное руководство по wasm-decompile. Код декомпилятора можно найти в файлах этого репозитория, имена которых начинаются с decompile (если хотите — присоединяйтесь к работе над декомпилятором). Здесь можно найти тесты, показывающие дополнительные примеры различий между .wat-файлами и результатами декомпиляции.

А с помощью каких инструментов вы исследуете .wasm-файлы?

Напоминаем, что у нас продолжается конкурс прогнозов, в котором можно выиграть новенький iPhone. Еще есть время ворваться в него, и сделать максимально точный прогноз по злободневным величинам.

Источник: https://habr.com/ru/company/ruvds/blog/501650/


Интересные статьи

Интересные статьи

Всем привет. Я решил наконец-то разобраться, как работает интерпретатор Python. Для этого стал изучать одну статью-книгу и задумал заодно перевести её на русский язык. Дело в том, что перевод...
Мы в фирме 1С широко используем собственные разработки для организации работы компании. В частности, «1С:Документооборот 8». Помимо управления документами (как следует из названия) это ещё и совр...
Всем привет. Когда я искал информацию о журналировании (аудите событий) в Bitrix, на Хабре не было ни чего, в остальном рунете кое что было, но кто же там найдёт? Для пополнения базы знаний...
Фиг вы где увидите, что происходит внутри аэропорта. Всё потому, что это место, где встречается сразу куча юрисдикций: полиция и кинологи — федеральные, таможня и пограничники предпочитают вообще...
Для всех хабравчан, у которых возникло ощущение дежавю: Написать этот пост меня побудили статья "Введение в Python" и комментарии к ней. К сожалению, качество этого "введения" кхм… не будем о гру...