Релокации. Разбираемся, как Linux-программа находит функцию во время выполнения

Релокации. Разбираемся, как Linux-программа находит функцию во время выполнения

Содержание статьи

В момент запус­ка прог­раммы в Linux ядро заг­ружа­ет ее в память, а динами­чес­кий ком­понов­щик под­гру­жает все необ­ходимые биб­лиоте­ки. Механизм, который мы будем раз­бирать, работа­ет оди­нако­во для любой динами­чес­кой биб­лиоте­ки, но в качес­тве при­мера мы возь­мем стан­дар­тную биб­лиоте­ку язы­ка C libc. so. 6 : она заг­ружа­ется прак­тичес­ки всег­да. Имен­но в ней лежат хорошо зна­комые нам фун­кции вро­де printf , puts или sleep .

Но тут есть заг­воз­дка. Из‑за ASLR (address space layout randomization) — защит­ного механиз­ма, который слу­чай­ным обра­зом раз­меща­ет биб­лиоте­ки в памяти, — адрес libc. so. 6 и всех фун­кций внут­ри нее меня­ется при каж­дом запус­ке. Ком­пилятор не может знать эти адре­са заранее. Как тог­да прог­рамма находит нуж­ную фун­кцию, если ее адрес ста­новит­ся известен толь­ко во вре­мя выпол­нения?

От­вет кро­ется в механиз­ме релока­ций: PLT, GOT и ленивой при­вяз­ке. Давай раз­берем­ся, как это работа­ет, на при­мере прос­той прог­раммы empty_sleep , которая вызыва­ет фун­кцию sleep( 30) и завер­шает­ся. И не толь­ко изу­чим теорию, но и уви­дим релока­ции вжи­вую с помощью отладчи­ка GDB.

Пустая программа для эксперимента

Для при­мера напишем прос­тую прог­рамму empty_sleep . Она ничего не дела­ет, толь­ко вызыва­ет sleep( 30) :

Ском­пилиру­ем ее в объ­ектный файл:

За­тем пос­мотрим на дизас­сем­бли­рован­ный код, но сна­чала — неболь­шое пояс­нение.

В зависи­мос­ти от того, с какими фла­гами был соб­ран GCC и glibc, вывод команд может выг­лядеть по‑раз­ному. Я покажу два вари­анта. Раз­лича­ются сме­щения в коде, наличие инс­трук­ции endbr64 и струк­тура PLT-заг­лушек. На логику релока­ций это не вли­яет.

Те­перь смот­рим дизас­сем­бли­рован­ный код:

Вот пер­вый при­мер:

А вот вто­рой при­мер:

Пер­вый при­мер — с инс­трук­цией endbr64 , вто­рой — без нее. В пер­вом слу­чае заг­лушка находит­ся по сме­щению 0xd , во вто­ром — по сме­щению 0x9 . В осталь­ном никакой раз­ницы нет.

Что здесь важ­но? Инс­трук­ция call име­ет опе­ранд 0x00 00 00 00 — заг­лушку . Ком­пилятор не зна­ет, где будет находить­ся sleep , поэто­му оставля­ет пус­тое мес­то. Четыре нулевых бай­та — это вре­мен­ный запол­нитель, который поз­же будет заменен нас­тоящим адре­сом.

Инс­трук­ция endbr64 — это часть тех­нологии Intel CET (control-flow enforcement technology) — аппа­рат­ной защиты от атак через кос­венные перехо­ды, таких как JOP (jump-oriented programming) и подоб­ных. Она не име­ет отно­шения к механиз­му релока­ций. Если твой GCC соб­ран без под­дер­жки CET, этой инс­трук­ции не будет, а сме­щение заг­лушки ока­жет­ся на четыре бай­та мень­ше. На логику релока­ций это тоже не вли­яет.

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

Как компилятор сообщает о будущих правках

Ком­пилятор записы­вает в спе­циаль­ную таб­лицу сле­дующие све­дения: по опре­делен­ному сме­щению (сра­зу пос­ле кода опе­рации e8 ) нуж­но будет впи­сать адрес фун­кции sleep . Пос­мотрим:

Вы­вод:

Кон­крет­ное зна­чение сме­щения зависит от раз­мера кода фун­кции main и может отли­чать­ся в тво­ем слу­чае. Глав­ное — оно ука­зыва­ет ров­но на те четыре бай­та, которые мы видели в дизас­сем­бле­ре.

Раз­берем запись по полям. Сме­щение 0xe соот­ветс­тву­ет при­меру с endbr64 . Если в тво­ем выводе endbr64 нет, сме­щение будет 0xa .

От­куда взял­ся индекс 4? Заг­лянем в таб­лицу сим­волов:

Сим­вол номер 4 — это sleep . Его зна­чение пока 0x0 , потому что реаль­ный адрес неиз­вестен. Бук­вы UND (undefined) озна­чают, что сим­вол опре­делен где‑то вов­не, в нашем слу­чае — в биб­лиоте­ке libc. so. 6 .

Итак, в объ­ектном фай­ле у нас есть:

заг­лушка 00 00 00 00 в коде;

за­пись в таб­лице релока­ций: «по это­му сме­щению впи­сать адрес sleep ».

Даль­ше в дело всту­пает ста­тичес­кий ком­понов­щик.

Как статический компоновщик создает механизм подстановки

Про­дол­жу с пер­вым при­мером (с endbr64 ), для вто­рого вари­анта отли­чия толь­ко в сме­щени­ях. Ском­пону­ем прог­рамму:

Те­перь пос­мотрим на main в готовом исполня­емом фай­ле:

Ад­рес main изме­нил­ся с 0x0 на 0x1149 — ста­тичес­кий ком­понов­щик раз­местил код в финаль­ном фай­ле, добавив заголов­ки и дру­гие сек­ции. Кон­крет­ные адре­са у тебя будут дру­гими, это нор­маль­но.

Вмес­то call с нулями теперь call 1050 < sleep@plt> . Ста­тичес­кий ком­понов­щик не под­ста­вил реаль­ный адрес sleep — он и не может, ведь биб­лиоте­ка заг­ружа­ется динами­чес­ки и ее адрес ста­нет известен толь­ко при запус­ке. Вмес­то это­го он соз­дал два клю­чевых механиз­ма:

PLT (procedure linkage table) — таб­лица заг­лушек для фун­кций из динами­чес­ких биб­лиотек;

GOT (global offset table) — таб­лица, куда динами­чес­кий ком­понов­щик поз­же запишет реаль­ные адре­са.

Те­перь инс­трук­ция call в main ука­зыва­ет не на sleep нап­рямую, а на запись в PLT. Что внут­ри PLT-заг­лушки? Раз­бира­емся даль­ше.

Что внутри PLT-заглушки

Сей­час покажу оба вари­анта PLT-заг­лушки, они выг­лядят по‑раз­ному, но дела­ют одно и то же.

Нач­нем с тер­минов. PLT-заг­лушка — это неболь­шой фраг­мент кода, который main вызыва­ет вмес­то нас­тоящей биб­лиотеч­ной фун­кции. Ре­зол­вер — это код динами­чес­кого ком­понов­щика, который при пер­вом вызове находит нас­тоящий адрес фун­кции в биб­лиоте­ке и записы­вает его в GOT.

Пос­мотрим на заг­лушку sleep@plt :

Флаг --no-show-raw-insn скры­вает стол­бец с «сырыми» бай­тами — с ним вывод чище и лег­че чита­ется.

Вы­вод может выг­лядеть по‑раз­ному. В пер­вом при­мере (с endbr64 ) заг­лушка и резол­вер ока­жут­ся в раз­ных сек­циях. Во вто­ром (без endbr64 ) — объ­еди­нены в одном мес­те.

Вот при­мер, где заг­лушка и резол­вер объ­еди­нены (вто­рой при­мер из раз­дела «Пус­тая прог­рамма для экспе­римен­та», даль­ше буду называть его при­мером с тре­мя инс­трук­циями):

А вот при­мер, где заг­лушка и резол­вер раз­несены (пер­вый при­мер из раз­дела «Пус­тая прог­рамма для экспе­римен­та», даль­ше буду называть при­мером с раз­несен­ными инс­трук­циями):

В раз­несен­ном вари­анте в заг­лушке нет ни push , ни jmp на резол­вер — толь­ко jmp в GOT и nop . Но ком­мента­рий # 3fd0 < sleep@GLIBC_2. 2. 5> под­ска­зыва­ет, что jmp обра­щает­ся к GOT по сме­щению 0x3fd0 . Заг­лянем туда:

Ре­зуль­тат:

Бай­ты 30 10 — это не коман­да xor , а млад­шие бай­ты адре­са 0x1030 , записан­ные в обратном поряд­ке (little-endian). Получа­ется, GOT ука­зыва­ет на адрес 0x1030 внут­ри сек­ции . plt . Давай пос­мотрим, что там:

Мы уви­дим такой резуль­тат:

Вот они, push и jmp на резол­вер, по адре­су 0x1030 в сек­ции . plt . Мы наш­ли их не по име­ни < sleep@plt> , а по адре­су, который лежал в GOT. GOT, в свою оче­редь, ука­зыва­ет не на заг­лушку в . plt. sec , а на код резол­вера в . plt .

Оба вари­анта реали­зуют один и тот же механизм. В пер­вом слу­чае заг­лушка и резол­вер объ­еди­нены в одном мес­те, во вто­ром — раз­несены по раз­ным сек­циям: . plt. sec содер­жит толь­ко заг­лушку (сюда при­ходит call из main ), а . plt — код резол­вера с push и jmp (сюда попада­ют толь­ко при пер­вом вызове через GOT).

Те­перь раз­берем каж­дую инс­трук­цию под­робнее.

Инструкция 1: jmp в GOT

Нач­нем раз­бирать­ся на при­мере с тре­мя инс­трук­циями:

Инс­трук­ция jmp QWORD PTR \[ rip+0x2fca] — это переход по адре­су, который хра­нит­ся в GOT. В при­мере выше ком­мента­рий # 4000 < sleep@GLIBC_2. 2. 5> под­ска­зыва­ет, что адрес находит­ся по сме­щению 0x4000 . Имен­но туда динами­чес­кий ком­понов­щик поз­же запишет реаль­ный адрес sleep .

Но при пер­вом вызове там лежит не адрес sleep , а адрес, ука­зыва­ющий обратно на код внут­ри PLT. Заг­лянем в GOT:

В зависи­мос­ти от вер­сии objdump наз­вание сек­ции может быть . got или . got. plt — это одна и та же область памяти. Вывод тоже может отли­чать­ся.

Для при­мера возь­мем вари­ант с тре­мя инс­трук­циями:

А вот дизас­сем­бли­рова­ние для при­мера с раз­несен­ными инс­трук­циями (как мы уже видели в раз­деле «Что внут­ри PLT-заг­лушки»):

Инс­тру­мент objdump интер­пре­тиру­ет содер­жимое GOT как машин­ный код. Бай­ты 36 10 00 по сме­щению 0x4000 (при­мер с тре­мя инс­трук­циями) или 30 10 по сме­щению 0x3fd0 (при­мер с раз­несен­ными инс­трук­циями) — это не коман­ды ss adc или xor , а млад­шие бай­ты адре­са в обратном поряд­ке (little-endian). В пер­вом слу­чае это 0x1036 , во вто­ром — 0x1030 . Оба адре­са ука­зыва­ют на код внут­ри PLT: либо на сле­дующую инс­трук­цию пос­ле jmp (при­мер с тре­мя инс­трук­циями), либо на вход в сек­цию . plt , где лежат push и jmp на резол­вер (при­мер с раз­несен­ными инс­трук­циями).

Продолжение доступно только участникам

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

9990 рублей 5000 р.

950 р.

Опубликованы технические детали и PoC-эксплоит для уязвимости Bad Epoll, обнаруженной в яд…

← Cybersecurity