Главная / Блог / Анализ ELF бинарников в CTF: пошаговый разбор с readelf, strings и objdump

13 мин.00

Анализ ELF бинарников в CTF: пошаговый разбор с readelf, strings и objdump

Анализ ELF бинарников в CTF: пошаговый разбор с readelf, strings и objdump

Анализ ELF бинарников в CTF: пошаговый разбор с readelf, strings и objdump

На соревновании получаешь бинарник — 6 КБ, stripped, а file сообщает: архитектура Motorola Coldfire, OS/ABI Novell Modesto. Новичок пишет организаторам — файл битый. Опытный участник открывает readelf -h, видит e_phoff: 4022250974 (при размере файла 6 КБ — явная аномалия), распознаёт в hex-значении 0xdeadbeef, патчит три поля в hex-редакторе и получает рабочий x86-64 бинарник с флагом. Это уровень 3 из CTF-челленджа книги "Practical Binary Analysis" Денниса Андриесса, и решается он исключительно пониманием формата ELF и тремя стандартными утилитами Linux: readelf, strings, objdump.

Требования к окружению

Все примеры воспроизводятся в минимальном окружении:

  • ОС: Ubuntu 20.04+ или Kali Linux (любая актуальная версия)
  • RAM: 2 ГБ минимум — для статического анализа бинарников до 100 МБ хватает за глаза
  • Пакеты: binutils (включает readelf, objdump, strings, nm), file, xxd. Пакет binutils — часть GNU, обновляется с каждым релизом GCC-тулчейна. В большинстве дистрибутивов стоит из коробки
  • Права: обычный пользователь, root для статического анализа не нужен
  • Сеть: offline-режим подходит полностью, всё работает локально

Проверить наличие: which readelf objdump strings file. Если чего-то нет — sudo apt install binutils file.

Первый контакт с бинарником: file и поверхностный анализ ELF формата

Каждый разбор ELF бинарников на CTF начинается с команды file. Не формальность — её вывод сразу даёт три критических ориентира для выбора дальнейшей стратегии.

Типичный вывод: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, stripped. Разберём, что каждое поле значит для решения задачи.

ELF 64-bit LSB — формат, разрядность и порядок байтов. LSB (little-endian) означает: при ручном чтении hex-дампа через xxd младший байт идёт первым. Последовательность a8 2b 40 00 в дампе — это адрес 0x00402ba8. Путаница с порядком байтов — одна из самых частых ошибок при ручном разборе заголовков. Я сам на это наступал не раз.

x86-64 — архитектура процессора. Определяет набор инструкций для дизассемблера. Если здесь указано что-то неожиданное (Motorola Coldfire, ARM, MIPS при работе на x86-машине) — либо задача на кросс-архитектурный анализ, либо поле e_machine в ELF Header намеренно подпорчено. В CTF второй вариант встречается регулярно.

dynamically linked vs statically linked — принципиальное различие. Динамически слинкованный бинарник зависит от системных библиотек, и таблица .dynsym содержит имена импортированных функций — сильно упрощает понимание логики. Статически слинкованный включает код библиотек внутри: файл жирнее, а в выводе objdump -d тысячи функций из libc перемешаны с пользовательским кодом. Искать main в таком — то ещё удовольствие.

stripped vs not stripped — ключевое поле для CTF. Not stripped сохраняет символьную таблицу: имена функций, переменных, метки. readelf -s покажет всё, а objdump -d разметит функции по именам. Stripped-бинарник лишён этой информации — вместо <main> в дизассемблере безымянные адреса. В классификации MITRE ATT&CK удаление символов соответствует технике Stripped Payloads (T1027.008, Defense Evasion): атакующие удаляют метаданные из payload, чтобы затруднить реверс. На CTF stripped-бинарники встречаются чаще — организаторы не хотят отдавать подсказки бесплатно.

Перед запуском бинарника выставьте execute-бит: chmod +x ./challenge. Скачанные файлы в Linux по умолчанию не имеют права на выполнение — это нормальное поведение, а не признак проблемы.

readelf: анализ заголовков и структуры ELF бинарника

readelf показывает метаданные ELF-файла в человекочитаемом виде. В отличие от objdump, он не дизассемблирует код — только структурные данные. Для reverse engineering CTF заданий это инструмент первой линии после file.

Чтение ELF Header: ключевые поля для CTF

Команда readelf -h ./challenge выводит ELF Header — первые 64 байта файла (для 64-bit). Разберём поля, которые непосредственно влияют на решение задачи.

Entry point address — адрес начала выполнения. Нюанс, на котором спотыкаются: это НЕ адрес функции main. Entry point указывает на _start, которая вызывает __libc_start_main, и уже та передаёт управление в main. Для CTF это критично в stripped-бинарниках: entry point — единственный известный адрес. От него можно добраться до main через objdump, найдя вызов __libc_start_main и посмотрев, какой адрес загружается в регистр rdi (первый аргумент по x86-64 calling convention) перед этим вызовом.

TypeEXEC или DYN. Для EXEC адреса в выводе objdump совпадают с runtime-адресами. Для DYN (PIE-бинарник) загрузчик разместит код по случайному базовому адресу из-за ASLR, и адреса из objdump не совпадут с тем, что покажет gdb. При динамическом анализе DYN-бинарника придётся вычислять реальные адреса: адрес в gdb = базовый адрес загрузки + смещение из objdump.

Start of program headers (e_phoff) и Start of section headers (e_shoff) — смещения к таблицам. Если e_phoff содержит значение, превышающее размер файла (классика — 0xdeadbeef), заголовок повреждён намеренно. Стандартный CTF-приём. Починка: открыть файл в hex-редакторе (bvi, hexedit или xxd -r) и записать корректное значение. Для стандартного 64-bit ELF таблица program headers обычно начинается сразу после ELF Header, то есть e_phoff = 0x40 (64 байта).

Machine — поле e_machine. Значение 0x3e — AMD64. Если стоит 0x34 (Motorola Coldfire) для бинарника, который явно предназначен для x86-64 — поле подменено. Патч: записать 0x3e по смещению 0x12 от начала файла.

Секции и сегменты ELF: в чём разница и что искать

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

Секции (sections) — представление для линкера и аналитика. Каждая имеет имя и тип. readelf -S ./challenge показывает полный список. Для CTF ключевые:

  • .text — машинный код программы. Тип должен быть SHT_PROGBITS. Если readelf -S показывает SHT_NOBITS для .text — тип секции подменён, и objdump откажется дизассемблировать. Починка: найти sh_type для .text в таблице section headers и записать 0x01
  • .rodata — read-only данные: строковые литералы, константные массивы, нередко фрагменты флага
  • .data — инициализированные переменные, иногда тут валяются захардкоженные ключи
  • .symtab — полная символьная таблица (отсутствует в stripped-бинарниках)
  • .dynsym — динамические символы, сохраняется даже в stripped-бинарниках с динамической линковкой

Сегменты (segments) — представление для загрузчика ОС. Сегмент LOAD с флагами R E — код, с RW — данные. readelf -l ./challenge покажет program headers. Для большинства CTF-задач секции важнее сегментов.

Отдельно проверьте динамические символы: readelf --dyn-syms ./challenge. Даже в stripped-бинарнике эта таблица покажет имена импортированных функций. На что смотреть:

  • strcmp / strncmp — программа сравнивает строки, одна из них может быть флагом
  • getenv — флаг читается из переменной окружения
  • rand / srand — генерация псевдослучайных значений, вывод может меняться при каждом запуске
  • system — вызов внешней команды (например, md5sum)
  • ptrace — вероятный anti-debug (MITRE ATT&CK T1622, Debugger Evasion)

strings: извлечение данных из бинарного файла

strings находит в бинарнике все последовательности печатных ASCII-символов длиной от 4 знаков (по умолчанию). Самый быстрый способ получить первые зацепки — иногда флаг лежит в открытом виде и задача решается за 10 секунд.

Базовый вызов — strings ./challenge. Но для CTF есть нюансы. По умолчанию strings сканирует только инициализированные секции с данными. Флаг -a заставляет сканировать весь файл целиком, включая заголовки и padding между секциями. Если флаг спрятан в нестандартном месте, без -a его пропустите. Минимальную длину строки меняем через -n: strings -n 8 ./challenge покажет только строки от 8 символов, отсеивая мусор.

Для поиска флага конкретного CTF: strings -a ./challenge | grep -iE 'flag|ctf|correct|wrong|key'. Что может всплыть:

  • Прямой флаг — на beginner-уровнях: flag{this_was_easy}
  • Строки-маркеры — "What's the flag?", "That's not it...", "Correct!" указывают на логику проверки ввода
  • Подсказки на алгоритм — md5sum, libcrypto намекают на хэширование или шифрование
  • Зашифрованные данные — строки вроде XaDht-+1432=/as4?0129mklqt!@cnz^ могут быть XOR-результатом
  • Имена переменных окружения — строка FLAG может означать, что программа вызывает getenv("FLAG")

Если strings ничего полезного не дал — переходите к objdump -s --section .rodata ./challenge. Эта команда покажет hex-дамп секции .rodata с ASCII-интерпретацией. Байты, которые не формируют строку длиной 4+, strings пропускает, а в hex-дампе видны даже однобайтовые фрагменты. Именно так решается класс задач, где флаг разбит на отдельные байты, разделённые нулевыми символами — каждая пара видна в hex-колонке.

objdump: дизассемблирование ключевых функций

objdump превращает машинный код в ассемблерные инструкции. Для CTF это тот инструмент, который показывает, что программа делает с вводом пользователя: где сравнивает строки, какие условия проверяет, какие преобразования выполняет.

Паттерны в дизассемблере для reverse engineering CTF заданий

objdump -d ./challenge дизассемблирует все исполняемые секции. Для крупных бинарников вывод массивный — статически слинкованный hello world содержит тысячи функций из libc. На практике полезнее objdump -dj .text ./challenge — только секция .text.

Если бинарник not stripped, ищите метку <main>. Если stripped, начинайте с entry point (из readelf -h): найдите инструкцию call к __libc_start_main и определите адрес main из загрузки первого аргумента.

На CTF начального и среднего уровня не нужно читать каждую инструкцию. Ищите конкретные паттерны:

401acf: e8 3c fc ff ff  callq  401710 <getopt_long@plt>
401ad4: 83 f8 ff        cmp    $0xffffffff,%eax
401add: 83 f8 62        cmp    $0x62,%eax
401ae8: 83 f8 73        cmp    $0x73,%eax
401af8: 83 f8 75        cmp    $0x75,%eax
401b04: 83 f8 76        cmp    $0x76,%eax

Тут 0x62 — ASCII-код b, 0x73s, 0x75u, 0x76v. Программа парсит аргументы командной строки через getopt_long и обрабатывает флаги -b, -s, -u, -v. Этот приём описан на Reverse Engineering Stack Exchange: даже без документации бинарника его интерфейс восстанавливается из дизассемблированного switch-конструкта.

Перед call strcmp в регистры rdi и rsi загружаются адреса двух строк. Один из адресов может указывать на .rodata — найдите его в hex-дампе секции, и у вас эталонная строка, с которой сравнивается ввод.

XOR-циклы — последовательность mov → xor → cmp в цикле — классика шифрования/дешифрования флага. Программа XOR'ит пользовательский ввод с ключом и сравнивает результат с эталоном. Для решения нужны эталонная последовательность байтов и ключ — обе части обычно лежат в .rodata или .data. XOR'нуть их между собой — и флаг готов. Этот паттерн соответствует технике Deobfuscate/Decode Files or Information (T1140, MITRE ATT&CK) — в реальных вредоносных бинарниках строки часто обфусцированы аналогичным образом.

Инспекция секции .rodata для извлечения флагов

Отдельного внимания заслуживает objdump -s --section .rodata ./challenge. Содержимое секции в формате hex + ASCII:

4006c0 01000200 30330034 66006334 00663600 ....03.4f.c4.f6.
4006d0 61350033 36006632 00626600 37340066 a5.36.f2.bf.74.f
4006e0 38006436 00643300 38310036 63006466 8.d6.d3.81.6c.df
4006f0 00383800                             .88.

В этом примере (из CTF-уровня книги "Practical Binary Analysis") между нулевыми байтами спрятаны пары символов: 03, 4f, c4, f6, a5, 36, f2, bf и так далее. Конкатенация всех пар даёт 32-символьную строку — готовый флаг. strings эти фрагменты не покажет, потому что каждый элемент короче порога в 4 символа. А в hex-дампе — вот они, как на ладони.

Пошаговый workflow статического анализа бинарников

Объединяем инструменты в рабочую последовательность. Этот workflow я использую на каждом CTF для задач категории reverse, и он закрывает 80% задач уровня beginner-intermediate.

  1. file ./challenge — определяем формат, архитектуру, тип линковки, stripped или нет. Записываем всё, что выглядит аномально (неожиданная архитектура, странный OS/ABI)
  2. strings -a ./challenge | grep -iE 'flag|ctf|correct|key' — быстрый поиск флага и маркерных строк. Если флаг найден — задача решена, идём пить кофе
  3. readelf -h ./challenge — читаем ELF Header. Проверяем entry point, значения e_phoff и e_machine. Аномалии в этих полях — повод открыть hex-редактор
  4. readelf -S ./challenge — список секций. Есть ли .symtab (признак not stripped)? Какой размер у .rodata? Не подменён ли тип .text на NOBITS?
  5. readelf --dyn-syms ./challenge — динамические символы. Ищем strcmp, getenv, rand, ptrace и другие функции, указывающие на логику
  6. objdump -s --section .rodata ./challenge — hex-дамп .rodata. Ищем фрагменты флага, ключи шифрования, строки, которые strings пропустил
  7. objdump -d ./challenge | less — дизассемблирование. От entry point или <main> ищем паттерны: вызовы strcmp, XOR-циклы, ветвление по условию

На шаге 3, если readelf выдаёт ошибки чтения заголовков — нужен патчинг. Минимальный набор полей для проверки: e_ident[EI_OSABI] (должен быть 0x00 для System V), e_machine (должен быть 0x3e для AMD64), e_phoff (должен указывать на реальное смещение внутри файла). Для ELF64 размер ELF Header — 64 байта, и program headers обычно идут сразу после, то есть e_phoff = 0x40.

Ограничения инструментов и когда переходить к Ghidra

readelf, strings и objdump — чистый статический анализ. Есть классы CTF-задач, где этих инструментов объективно не хватает.

Упакованные бинарники — если файл сжат пакером (UPX или кастомный — MITRE ATT&CK T1027.002, Software Packing), objdump -d покажет код распаковщика вместо реальной логики. Для UPX достаточно upx -d ./challenge, но кастомные пакеры — это уже динамический анализ через gdb.

Динамические ключи — если ключ шифрования не захардкожен в .rodata, а генерируется в runtime (из адреса функции, системного времени или состояния PRNG), статический анализ не даст его значение. Нужен отладчик — ставим breakpoint и снимаем значение прямо из регистра.

Anti-debug и anti-analysis — некоторые задачи проверяют наличие отладчика через ptrace (T1622, Debugger Evasion) или определяют запуск в виртуальной среде (T1497.001, System Checks). Статические инструменты не покажут результат этих проверок, но readelf --dyn-syms подскажет: видите ptrace в импортах — готовьтесь к anti-debug.

Самомодифицирующийся код — программа перезаписывает собственные инструкции во время выполнения. objdump показывает только начальное состояние — по сути, «в чём мать родила». Реальная логика появится только при запуске под gdb с breakpoint на нужном адресе.

Для этих случаев переходите к gdb (динамический анализ), radare2 (продвинутый консольный анализ, версия 5.x+, активно поддерживается) или Ghidra (декомпиляция в псевдо-C, бесплатный инструмент от NSA). Но фундамент остаётся: даже при работе в Ghidra данные из readelf -h и readelf --dyn-syms дают контекст, который декомпилятор не всегда отображает явно.

Инструмент Что даёт Когда достаточен Когда не хватает
file Тип, архитектура, линковка Всегда первый шаг Не анализирует содержимое
strings Читаемые строки Флаг в открытом виде Зашифрованные/фрагментированные данные
readelf Структура ELF, символы Разведка структуры, повреждённые заголовки Не показывает логику кода
objdump Дизассемблирование, hex-дамп секций Простые сравнения, XOR, .rodata Обфусцированный/упакованный код
Ghidra / r2 Декомпиляция, граф вызовов Сложная логика, anti-debug Не заменяет базовый triage

Три инструмента из binutils — не «набор для начинающих». Это рабочий арсенал, который закрывает первые 15-20 минут анализа любого ELF-бинарника. На практике я вижу один и тот же паттерн: участники ставят Ghidra, открывают бинарник, ждут декомпиляцию — и тонут в псевдо-C, не понимая контекста. Потому что не посмотрели readelf -h и не знают, stripped бинарник или нет, PIE включён или нет, какие функции импортированы. Декомпилятор без контекста — шум, а не информация.

На CTF разница между первым и десятым местом на beginner-intermediate уровне — не экзотические инструменты, а скорость triage. Участник, который за 30 секунд прогоняет file → strings → readelf -h → readelf --dyn-syms и получает полную картину, решает задачу быстрее того, кто сразу ныряет в граф вызовов. Workflow важнее инструментария, а readelf, strings и objdump — его фундамент.

Если ваши задачи на CTF ограничиваются только reverse — попробуйте провернуть тот же triage на pwn-задаче с .rodata-строками. Удивитесь, сколько информации лежит на поверхности.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...