Витягування вбудованих файлів зі скомпільованого бінарника Deno
Зміст
Як змінити виконуваний файл Deno 2.7.0 для Windows, щоб витягти вбудовані файли VFS, і чого цей спосіб не відновлює. Механізм саморозпакування (self-extraction) з’явився у Deno 2.7.0 (25 лютого 2026) з додаванням прапорця У попередніх версіях немає використаного тут прапорця Deno — середовище виконання JavaScript і TypeScript від творця Node.js Раяна Дала. Команда На виході — один Користувач може запустити бінарник без встановлення Deno. Розглянемо, як у ньому зберігаються вбудовані файли та як витягти вміст VFS. Додаємо JSON-поле до метаданих бінарника й використовуємо вбудований у Deno механізм саморозпакування для запису файлів VFS на диск. Це не відновлення всього початкового репозиторію і не витягування всіх окремих сховищ модулів. Deno вбудовує дані в PE-ресурс з іменем При запуску скомпільованого бінарника Deno знаходить секцію Виконуваний файл відображено в адресний простір процесу. У показаному нижче шляху читання VFS Цей шлях читання VFS не записує вбудовані файли на диск. Це не твердження про інші частини рантайму чи сам застосунок: вони можуть виконувати дискове введення-виведення, а звернення до відображених сторінок теж може потребувати читання з диска. Якщо в метаданих є поле Функція рекурсивно обходить VFS і записує її файли на диск, якщо маркер Директорія розпакування залежить від версії рантайму, виконуваного файлу, платформи, оточення та доступних для запису місць. Цей режим спочатку створений для нативних модулів (FFI, Саморозпакування — не пасивне розпакування: запуск пропатченого бінарника також виконує застосунок. Для недовірених бінарників використовуйте одноразове ізольоване середовище без секретів і доступу до мережі. Не запускайте їх на своїй робочій машині. Додаємо Вставлення байтів у метадані без коригування довжин і зміщень може пошкодити бінарник. Потрібно врахувати структуру даних і розмір файлу: Використовуємо Отримуємо три значення: Знаючи зміщення секції, читаємо початкову сигнатуру (magic), довжину та JSON: Якщо довжина JSON змінюється, зсуваємо байти після нього. Використовуємо Оновлюємо записаний розмір ресурсу. Код патчера: GitHub Gist Єдина зовнішня залежність. Усе інше — стандартна бібліотека Python. Змінюємо метадані, щоб увімкнути вбудовану в Deno процедуру витягування VFS: Цей спосіб витягує вбудовані файли VFS, а не весь початковий репозиторій чи всі сховища модулів. Тут він обмежений Windows-бінарниками Deno 2.7.0; запуск зміненого бінарника також запускає застосунок.--self-extracting для deno compile. Розбір нижче обмежено Deno v2.7.0 та Windows-бінарниками (PE). Python-скрипт використовує pefile: це не екстрактор ELF/Mach-O, а сумісність із пізнішими версіями Deno не перевірена.--self-extracting, тому цей спосіб до них незастосовний. Для витягування вбудованих даних потрібен окремий підхід, наприклад розбір відповідного бінарного формату. Це не означає, що такі рантайми взагалі ніколи не записують файли на диск.Що таке
deno compiledeno compile збирає проєкт у єдиний самодостатній виконуваний файл:deno compile --allow-net --allow-read src/main.ts.exe (Windows), ELF (Linux) або Mach-O (macOS), що містить:Структура автономного виконуваного файлу
Секція
d3n0l4ndD3N0L4ND (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Два режими запуску
d3n0l4nd та десеріалізує вміст. Далі можливі два шляхи.Типовий режим — читання вбудованої VFS
fn find_section() -> Result<&'static [u8], AnyError> {
match libsui::find_section("d3n0l4nd") {
Ok(Some(data)) => Ok(data), // прямий вказівник у пам'ять процесу
...
}
}FileBackedVfs обчислює зміщення та повертає зріз вбудованих байтів, а не відкриває окремий файл:let start = self.start_file_offset + file.offset;
let end = start + file.len;
Ok(Cow::Borrowed(&self.data[start..end]))
// Жодного дискового IO — просто індекс у масив байтРежим саморозпакування — розпакування на диск
"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))
};.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 і код, що її викликає, у вихідному коді потрібної версії рантайму, а потім підтвердьте розташування в ізольованому середовищі..node-аддони), які неможливо завантажити прямо з пам’яті.Патчинг: вмикаємо self-extraction у готовому бінарнику
"self_extracting": "<hash>" до JSON метаданих усередині бінарника. Під час наступного запуску Deno розпакує файли VFS на диск вбудованою процедурою, якщо маркер .done ще не існує.Що потрібно врахувати під час зміни бінарника
u64 з точним розміром у байтах. Додавання поля збільшує JSON на ~37 байт → значення u64 перестає збігатися.Size. У PE-заголовку записано розмір ресурсу D3N0L4ND. Вставка байт ламає і його.Алгоритм покроково
Крок 1. Знайти ресурс
D3N0L4ND у PEpefile у режимі 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 — де у файлі починається секція d3n0l4ndsize — поточний розмір секціїstruct_file_offset — зміщення структури IMAGE_RESOURCE_DATA_ENTRY у файлі; її поле Size оновлюємо на кроці 6Крок 2. Прочитати 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. Зсунути хвіст файлу
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.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))Повний код
Встановлення залежностей
pip install pefileПідсумок
d3n0l4nd"self_extracting": "<hash>" до цього JSONmmap.move()u64) і розмір PE-ресурсу (u32)extract_vfs_to_disk() → вбудовані файли VFS на диску, якщо розпакування не пропущене через уже наявний .done