Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Исполняемые файлы в ОС семейства Linux имеют довольно сложную структуру. Важный аспект в работе таких приложений — взаимодействие с внешними библиотеками и вызываемыми из них функциями. Сегодня мы поговорим о том, как обеспечивается такое взаимодействие и каким образом исполняемый файл может быть связан с библиотеками.
info
Это продолжение статьи «Анатомия эльфов. Разбираемся с внутренним устройством ELF-файлов», в которой мы начали изучать секреты формата исполняемых ELF-файлов. В ней мы определились с инструментарием анализа, создали несколько подопытных экземпляров ELF-файлов, разобрались с форматом заголовка ELF-файла, узнали про таблицы заголовков секций и сегментов, а также заглянули внутрь некоторых секций и сегментов.
Основная проблема, возникающая при компоновке исполняемого файла, — определение адресов вызываемых в программе функций, расположенных во внешних библиотеках. Если для функций, которые определены в самом исполняемом файле, такой проблемы не наблюдается (адреса этих функций определяются уже на этапе компиляции), то внешние библиотеки могут находиться в памяти по большому счету где угодно. Это получается благодаря возможности формировать позиционно независимый код. С ходу, на этапе компиляции, определить адрес той или иной функции, содержащейся в такой библиотеке, невозможно. Это можно сделать либо статически (включив нужные библиотеки непосредственно в ELF-файл), либо динамически (во время загрузки или выполнения программы).
Исходя из этого, можно выделить три вида связывания исполняемого ELF-файла с библиотеками:
Если обратиться к миру Windows, то там наблюдается примерно такая же картина и основные принципы функционирования этих видов связывания с внешними библиотеками аналогичны.
Для статически линкуемых библиотек используется расширение .a (в Windows это файлы с расширением .lib), для динамических — расширение .so (в Windows это файлы .dll).
Здесь все достаточно просто. Внешняя библиотека линкуется с исполняемым файлом, образуя с ним единое целое. Если обратиться к примеру из предыдущей статьи (файл с хелловорлдом example.c
), то для того, чтобы сделать из него программу, статически связанную с библиотекой glibc, нужно набрать в консоли следующее:
gcc -o example_static_linked -static example.c
В итоге получим исполняемый файл со статически прилинкованной к нему библиотекой glibc. Если ты обратишь внимание на размер полученного файла, то увидишь, что он существенно больше, чем размер файлов example_pie
и example_no_pie
, которые были скомпилированы методом динамической линковки с библиотекой glibc. У меня, например, получилось целых 872 Кбайт для статической линковки, в то время как динамическая дала всего 17.
Собственно говоря, это и есть основной недостаток статического связывания. Несмотря на то что из glibc мы используем всего одну функцию puts()
, при статическом связывании приходится тащить в исполняемый файл еще много чего ненужного. Также можно отметить еще один, не совсем явный недостаток статической линковки: если появляется новая версия библиотеки (в которой, например, устранена та или иная уязвимость), то нам придется перекомпилировать программу уже с новой версией нужной нам библиотеки. Если этого не делать, то наша программа будет пользоваться функциями, в которых уязвимость не устранена.
Посмотреть тип связывания в исполняемом файле можно, применив утилиту file или ldd.
Определяем тип связывания в ELF-файле (в данном случае видим, что применена статическая линковка)
Обрати внимание, что для примера example_static
из предыдущей статьи утилита file покажет динамическую линковку. Все дело в том, что в этом случае мы статически линковали с программой нашу самописную библиотеку lib_static_example.a
, в которой содержится функция hello_world_function()
. Однако в этой функции используется функция puts()
, которая берется из библиотеки glibc, связанной с lib_static_example.a
уже динамически.
Как мы уже говорили, благодаря позиционно независимому коду динамические (или разделяемые) библиотеки могут быть загружены в память один раз, а все нуждающиеся в этой библиотеке программы станут пользоваться этой разделяемой копией библиотеки. На этапе компоновки исполняемого файла адреса, по которым будут загружены динамические библиотеки, неизвестны, и поэтому адреса содержащихся в них функций определить невозможно.
В этом случае динамический компоновщик (если помнишь, то путь к нему лежит в секции .interp
) определяет адреса нужных функций и переменных в ходе загрузки программы в память. Если говорить точнее, во многих случаях даже не в ходе загрузки, а во время первого обращения к функции, но об этом более подробно поговорим чуть ниже.
Разделяемые библиотеки могут быть загружены в память и во время выполнения программы. В этом случае приложение обращается к динамическому линковщику с просьбой загрузить и прилинковать динамическую библиотеку. В Linux для этого предусмотрены системные функции dlopen()
, dlsym()
и dlclose()
, первая загружает разделяемую библиотеку, вторая ищет в ней нужную функцию, третья закрывает ее файл.
Если покопаться во внутренностях Windows, там можно обнаружить аналогичные API-функции: LoadLibrary()
и GetProcAddress()
(либо LdrLoadDll()
и LdrGetProcAddress()
).
Этот вид связывания (как в Linux, так и в Windows) используется достаточно редко, во многих случаях его применяют для того, чтобы скрыть от исследователей истинную функциональность программы.
В предыдущей статье мы рассмотрели несколько секций, которые может содержать ELF-файл, однако изучили мы их далеко не все. Сегодня мы продолжим исследовать этот вопрос и посмотрим в том числе, какие секции в ELF-файле предусмотрены для организации связывания и разрешения адресов функций, которые содержатся в разделяемых библиотеках.
Эта секция представляет собой массив строк, заканчивающихся нулем, с именами всех секций ELF-файла. Указанная таблица позволяет различным утилитам (например, таким, какreadelf
) находить имена секций. Для просмотра этой секции в символьном или шестнадцатеричном виде можно использовать опции -p
или -x
утилиты readelf
соответственно. Например, вот так:
readelf -x .shstrtab example_pie
Секция .shstrtab в шестнадцатеричном представлении
Как можно догадаться по названию, символьные секции хранят какие‑то символы. В нашем случае под символами понимаются имена функций и переменных. Эти имена используются в качестве символьных имен для представления определенного местоположения в файле или в памяти. Все это вместе и образует то, что мы называем символами функций и данных. (Да, мы привыкли считать, что символ, как правило, занимает одно знакоместо в виде буквы, цифры или знака препинания, однако здесь это не так.)
Чтобы посмотреть информацию о символах, можно воспользоваться уже знакомой нам утилитой readelf и набрать в консоли что‑нибудь вроде этого:
readelf -s -W example_pie
На выходе увидим содержимое двух секций .symtab
и .dynsym
.
Вывод символьной информации из ELF-файлаСекция .symtab
Для начала необходимо отметить, что наличие этой секции в ELF-файле необязательно. Более того, в большинстве встречающихся в дикой природе файлов она отсутствует. Основное ее назначение — помощь при отладке программы, в то время как для исполнения файла она не требуется. По умолчанию эта секция создается во время компиляции программы, однако ее можно удалить с помощью команды strip
, например так:
strip example_pie
Теперь, если попытаться посмотреть символьную информацию в этом файле, будет выведено только содержимое секции .dynsym
.
Вывод символьной информации из ELF-файла, на который воздействовали утилитой strip
Все же, хоть эта секция и необязательна в файле, остановимся на ней чуть подробнее.
Каждая запись этой секции представляет собой структуру вида Elf32_Sym
или Elf64_Sym
. Внутреннее устройство этой структуры (как, впрочем, и содержимое всех остальных структур и значений констант ELF-файлов) можно посмотреть в файле /usr/include/elf.h
.
В указанной секции содержатся все символы, которые компоновщик использует как во время компиляции, так и во время выполнения приложения. В нашем примере example_pie
среди всего, что содержится в данной секции, можно увидеть символьное имя знакомой нам функции main()
, которая присутствует в любой программе, а также символьное имя функции puts()
.
Функции main() и puts() в секции .symtab
Функции main()
соответствует адрес 0x1149
, и именно с этого адреса функция будет начинаться после загрузки файла в память перед выполнением. Также видно, что размер функции main()
составляет 27 байт, ее тип — FUNC
(то есть функция), а сама функция размещается в секции с номером 16 (это секция .text
, в которой находится непосредственно исполняемый код программы).
С функцией puts()
такой определенности не отмечается: нет ни адреса, ни секции. Так происходит потому, что функция puts()
находится в библиотеке glibc и, как мы уже говорили, адрес этой функции на этапе компиляции определить нельзя.
Содержимое секции .symtab
также можно посмотреть и с помощью команды nm
. Чтобы узнать подробности использования этой команды, набери в терминале
nm man
Если мы попробуем посмотреть содержимое секции .symtab
ELF-файла, в котором применено статическое связывание, то увидим, что, помимо уже знакомых нам символов функций main()
и puts()
, в секции присутствует большое количество символов других функций, входящих в состав библиотеки glibc.
Функции main() и puts() в секции .symtab в ELF-файле со статической компоновкой
На рисунке мы видим, что у функции main()
признак связывания (который содержится в поле st_info
структуры Elf32_Sym
или Elf64_Sym
) имеет значение STB_GLOBAL
, а функция puts()
— значение STB_WEAK
. Значение признака связывания символа, равное STB_WEAK
, говорит о том, что данный символ имеет самый низкий приоритет при связывании (так называемый слабый символ). Символы с другими признаками связывания, например STB_LOCAL
или STB_GLOBAL
, имеют более высокий приоритет (их называют сильными символами).
При связывании нескольких библиотек в ходе компоновки одного ELF-файла в нескольких библиотеках может оказаться определена функция с одинаковым именем (то есть символы этой функции в двух или более библиотеках будут совпадать). В этом случае компоновщик должен выбрать одну из этих функций. Когда есть одна функция с сильными символами и одна или несколько функций со слабыми символами, будет выбрана функция с сильными символами. Если имеется несколько одинаковых функций со слабыми символами, компоновщик выберет случайным образом. Если же в ходе компоновки обнаружится две или более одинаковые функции с сильными символами, то компоновка прервется и будет констатирована ошибка.
В данном случае в библиотеке glibc символы многих стандартных функций определены с низким приоритетом (слабые символы). Это делается для того, чтобы дать возможность программистам написать собственную библиотеку с переопределением некоторых стандартных функций (и, соответственно, с определением символов этих переопределенных функций как сильных). Затем они смогут использовать вместе и библиотеку glibc, и свою библиотеку с переопределенными функциями. При этом, поскольку у переопределенных функций символы имеют более высокий приоритет, будут вызываться именно они, а не те, которые определены в glibc. Более подробно про сильные и слабые символы можно почитать в документации.
Секция .dynsym
Записи в данной секции имеют такую же структуру, что и в секции .symtab
. Главное отличие в том, что в этой секции содержатся только символы функций или переменных, которые необходимы для динамической компоновки. На эту секцию команда strip
никакого влияния не оказывает (что, в общем‑то, понятно). Секция .dynsym
имеется в тех файлах, где используется динамическое связывание во время загрузки ELF-файла. Соответственно, если попытаться посмотреть наличие этой секции в файле со статической линковкой, то мы ее там не увидим.
Если внимательно изучить секции .symtab
и .dynsym
, можно заметить, что в секцию .dynsym
из секции .symtab
перекочевали символы c нулевыми адресами и неопределенными номерами секций. Значения этих адресов и номеров секций как раз и будут определены во время загрузки программы.
Секция .symtab
имеет тип SHT_SYMTAB
, а секция .dynsym
— тип SHT_DYNSYM
. Собственно, данный факт и позволяет утилите strip разобраться, что можно зачистить в ELF-файле, а что нельзя.
Секции .strtab и .dynstr
Указанные секции содержат непосредственно строковые значения символов, на которые указывает значение st_name
из структуры Elf32_Sym
или Elf64_Sym
. Они, как было показано выше, являются элементами секций .symtab
или .dynsym
. То есть в секциях .symtab
и .dynsym
непосредственно самих строковых значений символов не содержится, а присутствует только индекс, по которому и находится нужное строковое значение символа в секциях .strtab
или .dynstr
(этот индекс как раз и лежит в поле st_name
структуры Elf32_Sym
или Elf64_Sym
).
Посмотреть содержимое этих секций, так же как и для секции .shstrtab
, можно с использованием опций -x
или -p
утилиты readelf.
Вывод содержимого секций .dynstr и .strtab из ELF-файла в шестнадцатеричном и строковом виде
Более подробно про символьные секции ELF-файлов можно почитать в документации Oracle.
При динамическом связывании во время загрузки в большинстве случаев разрешение находящихся в разделяемых библиотеках адресов функций происходит чуть позже, не в сам момент запуска приложения, а во время первого обращения к неразрешенному адресу при вызове необходимой функции. Таким образом реализуется так называемое позднее (или отложенное) связывание.
Для чего это нужно? Позднее связывание позволяет не тратить без необходимости время на разрешение адресов при запуске программы. Для ее функционирования может потребоваться много функций из разделяемых библиотек, и определять их адреса именно тогда, когда это действительно необходимо, — вполне рациональное решение. В операционных системах семейства Linux режим позднего связывания реализуется динамическим компоновщиком по умолчанию. Можно заставить динамический компоновщик производить разрешение адресов функций из разделяемых библиотек непосредственно во время загрузки программы, задав переменную среды LD_BIND_NOW
:
export LD_BIND_NOW=1
Повторюсь, необходимость использовать режим немедленного связывания возникает крайне редко, разве что если требуется обеспечить гарантированную производительность в системах с режимами, близкими к режимам реального времени.
Для начала рассмотрим базовый принцип позднего связывания в ELF-файлах, который был реализован изначально. После чего поговорим о том, какие изменения были внесены в этот базовый принцип, когда появились новые технологии защиты программ от атак.
Для большей наглядности немного изменим наш «хелловорлд» и добавим в него еще одну функцию (например, exit()
):
#include <stdio.h> #include <stdlib.h> void main(int argc, char* argv[]) { printf("Hello worldn"); exit(0); }
Чтобы получить ELF-файл с базовым принципом позднего связывания, откомпилируем данный пример c использованием опции -fcf-protection=none
. Эта опция показывает компилятору, что нужно собрать программу без использования защитной технологии IBT (indirect branch tracking):
gcc -o example_exit_notrack -fcf-protection=none example_exit.c
Об опциях gcc
можно почитать в документации.
Итак, в базовом варианте в ELF-файлах позднее связывание реализуется с помощью двух специальных секций:
.plt
— таблица связей и процедур (Procedure Linkage Table);
.got
— таблица глобальных смещений (Global Offset Table).
Начнем с секции .got
. Для начала обратим внимание, что эта секция имеет тип SHT_PROGBITS
(то есть содержит либо код, либо данные, и в нашем случае это данные), а также флаг SHF_WRITE
(то есть ее содержимое может меняться в ходе выполнения программы).
Источник: xakep.ru