July 19, 2019

Рецепты для ELFов

На русском языке довольно мало информации про то, как работать с ELF-файлами (Executable and Linkable Format — основной формат исполняемых файлов Linux и многих Unix-систем). Не претендуем на полное покрытие всех возможных сценариев работы с эльфами, но надеемся, что информация будет полезна в виде справочника и сборника рецептов для программистов и реверс-инженеров.

Подразумевается, что читатель на базовом уровне знаком с форматом ELF (в противном случае рекомендуем цикл статей Executable and Linkable Format 101).

Под катом будут перечислены инструменты для работы, описаны приемы для чтения метаинформации, модификации, проверки и размножения создания эльфов, а также приведены ссылки на полезные материалы.

— Я тоже эльф… Синий в красный… Эльфы очень терпеливы… Синий в красный… А мы эльфы!.. Синий в красный… От магии одни беды…
(с) Маленькое королевство Бена и Холли

Инструменты

В большинстве случаев примеры можно выполнить как на Linux, так и на Windows.

В рецептах мы будем использовать следующие инструменты:

  • утилиты из набора binutils (objcopy, objdump, readelf, strip);
  • фреймворк radare2;
  • hex-редактор с поддержкой шаблонов файлов (в примерах показан 010Editor, но можно использовать, например, свободный Veles);
  • Python и библиотеку LIEF;
  • другие утилиты (ссылки указаны в рецепте).

Тестовые эльфы

В качестве «подопытного» будем использовать ELF-файл simple из таска nutcake's PieIsMyFav на crackmes.one, но подойдёт любой представитель «эльфийского» семейства. Если готовый файл с требуемыми характеристиками не был найден в свободном доступе, то будет приведён способ создания такого эльфа.

«Свободных» эльфов можно также найти по ссылкам:

Чтение, получение информации

Тип файла, заголовок, секции

В зависимости от задачи интерес могут представлять:

  • тип файла (DYN — библиотека, EXEC — исполняемый, RELOC — линкуемый);
  • целевая архитектура (E_MACHINE — x86_64, x86, ARM и т.д.);
  • точка входа в приложение (Entry Point);
  • информация о секциях.

010Editor

HEX-редактор 010Editor предоставляет систему шаблонов. Для ELF-файлов шаблон называется, как ни странно, ELF.bt и находится в категории Executable (меню Templates — Executable).
Интерес может представлять, например, точка входа в исполняемый файл (entry point) (записана в заголовке файла).

readelf

Утилиту readelf можно считать стандартом де-факто для получения сведений об ELF-файле.

  • Прочитать заголовок файла:
    $ readelf -h simple

Результат команды

  • Прочитать информацию о сегментах и секциях:
    $ readelf -l -W simple

Результат команды

  • Прочитать информацию о секциях:
    $ readelf -S -W simple

Результат команды

  • Прочитать информацию о символах:
    $ readelf -s -W simple

Результат команды

Опция -W нужна для увеличения ширины консольного вывода (по умолчанию, 80 символов).

LIEF

Прочитать заголовок и информацию о секциях можно с использованием кода на Python и библиотеки LIEF (предоставляет API не только для Python):

import lief

binary = lief.parse("simple.elf")
header = binary.header

print("Entry point: %08x" % header.entrypoint)
print("Architecture: ", header.machine_type)

for section in binary.sections:
    print("Section %s - size: %s bytes" % (section.name, section.size)

Информация о компиляторе

Для получения информации о компиляторе и сборке следует смотреть секции .comment и .note.

objdump

$ objdump -s --section .comment simple

Результат команды

readelf

$ readelf -p .comment simple

Результат команды

$ readelf -n simple

Результат команды

LIEF

import lief
binary = lief.parse("simple")
comment = binary.get_section(".comment")
print("Comment: ", bytes(comment.content))

Я вычислю тебя по… RPATH

Эльфы могут сохранять пути для поиска динамически подключаемых библиотек. Чтобы не задавать системную переменную LD_LIBRARY_PATH перед запуском приложения, можно просто «вшить» этот путь в ELF-файл.

Для этого используется запись в секции .dynamic с типом DT_RPATH или DT_RUNPATH (см. главу Directories Searched by the Runtime Linker в документации).

И будь осторожен, юный разработчик, не «спали» свою директорию проекта!

Как появляется RPATH?

Основная причина появления RPATH-записи в эльфе — опция -rpath линковщика для поиска динамической библиотеки. Примерно так:

$ gcc -L./lib -Wall -Wl,-rpath=/run/media/pablo/disk1/projects/cheat_sheets/ELF/lib/ -o test_rpath.elf bubble_main.c -lbubble

Такая команда создаст в секции .dynamic RPATH-запись со значением /run/media/pablo/disk1/projects/cheat_sheets/ELF/lib/.

readelf

Посмотреть элементы из секции .dynamic (среди которых есть и RPATH) можно так:

$ readelf -d test_rpath.elf 

Результат команды

LIEF

С помощью библиотеки LIEF также можно прочитать RPATH-запись в эльфе:

import lief
from lief.ELF import DYNAMIC_TAGS

elf = lief.parse("test_rpath.elf")

if elf.has(DYNAMIC_TAGS.RPATH):
    rpath = next(filter(lambda x: x.tag == DYNAMIC_TAGS.RPATH, elf.dynamic_entries))
    for path in rpath.paths:
        print(path)
else:
    print("No RPATH in ELF") 

Почитать про секцию .dynamic

Проверка эльфа на безопасность

Скрипт проверки безопасности checksec.sh от исследователя Tobias Klein (автора книги A Bug Hunter's Diary) не обновлялся с 2011 года. Данный скрипт для ELF-файлов выполняет проверку наличия опций RelRO (Read Only Relocations), NX (Non-Executable Stack), Stack Canaries, PIE (Position Independent Executables) и для своей работы использует утилиту readelf.

Можно сделать свой аналог на коленке Python и LIEF (чуть короче прародителя и с дополнительной проверкой опции separate-code):

import lief
from lief.ELF import DYNAMIC_TAGS, SEGMENT_TYPES

def filecheck(filename):
    binary = lief.parse(filename)

    # check RELRO
    if binary.has(SEGMENT_TYPES.GNU_RELRO):
        print("+ Full RELRO") if binary.has(DYNAMIC_TAGS.BIND_NOW) else print("~ Partial RELRO")
    else:
        print("- No RELRO")            

    # check for stack canary support
    print("+ Canary found") if binary.has_symbol("__stack_chk_fail") else print("- No canary found")

    # check for NX support (check X-flag for GNU_STACK-segment)
    print("+ NX enabled") if binary.has_nx else print("- NX disabled")

    # check for PIE support
    print("+ PIE enabled") if binary.is_pie else print("- No PIE")

    # check for rpath / run path
    print("+ RPATH") if binary.has(DYNAMIC_TAGS.RPATH) else print("- No RPATH")
    print("+ RUNPATH")if binary.has(DYNAMIC_TAGS.RUNPATH) else print("- No RUNPATH")

    # check separate-code option
    if set(binary.get_section('.text').segments) == set(binary.get_section('.rodata').segments):
        print("- Not Separated Code Sections")
    else:
        print("+ Separated Code Sections")

filecheck('test_rpath.elf')

«Сырой код» из эльфа (binary from ELF)

Бывают ситуации, когда «эльфийские одёжи» в виде ELF-структуры не нужны, а нужен только «голый» исполняемый код приложения.

objcopy

Использование objcopy вероятно знакомо тем, кто пишет прошивки:

$ objcopy -O binary -S -g simple.elf simple.bin
  • -S — для удаления символьной информации;
  • -g — для удаления отладочной информации.

LIEF

Никакой магии. Просто взять содержимое загружаемых секций и слепить из них бинарь:

import lief
from lief.ELF import SECTION_FLAGS, SECTION_TYPES

binary = lief.parse("test")
end_addr = 0
data = []

for section in filter(lambda x: x.has(SECTION_FLAGS.ALLOC) and
                                x.type != SECTION_TYPES.NOBITS,
                      binary.sections):
    if 0 < end_addr < section.virtual_address:
        align_bytes = b'\x00' * (section.virtual_address - end_addr)
        data.append(align_bytes)        

    data.append(bytes(section.content))
    end_addr = section.virtual_address + section.size

with open('test.lief.bin', 'wb') as f:
    for d_bytes in data:
        f.write(d_bytes)

Mangled — demangled имена функций

В ELF-ах, созданных из С++ кода, имена функций декорированы (манглированы) для упрощения поиска соответствующей функции класса. Однако читать такие имена при анализе не очень удобно.

Тестовый эльф

nm

Для представления имён в удобочитаемом виде можно использовать утилиту nm из набора binutils:

# Тут имена функций выводятся в манглированном виде
$ nm -D demangle-test-cpp
     ...
      U _Unwind_Resume
      U _ZdlPv
      U _Znwm
      U _ZSt17__throw_bad_allocv
      U _ZSt20__throw_length_errorPKc

# Тут имена функций выводятся в читаемом виде
$ nm -D --demangle demangle-test-cpp
      ...
      U _Unwind_Resume
      U operator delete(void*)
      U operator new(unsigned long)
      U std::__throw_bad_alloc()
      U std::__throw_length_error(char const*)

LIEF

Вывод имён символов в деманглированном виде с использованием библиотеки LIEF:

import lief
binary = lief.parse("demangle-test-cpp")
for symb in binary.symbols:
    print(symb.name, symb.demangled_name)

Сборка, запись, модификация эльфа

Эльф без метаинформации

После того как приложение отлажено и выпускается в дикий мир, имеет смысл удалить метаинформацию:

  • отладочные секции — бесполезны в большинстве случаев;
  • имена переменных и функций — совершенно ни на что не влияют для конечного пользователя (чуть усложняет реверс);
  • таблица секций — совершенно не нужна для запуска приложения (её отсутсвие чуть усложнит реверс).

Удаление символьной информации

Символьная информация — это имена объектов и функций. Без неё реверс приложения немного усложняется.

strip

В самом простом случае можно воспользоваться утилитой strip из набора binutils. Для удаления всей символьной информации достаточно выполнить команду:

  • для исполняемого файла:
    $ strip -s simple
  • для динамической библиотеки:
    $ strip --strip-unneeded libsimple.so

sstrip

Для тщательного удаления символьной информации (в том числе ненужных нулевых байтов в конце файла) можно воспользоваться утилитой sstrip из набора ELFkickers. Для удаления всей символьной информации достаточно выполнить команду:

$ sstrip -z simple

LIEF

C использованием библиотеки LIEF также можно сделать быстрый strip (удаляется таблица символов — секция .symtab):

import lief
binary = lief.parse("simple")
binary.strip()
binary.write("simple.stripped")

Удаление таблицы секций

Как упоминалось выше, наличие/отсутствие таблицы секций не оказывает влияния на работу приложения. Но при этом без таблицы секций реверс приложения становится чуть сложнее.
Воспользуемся библиотекой LIEF под Python и примером удаления таблицы секций:

import lief
binary = lief.parse("simple")
binary.header.numberof_sections = 0
binary.header.section_header_offset = 0
binary.write("simple.modified")

Изменение и удаление RPATH

chrpath, PatchELF

Для изменения RPATH под Linux можно воспользоваться утилитами chrpath (доступна в большинстве дистрибутивов) или PatchELF.

  • Изменить RPATH:
    $ chrpath -r /opt/my-libs/lib:/foo/lib test_rpath.elf
    или
    $ patchelf --set-rpath /opt/my-libs/lib:/foo/lib test_rpath.elf
  • Удалить RPATH:
    $ chrpath -d test_rpath.elf
    или
    $ patchelf --shrink-rpath test_rpath.elf

LIEF

Библиотека LIEF также позволяет как изменить, так и удалить RPATH-запись.

  • Изменить RPATH:
    import lief binary = lief.parse("test_rpath.elf") rpath = next(filter(lambda x: x.tag == lief.ELF.DYNAMIC_TAGS.RPATH, binary.dynamic_entries)) rpath.paths = ["/opt/my-lib/here"] binary.write("test_rpath.patched")
  • Удалить RPATH:
    import lief binary = lief.parse("test_rpath.elf") binary.remove(lief.ELF.DYNAMIC_TAGS.RPATH) binary.write("test_rpath.patched")

Обфускация символьной информации

Для усложнения реверса приложения можно сохранить символьную информацию, но запутать имена объектов. В качестве подопытного используем эльф crackme01_32bit из crackme01 by seveb.

Упрощенный вариант примера из библиотеки LIEF может выглядеть так:

import lief

binary = lief.parse("crackme01_32bit")

for i, symb in enumerate(binary.static_symbols):
    symb.name = "zzz_%d" % i

binary.write("crackme01_32bit.obfuscated")

В результате получим:

$ readelf -s crackme01_32bit.obfuscated
...
Symbol table '.symtab' contains 78 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND zzz_0
     1: 08048154     0 SECTION LOCAL  DEFAULT    1 zzz_1
     2: 08048168     0 SECTION LOCAL  DEFAULT    2 zzz_2
     3: 08048188     0 SECTION LOCAL  DEFAULT    3 zzz_3
     4: 080481ac     0 SECTION LOCAL  DEFAULT    4 zzz_4
     5: 080481d0     0 SECTION LOCAL  DEFAULT    5 zzz_5
     6: 080482b0     0 SECTION LOCAL  DEFAULT    6 zzz_6
     7: 0804835a     0 SECTION LOCAL  DEFAULT    7 zzz_7
     8: 08048378     0 SECTION LOCAL  DEFAULT    8 zzz_8
     9: 080483b8     0 SECTION LOCAL  DEFAULT    9 zzz_9
    10: 080483c8     0 SECTION LOCAL  DEFAULT     10 zzz_10
...

Подмена функций через PLT/GOT

Также известная как ELF PLT INFECTION.

Дабы не копипастить, просто оставим ссылки по теме:

Изменить точку входа

Может быть полезно при создании патчей, установке хуков и прочей динамической инструментации, ну или для вызова скрытых функций. В качестве подопытного используем эльфа crackme01_32bit из crackme01 by seveb

radare2

radare2 запускается в режиме записи (опция -w) — изменения будут внесены в оригинальный файл:

$ ./crackme01_32bit
Please enter the secret number: ^C

$ r2 -w -nn crackme01_32bit
[0x00000000]> .pf.elf_header.entry=0x0804860D
[0x00000000]> q

$ ./crackme01_32bit
Nope.

LIEF

import lief

binary = lief.parse("crackme01_32bit")
header = binary.header
header.entrypoint = 0x0804860D
binary.write("crackme01_32bit.patched")

Патчинг кода

В качестве простого подопытного возьмём крякми novn91's crackmepal. При запуске без параметров программка выводит:

$ ./crackmeMario
usage <password>

При запуске с параметром-произвольной строкой выдаётся:

./crackmeMario qwerty
try again pal.

Сделаем патч, чтобы программа сразу при запуске выводила сообщение «good job! now keygen me!»

radare2

radare2 умеет патчить любые форматы, которые сам поддерживает. При этом имеется возможность описывать патчи в текстовом формате:

# Rapatch for https://crackmes.one/crackme/5ccecc7e33c5d4419da559b3
!echo Patching crackme
0x115D : jmp 0x1226

Применить такой патч можно командой:

$ r2 -P patch.txt crackmeMario

Почитать про патчинг кода через radare2:

LIEF

LIEF позволяет патчить эльф (перезаписать байты) по указанному виртуальному адресу. Патч может быть в виде массива байт или в виде целочисленного значения:

import lief
binary = lief.parse("crackmeMario")
binary.patch_address(0x115D, bytearray(b"\xe9\xc4\x00\x00\x00"))
binary.write("crackmeMario.patched")

После применения патча программа будет выводить:

$ ./crackmeMario.patched
good job! now keygen me!

Добавить секцию в ELF

objcopy

objcopy позволяет добавить секцию, но эта секция не будет относиться ни к одному сегменту и не будет загружаться в ОЗУ при запуске приложения:

$ objcopy --add-section .testme=data.zip \
   --set-section-flags .testme=alloc,contents,load,readonly \
   --change-section-address .testme=0x08777777 \ 
   simple simple.patched.elf

LIEF

Библиотека LIEF позволяет добавить новую секцию и соответствующий ей сегмент (флаг loaded=True) в имеющийся ELF:

import lief

binary  = lief.parse("simple")
data = bytearray(b"\xFF" * 16)

section = lief.ELF.Section(".testme", lief.ELF.SECTION_TYPES.PROGBITS)
section += lief.ELF.SECTION_FLAGS.EXECINSTR
section += lief.ELF.SECTION_FLAGS.ALLOC
section.content = data  

binary.add(section, loaded=True)
binary.write("simple.testme.lief")

Изменить секцию

objcopy

objcopy позволяет заменить содержимое секции данными из файла, а также изменить виртуальный адрес секции и флаги:

$ objcopy --update-section .testme=patch.bin \
    --change-section-address .testme=0x08999999
    simple simple.testme.elf

LIEF

import lief

binary  = lief.parse("simple")
data = bytearray(b"\xFF" * 17)

section = binary.get_section(".text")
section.content = data  

binary.write("simple.patched")

Удалить секцию

objcopy

objcopy позволяет удалить определённую секцию по имени:

$ objcopy --remove-section .testme simple.testme.elf simple.no_testme.elf

LIEF

Удаление секции с использованием библиотеки LIEF выглядит так:

import lief
binary = lief.parse("simple.testme.elf")
binary.remove_section(".testme")
binary.write("simple.no_testme")

Эльф-контейнер

Рецепт навеян статьёй Гремлины и ELFийская магия: а что, если ELF-файл — это контейнер?. Встречаются также man’ы про утилиту elfwrap родом из Solaris, которая позволяет создавать ELF-файл из произвольных данных, а формат ELF используется просто как контейнер.

Попробуем сделать то же самое на Python и LIEF.
К сожалению, на данный момент библиотека LIEF не умеет создавать эльф-файл c нуля, поэтому нужно ей помочь — создать пустой ELF-шаблон:

$ echo "" | gcc -m32 -fpic -o empty.o -c -xc -
$ gcc -m32 -shared -o libempty.so empty.o

Теперь можно использовать этот шаблон для наполнения данными:

import lief

binary = lief.parse("libempty.so")
filename = "crackme.zip"
data = open(filename, 'rb').read()

# Add section with zip-archive as content
section = lief.ELF.Section()
section.content = data
section.name = ".%s"%filename
binary.add(section, loaded=True)   

# Add symbol as a reference to zip-archive
symb = lief.ELF.Symbol()
symb.type = lief.ELF.SYMBOL_TYPES.OBJECT
symb.binding = lief.ELF.SYMBOL_BINDINGS.GLOBAL
symb.size = len(data)
symb.name = filename
symb.value = section.virtual_address
binary.add_static_symbol(symb)

binary.write("libdata.crackme.container")

Эльф «с прицепом»

ELF-формат не накладывает ограничений на данные, которые есть в файле, но не входят ни в один сегмент. Таким образом, можно создать исполняемый файл, у которого после ELF-структуры будет храниться что-то. Это что-то не будет загружаться в ОЗУ при исполнении, но оно будет записано на диске, и в любой момент это что-то можно с диска прочитать.

  • IDA Pro не будет учитывать эти данные при анализе

Пример структуры файла «с прицепом»

radare2

Наличие «прицепа» можно установить, если сравнить реальный и вычисленный размер файла:

$ radare2 test.elf
[0x00001040]> ?v $s
0x40c1
[0x00001040]> iZ
14699

readelf

readelf не показывает информацию о наличии «прицепа», но можно вычислить вручную:

$ ls -l test.elf

# Размер файла 16577 байт

$ readelf -h test.elf
Start of section headers    e_shoff     14704
Size of section headers     e_shentsize 64
Number of section headers   e_shnum     29

# Размер ELF-структуры: e_shoff + ( e_shentsize * e_shnum ) = 16560

LIEF

Библиотека LIEF позволяет как проверить наличие «прицепа», так и добавить его. С использованием LIEF всё выглядит достаточно лаконично:

import lief

binary  = lief.parse("test")

# check if overlay exists
print('ELF has overlay data') if binary.has_overlay else print("No overlay data")

# add overlay data to ELF
data = bytearray(b'\xFF'*17)
binary.overlay = data

binary.write('test.overlay')

Эльф из пустоты (ELF from scratch)

На просторах интернета можно найти проекты по созданию ELF-файла «вручную» — без использования компилятора и линковщика под общим названием «ELF from scratch»:

Знакомство с этими проектами благотворно влияет на впитывание в себя формата ELF.

Самый маленький эльф

Интересные эксперименты с минимизацией размера эльфа описаны в статьях:

Если кратко, загрузчик эльфа в ОС использует далеко не все поля заголовка и таблицы сегментов, при этом некоторый минимальный исполняемый код можно поместить прямо в структуру заголовка ELF’а (код взят из первой статьи):

; tiny.asm

  BITS 32

     org     0x00010000
     db      0x7F, "ELF"             ; e_ident
     dd      1                                       ; p_type
     dd      0                                       ; p_offset
     dd      $                                      ; p_vaddr 
     dw      2                       ; e_type        ; p_paddr
     dw      3                       ; e_machine
     dd      _start                  ; e_version     ; p_filesz
     dd      _start                  ; e_entry       ; p_memsz
     dd      4                       ; e_phoff       ; p_flags
  _start:
     mov     bl, 42                  ; e_shoff       ; p_align
     xor     eax, eax
     inc     eax                     ; e_flags
     int     0x80
     db      0
     dw      0x34                    ; e_ehsize
     dw      0x20                    ; e_phentsize
     db      1                       ; e_phnum
                                     ; e_shentsize
                                     ; e_shnum
                                     ; e_shstrndx
  filesize      equ     $ - $

Ассемблируем и получаем ELF размером… 45 байт:

  $ nasm -f bin -o a.out tiny.asm
  $ chmod +x a.out
  $ ./a.out ; echo $?
  42
  $ wc -c a.out
       45 a.out

Эльф по шаблону

Для создания эльфа с использованием библиотеки LIEF можно сделать следующие шаги (см. рецепт «Эльф-контейнер»):

  • взять простой ELF-файл в качестве шаблона;
  • заменить содержимое секций, добавить новые секции;
  • настроить необходимые параметры (точка входа, флаги).

Вместо заключения

Дописывая статью, обнаружили, что получилось что-то вроде оды библиотеке LIEF. Но так не было запланировано — хотелось показать способы работы с ELF-файлами с использованием разных инструментов.

Наверняка есть или нужны сценарии, которые не были упомянуты здесь — напишите об этом в комментариях.

Ссылки и литература

Автор: https://habr.com/ru/users/prusanov/

By @it_ha