Як зазирнути в двійкові файли з командного рядка Linux

У вас є невідомий файл? Утиліта `file` в Linux допоможе вам визначити його тип. Але якщо це двійковий файл, можливо отримати набагато більше інформації. Існує ряд інструментів, які допоможуть вам його проаналізувати. Зараз ми розглянемо, як використовувати деякі з них.

Ідентифікація типів файлів

Файли зазвичай мають певні ознаки, які дозволяють програмному забезпеченню ідентифікувати їхній тип та структуру даних. Нелогічно намагатися відкрити файл PNG в програвачі MP3, тому важливо, щоб файл мав унікальний ідентифікатор.

Таким ідентифікатором може бути кілька байтів у заголовку файлу. Це дозволяє файлу чітко вказувати свій формат і зміст. Іноді тип файлу визначається за внутрішньою структурою даних, що відома як файлова архітектура.

Деякі операційні системи, наприклад Windows, повністю покладаються на розширення файлів. Це може бути зручно, але Windows вважає, що файл з розширенням DOCX є файлом обробки тексту DOCX. Linux, на відміну від цього, вимагає доказів і перевіряє вміст файлу, щоб визначити його тип.

Інструменти, що тут описані, вже встановлені в дистрибутивах Manjaro 20, Fedora 21 та Ubuntu 20.04, які ми використовували для написання цієї статті. Розпочнемо наше дослідження з використання команди `file`.

Використання команди `file`

У нас є набір різних типів файлів у поточному каталозі. Це суміш документів, початкового коду, виконуваних файлів та текстових файлів.

Команда `ls` покаже нам вміст каталогу, а параметр `-hl` (розмір у зручному для читання форматі, довгий список) – розмір кожного файлу:

ls -hl

Спробуємо команду `file` на деяких з них і подивимося, що отримаємо:

file build_instructions.odt
file build_instructions.pdf
file COBOL_Report_Apr60.djvu

Усі три типи файлів ідентифіковані правильно. Якщо можливо, `file` надає додаткову інформацію. Наприклад, що файл PDF має формат версії 1.5.

Навіть якщо перейменувати файл ODT, надавши йому розширення XYZ, він все одно буде правильно ідентифікований як у файловому менеджері, так і за допомогою команди `file`.

Файловий менеджер відображає правильну іконку. У командному рядку `file` ігнорує розширення та аналізує вміст файлу, щоб визначити його тип:

file build_instructions.xyz

Застосування `file` до медіафайлів, таких як зображення та музика, зазвичай надає інформацію про їхній формат, кодування, роздільну здатність тощо:

file screenshot.png
file screenshot.jpg
file Pachelbel_Canon_In_D.mp3

Цікаво, що навіть у випадку текстових файлів, `file` не оцінює файл за його розширенням. Наприклад, якщо у вас є файл з розширенням “.c”, що містить звичайний текст, а не початковий код, `file` не вважатиме його файлом початкового коду C:

file function+headers.h
file makefile
file hello.c

`file` правильно ідентифікує заголовний файл (.h) як частину набору файлів початкового коду C, а також розуміє, що makefile є скриптом.

Аналіз двійкових файлів

Двійкові файли є більш “чорними скриньками”, ніж інші типи файлів. За допомогою відповідного програмного забезпечення можна переглядати зображення, відтворювати звукові файли та відкривати документи. Однак двійкові файли є більш складним завданням.

Наприклад, файли “hello” та “wd” є виконуваними двійковими файлами. Це програми. Файл “wd.o” є об’єктним файлом. Коли початковий код компілюється, створюються об’єктні файли. Вони містять машинний код, який комп’ютер виконуватиме при запуску готової програми, а також інформацію для компонувальника. Компонувальник перевіряє кожен об’єктний файл на наявність викликів функцій з бібліотек. Він зв’язує їх з бібліотеками, які використовує програма. Результатом цього процесу є виконуваний файл.

Файл “watch.exe” – це двійковий виконуваний файл, скомпільований для запуску в Windows:

file wd
file wd.o
file hello
file watch.exe

Почнемо з останнього: `file` повідомляє, що “watch.exe” є виконуваною консольною програмою PE32+ для архітектури x86 в Microsoft Windows. PE означає Portable Executable Format, який має 32- та 64-бітові версії. PE32 – це 32-бітова версія, а PE32+ – 64-бітова.

Інші три файли ідентифіковані як Executable and Linkable Format (ELF). Це стандарт для виконуваних файлів та спільних об’єктів, таких як бібліотеки. Незабаром ми розглянемо формат заголовка ELF.

Цікаво, що два виконуваних файли (“wd” та “hello”) ідентифіковані як Linux Standard Base (LSB) спільні об’єкти, а об’єктний файл “wd.o” – як переміщуваний LSB. Відсутність слова “виконуваний” вказує на це.

Об’єктні файли є переміщуваними, тобто їхній код може бути завантажений у будь-яке місце пам’яті. Виконувані файли позначені як спільні об’єкти, оскільки вони створені компонувальником з об’єктних файлів таким чином, що вони успадковують цю можливість.

Це дозволяє рандомізації адресного простору (ASLR) завантажувати виконувані файли у пам’ять за довільними адресами. Звичайні виконувані файли мають адресу завантаження, що закодована в їхньому заголовку, яка вказує, де вони завантажуються в пам’ять.

ASLR – це техніка безпеки. Завантаження виконуваних файлів у пам’ять за передбачуваними адресами робить їх вразливими до атак. Зловмисники завжди знатимуть їхні точки входу та розташування їхніх функцій. Незалежні від позиції виконувані файли (PIE), що розміщуються за довільними адресами, усувають цю вразливість.

Якщо ми скомпілюємо нашу програму за допомогою компілятора gcc з параметром `-no-pie`, ми отримаємо звичайний виконуваний файл.

Параметр `-o` (вихідний файл) дозволяє нам вказати ім’я нашого виконуваного файлу:

gcc -o hello -no-pie hello.c

Проаналізуємо новий виконуваний файл за допомогою `file` і подивимося, що змінилося:

file hello

Розмір виконуваного файлу залишився тим самим (17 КБ):

ls -hl hello

Тепер двійковий файл ідентифікується як стандартний виконуваний файл. Це зроблено лише для демонстрації. Якщо ви компілюєте програми таким чином, ви втратите переваги ASLR.

Чому виконуваний файл такий великий?

Наша програма `hello` має розмір 17 КБ. Це не дуже багато, але все відносно. Початковий код складає лише 120 байт:

cat hello.c

Що збільшує розмір двійкового файлу, якщо все, що він робить, це виводить один рядок у терміналі? Ми знаємо, що є заголовок ELF, але для 64-бітового двійкового файлу це лише 64 байти. Очевидно, що є ще щось:

ls -hl hello

Давайте скануємо двійковий файл за допомогою `strings` як перший крок, щоб побачити, що в ньому є. Виведемо результат у less:

strings hello | less

У двійковому файлі є багато рядків, окрім “Hello, Geek world!” з нашого початкового коду. Більшість з них є мітками регіонів у двійковому файлі, а також іменами та інформацією про зв’язки спільних об’єктів. Сюди входять бібліотеки та функції в цих бібліотеках, від яких залежить двійковий файл.

Команда `ldd` показує нам залежності від спільних об’єктів:

ldd hello

У виводі є три записи, і два з них містять шлях до каталогу (перший не містить):

`linux-vdso.so`: Віртуальний динамічний спільний об’єкт (VDSO) є механізмом ядра, який дозволяє отримати доступ до набору підпрограм простору ядра з двійкового файлу простору користувача. Це дозволяє уникнути накладних витрат на перемикання контексту між режимом користувача і ядра. Спільні об’єкти VDSO використовують формат Executable and Linkable Format (ELF), що дозволяє їм динамічно зв’язуватися з двійковим файлом під час виконання. VDSO динамічно розподіляється і використовує переваги ASLR. Можливість VDSO передбачена стандартом GNU C Library, якщо ядро підтримує схему ASLR.
`libc.so.6`: Це спільний об’єкт бібліотеки GNU C.
`/lib64/ld-linux-x86-64.so.2`: Це динамічний компонувальник, який використовується двійковим файлом. Динамічний компонувальник опитує двійковий файл, щоб визначити, які залежності він має. Він завантажує ці спільні об’єкти в пам’ять. Він готує двійковий файл до запуску і забезпечує можливість знаходити залежності в пам’яті та мати до них доступ. Потім він запускає програму.

Заголовок ELF

Ми можемо переглянути та декодувати заголовок ELF за допомогою утиліти `readelf` з параметром `-h` (заголовок файлу):

readelf -h hello

Заголовок інтерпретується для нас.

Перший байт усіх двійкових файлів ELF має шістнадцяткове значення 0x7F. Наступні три байти мають значення 0x45, 0x4C та 0x46. Перший байт є індикатором того, що файл є двійковим файлом ELF. Для більшої ясності, наступні три байти визначають “ELF” у ASCII:

Клас: вказує, чи є двійковий файл 32- чи 64-бітовим (1=32, 2=64).
Дані: вказує порядок байтів. Endian кодування визначає спосіб зберігання багатобайтових чисел. У кодуванні big-endian число зберігається з найзначущих байтів. У кодуванні little-endian число зберігається з молодших байтів.
Версія: версія ELF (наразі це 1).
OS/ABI: представляє тип інтерфейсу бінарної програми. Це визначає інтерфейс між двома бінарними модулями, такими як програма та спільна бібліотека.
Версія ABI: версія ABI.
Тип: тип двійкового файлу ELF. Загальні значення: ET_REL для переміщуваного ресурсу (наприклад, об’єктний файл), ET_EXEC для виконуваного файлу, скомпільованого з прапорцем `-no-pie`, і ET_DYN для виконуваного файлу з підтримкою ASLR.
Машина: архітектура набору інструкцій. Це вказує на цільову платформу, для якої створено двійковий файл.
Версія: для цієї версії ELF завжди встановлено значення 1.
Адреса точки входу: адреса пам’яті в двійковому файлі, з якої починається виконання.

Інші записи – це розміри та кількість регіонів і розділів у двійковому файлі, щоб можна було розрахувати їхнє розташування.

Подивимося на перші вісім байтів двійкового файлу за допомогою `hexdump`. Параметр `-C` (канонічний) надасть ASCII представлення байтів разом з їхніми шістнадцятковими значеннями, а параметр `-n` (кількість) дозволяє вказати, скільки байтів ми хочемо бачити:

hexdump -C -n 8 hello

`objdump` і детальний перегляд

Для більш детального перегляду можна використовувати команду `objdump` з параметром `-d` (дизасемблювання):

objdump -d hello | less

Це дизасемблює виконуваний машинний код і показує його у шістнадцяткових байтах разом з відповідним кодом на мові асемблера. Адреса початку кожного рядка відображається в крайньому лівому куті.

Це корисно лише якщо ви вмієте читати асемблер, або вам цікаво, що відбувається під капотом. Виводу багато, тому ми скористалися `less`.

Компіляція та зв’язування

Існує багато способів скомпілювати двійковий файл. Наприклад, розробник обирає, чи включати інформацію для налагодження. Спосіб зв’язування двійкового файлу також впливає на його вміст та розмір. Якщо двійковий файл має посилання на спільні об’єкти як зовнішні залежності, він буде меншим, ніж файл зі статично пов’язаними залежностями.

Більшість розробників вже знайомі з командами, які ми тут розглянули. Іншим ці команди можуть надати прості способи дослідити та зрозуміти, що знаходиться всередині двійкової “чорної скриньки”.