Витягування вбудованих файлів зі скомпільованого бінарника 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; запуск зміненого бінарника також запускає застосунок.