Извлечение встроенных файлов из скомпилированного бинарника Deno

Версия Deno

Механизм самораспаковки (self-extraction) появился в Deno 2.7.0 (25 февраля 2026) с добавлением флага --self-extracting для deno compile. Разбор ниже ограничен Deno v2.7.0 и Windows-бинарниками (PE). Python-скрипт использует pefile: это не экстрактор ELF/Mach-O, а совместимость с более поздними версиями Deno не проверена.

До версии 2.7.0

В более ранних версиях нет используемого здесь флага --self-extracting, поэтому этот способ к ним неприменим. Для извлечения встроенных данных нужен отдельный подход, например разбор соответствующего бинарного формата. Это не означает, что такие рантаймы вообще никогда не записывают файлы на диск.

Что такое deno compile

Deno — среда выполнения JavaScript и TypeScript от создателя Node.js Райана Дала. Команда deno compile собирает проект в единый самодостаточный исполняемый файл:

deno compile --allow-net --allow-read src/main.ts

На выходе — один .exe (Windows), ELF (Linux) или Mach-O (macOS), содержащий:

  • Среда выполнения Deno — движок V8 и Rust-обвязка
  • Встроенные файлы проекта — виртуальная файловая система (VFS)
  • Включённые зависимости — npm-пакеты, JSR-модули, HTTP-импорты; часть модульных данных хранится отдельно
  • Метаданные — точка входа, разрешения, конфигурация

Пользователь может запустить бинарник без установки Deno. Разберём, как в нём хранятся встроенные файлы и как извлечь содержимое VFS.

TL;DR

Добавляем JSON-поле в метаданные бинарника и используем встроенный в Deno механизм самораспаковки для записи файлов VFS на диск. Это не восстановление всего исходного репозитория и не извлечение всех отдельных хранилищ модулей.


Структура автономного исполняемого файла

Секция d3n0l4nd

Deno встраивает данные в PE-ресурс с именем D3N0L4ND (Windows) или в секцию ELF/Mach-O с именем d3n0l4nd. Встроенные данные расположены последовательно. На схеме отмечены поля длины типа u64:

d3n0l4nd                              ← 8 байт, opening magic
┌──────────────────────────────────┐
│ u64: metadata_len                │
│ metadata JSON                    │  ← entrypoint, permissions, настройки
├──────────────────────────────────┤
│ u64: npm_snapshot_len            │
│ npm snapshot blob                │  ← граф npm-зависимостей
├──────────────────────────────────┤
│ specifiers store                 │  ← URL всех модулей → ID
├──────────────────────────────────┤
│ redirects store                  │  ← перенаправления модулей
├──────────────────────────────────┤
│ remote modules store             │  ← исходники HTTP-импортов
├──────────────────────────────────┤
│ u64: vfs_headers_len             │
│ VFS headers JSON                 │  ← дерево каталогов с offset/size
├──────────────────────────────────┤
│ u64: vfs_file_data_len           │
│ VFS file data blob               │  ← байты всех встроенных файлов
└──────────────────────────────────┘
d3n0l4nd                              ← 8 байт, closing magic

Два режима запуска

При запуске скомпилированного бинарника Deno находит секцию d3n0l4nd и десериализует содержимое. Дальше возможны два пути.

Режим по умолчанию — чтение встроенной VFS

fn find_section() -> Result<&'static [u8], AnyError> {
    match libsui::find_section("d3n0l4nd") {
        Ok(Some(data)) => Ok(data),  // прямой указатель в память процесса
        ...
    }
}

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

let start = self.start_file_offset + file.offset;
let end = start + file.len;
Ok(Cow::Borrowed(&self.data[start..end]))
// Никакого дискового IO — просто индекс в массив байт

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

Режим самораспаковки — распаковка на диск

Если в метаданных есть поле "self_extracting", рантайм при запуске вызывает extract_vfs_to_disk():

let root_path = if let Some(hash) = &metadata.self_extracting {
    let dir = choose_and_create_extraction_dir(hash)?;
    dir
} else {
    std::env::temp_dir().join(format!("deno-compile-{}", current_exe_name))
};

Функция рекурсивно обходит VFS и записывает её файлы на диск, если маркер .done не заставит пропустить распаковку. Отдельные хранилища модулей в этот обход не входят:

pub fn extract_vfs_to_disk(vfs: &FileBackedVfs, extraction_dir: &Path) -> Result<()> {
    // Если маркер .done существует — распаковка уже была, пропускаем
    let done_marker = extraction_dir.join(".done");
    if done_marker.exists() {
        return Ok(());
    }

    // Рекурсивно пишем ВСЕ файлы из VFS на диск
    extract_vfs_dir(vfs, vfs.root_dir(), &mut extraction_dir.to_path_buf())?;

    // Маркер: при следующем запуске распаковка не повторится
    std::fs::File::create(&done_marker)?;
    Ok(())
}

Директория распаковки зависит от версии рантайма, исполняемого файла, платформы, окружения и доступных для записи мест. taskflow — пример имени приложения, а не универсальная директория Deno. Чтобы определить точный путь, проверьте choose_and_create_extraction_dir и вызывающий её код в исходниках нужной версии рантайма, а затем подтвердите расположение в изолированной среде.

INFO

Этот режим изначально создан для нативных модулей (FFI, .node-аддоны), которые невозможно загрузить прямо из памяти.


Патчинг: включаем self-extraction в готовом бинарнике

Запуск бинарника выполняет приложение

Самораспаковка — не пассивная распаковка: запуск пропатченного бинарника также выполняет приложение. Для недоверенных бинарников используйте одноразовую изолированную среду без секретов и доступа к сети. Не запускайте их на своей рабочей машине.

Добавляем "self_extracting": "<hash>" в JSON метаданных внутри бинарника. При следующем запуске Deno распакует файлы VFS на диск встроенной процедурой, если маркер .done ещё не существует.

Что нужно учесть при изменении бинарника

Вставить поле недостаточно

Вставка байтов в метаданные без корректировки длин и смещений может повредить бинарник.

Нужно учесть структуру данных и размер файла:

  1. Перед метаданными записана их длина. Перед JSON лежит u64 с точным размером в байтах. Добавление поля увеличивает JSON на ~37 байт → значение u64 перестаёт совпадать.
  2. Секции идут вплотную. Без выравнивающих байтов: снимок npm-зависимостей начинается сразу после JSON метаданных.
  3. У PE-ресурса есть собственное поле Size. В PE-заголовке записан размер ресурса D3N0L4ND. Вставка байт ломает и его.
  4. Файл может быть большим. Автономные бинарники Deno могут превышать 100 МБ. Отображение файла в память позволяет обойтись без отдельной копии всего файла.

Алгоритм по шагам

Шаг 1. Найти ресурс D3N0L4ND в PE

Используем pefile в режиме fast_load, затем явно разбираем каталог ресурсов:

def locate_resource(exe_path: str) -> ResourceLocation:
    pe = pefile.PE(exe_path, fast_load=True)
    pe.parse_data_directories(
        directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]]
    )

    for rsrc_type in pe.DIRECTORY_ENTRY_RESOURCE.entries:
        for rsrc_id in rsrc_type.directory.entries:
            name = rsrc_id.name.string.upper() if rsrc_id.name else b""
            if name == b"D3N0L4ND":
                lang = rsrc_id.directory.entries[0]
                rva = lang.data.struct.OffsetToData
                return ResourceLocation(
                    file_offset=pe.get_offset_from_rva(rva),
                    size=lang.data.struct.Size,
                    struct_file_offset=lang.data.struct.get_file_offset(),
                )

Получаем три значения:

  • file_offset — где в файле начинается секция d3n0l4nd
  • size — текущий размер секции
  • struct_file_offset — смещение структуры IMAGE_RESOURCE_DATA_ENTRY в файле; её поле Size обновляем на шаге 6

Шаг 2. Прочитать JSON метаданных

Зная смещение секции, читаем начальную сигнатуру (magic), длину и JSON:

def read_metadata(f, section_offset: int) -> MetadataInfo:
    f.seek(section_offset)
    if f.read(8) != MAGIC:
        raise RuntimeError("Bad opening magic")
    length = struct.unpack("<Q", f.read(8))[0]
    json_offset = section_offset + 16  # 8 (magic) + 8 (u64)
    raw = f.read(length)
    return MetadataInfo(
        json_file_offset=json_offset,
        original_bytes=raw,
        parsed=json.loads(raw),
    )

Шаг 3. Модифицировать JSON

meta.parsed["self_extracting"] = hash_value
new_bytes = json.dumps(meta.parsed, separators=(",", ":")).encode()
diff = len(new_bytes) - len(meta.original_bytes)
# diff ≈ +37 байт

Шаг 4. Сдвинуть хвост файла

Если длина JSON меняется, сдвигаем следующие за ним байты. Используем mmap, чтобы не создавать отдельную копию всего файла:

def shift_bytes(f, *, start: int, amount: int) -> None:
    if amount == 0:
        return

    f.seek(0, 2)
    old_size = f.tell()
    tail = old_size - start

    if amount > 0:
        # Расширяем файл, затем сдвигаем блок
        f.truncate(old_size + amount)
        f.flush()
        with mmap.mmap(f.fileno(), 0) as mm:
            mm.move(start + amount, start, tail)
    else:
        # Сжимаем: сдвигаем, затем обрезаем
        with mmap.mmap(f.fileno(), 0) as mm:
            mm.move(start + amount, start, tail)
        f.truncate(old_size + amount)

    f.flush()
Почему mmap?

mmap.move() — обёртка над memmove. Корректно обрабатывает перекрывающиеся регионы памяти. Отображение файла в память позволяет обойтись без отдельного буфера Python для всего файла, но перемещение байтов всё равно обращается к страницам памяти и может вызвать дисковый ввод-вывод.

Шаг 5. Записать новые метаданные

f.seek(meta.json_file_offset - 8)
f.write(struct.pack("<Q", len(new_bytes)))
f.write(new_bytes)

Шаг 6. Обновить размер PE-ресурса

Обновляем записанный размер ресурса. IMAGE_RESOURCE_DATA_ENTRY.Size — u32 по смещению +4 от начала структуры:

def patch_resource_size(path: str, struct_offset: int, new_size: int) -> None:
    with open(path, "r+b") as f:
        f.seek(struct_offset + 4)
        f.write(struct.pack("<I", new_size))

Полный код

Код патчера: GitHub Gist

Установка зависимостей

pip install pefile
NOTE

Единственная внешняя зависимость. Всё остальное — стандартная библиотека Python.


Итог

Изменяем метаданные, чтобы включить встроенную в Deno процедуру извлечения VFS:

  1. Метаданные хранятся как JSON с префиксом длины в начале секции d3n0l4nd
  2. Добавляем поле "self_extracting": "<hash>" в этот JSON
  3. Сдвигаем все байты после метаданных через mmap.move()
  4. Обновляем два числа: длину метаданных (u64) и размер PE-ресурса (u32)
  5. При запуске Deno видит флаг → вызывает extract_vfs_to_disk() → встроенные файлы VFS на диске, если распаковка не пропущена из-за уже существующего .done

Этот способ извлекает встроенные файлы VFS, а не весь исходный репозиторий или все хранилища модулей. Здесь он ограничен Windows-бинарниками Deno 2.7.0; запуск изменённого бинарника также запускает приложение.