Ког­да прог­рамма в Linux вызыва­ет printf, sleep или любую дру­гую фун­кцию из динами­чес­кой биб­лиоте­ки, ее реаль­ный адрес заранее неиз­вестен из‑за ASLR. Тем не менее вызов каким‑то обра­зом сра­баты­вает. В этой статье мы шаг за шагом раз­берем, как работа­ют механиз­мы PLT, GOT и релока­ции, прос­ледим про­цесс раз­решения адре­сов с помощью objdump, readelf и GDB, а так­же уви­дим вжи­вую, что про­исхо­дит при пер­вом и пос­леду­ющих вызовах биб­лиотеч­ной фун­кции.

В момент запус­ка прог­раммы в 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):

#include <unistd.h>
int main() {
sleep(30);
return 0;
}

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

gcc -c empty_sleep.c -o empty_sleep.o

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

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

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

objdump -d -M intel empty_sleep.o

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

0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push rbp
5: 48 89 e5 mov rbp,rsp
8: bf 1e 00 00 00 mov edi,0x1e
d: e8 00 00 00 00 call 12 <main+0x12>
12: b8 00 00 00 00 mov eax,0x0
17: 5d pop rbp
18: c3 ret

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

0000000000000000 <main>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: bf 1e 00 00 00 mov edi,0x1e
9: e8 00 00 00 00 call e <main+0xe>
e: b8 00 00 00 00 mov eax,0x0
13: 5d pop rbp
14: c3 ret

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

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

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

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

 

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

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

readelf -r empty_sleep.o

Вы­вод:

Раздел перемещения .rela.text at offset 0x178 contains 1 entry:
Смещение Инфо Тип Знач.симв. Имя симв. + Addend
00000000000e 000400000004 R_X86_64_PLT32 0000000000000000 sleep - 4

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

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

По­ле Зна­чение Рас­шифров­ка
Сме­щение 0xe (или 0xa без endbr64) Где в сек­ции .text находит­ся заг­лушка
Ин­фо (стар­шие биты) 0x0004 Ин­декс сим­вола в таб­лице сим­волов = 4
Ин­фо (млад­шие биты) 0x00000004 Тип релока­ции = R_X86_64_PLT32
Имя sleep Нуж­но под­ста­вить адрес сим­вола sleep

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

readelf -s empty_sleep.o
Symbol table '.symtab' contains 5 entries:
Чис: Знач Разм Тип Связ Vis Индекс имени
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS empty_sleep.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 25 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sleep

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

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

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

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

 

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

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

gcc empty_sleep.o -o empty_sleep

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

objdump -d -M intel empty_sleep | grep -A10 '<main>:'
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push rbp
114e: 48 89 e5 mov rbp,rsp
1151: bf 1e 00 00 00 mov edi,0x1e
1156: e8 f5 fe ff ff call 1050 <sleep@plt>
115b: b8 00 00 00 00 mov eax,0x0
1160: 5d pop rbp
1161: c3 ret

Ад­рес 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:

objdump -d -M intel --no-show-raw-insn empty_sleep | grep -A6 '<sleep@plt>:'

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

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

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

0000000000001030 <sleep@plt>:
1030: jmp QWORD PTR \[rip+0x2fca] # 4000 <sleep@GLIBC_2.2.5>
1036: push 0x0
103b: jmp 1020 <_init+0x20>

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

# Секция .plt.sec заглушка, которую вызывает main:
0000000000001050 <sleep@plt>:
1050: endbr64
1054: jmp QWORD PTR \[rip+0x2f76] # 3fd0 <sleep@GLIBC_2.2.5>
105a: nop WORD PTR \[rax+rax\*1+0x0]

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

objdump -D -M intel empty_sleep | grep -A4 '3fd0:'

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

3fd0: 30 10 xor BYTE PTR \[rax],dl

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

objdump -d -M intel --no-show-raw-insn empty_sleep | grep -A4 '1030:'

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

1030: endbr64
1034: push 0x0
1039: jmp 1020 <_init+0x20>
103e: xchg ax,ax

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

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

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

 

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

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

0000000000001030 <sleep@plt>:
1030: jmp QWORD PTR \[rip+0x2fca] # 4000 <sleep@GLIBC_2.2.5>
1036: push 0x0
103b: jmp 1020 <_init+0x20>

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

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

objdump -D -M intel empty_sleep | grep -A6 -E '\\.got(\\.plt)?:'

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

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

Дизассемблирование раздела .got.plt:
0000000000003fe8 <_GLOBAL_OFFSET_TABLE_>:
3fe8: e0 3d loopne 4027 <_end+0x7>
...
3ffe: 00 00 add BYTE PTR \[rax],al
4000: 36 10 00 ss adc BYTE PTR \[rax],al

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

Дизассемблирование раздела .got:
0000000000003fb8 <_GLOBAL_OFFSET_TABLE_>:
3fb8: c8 3d 00 00 enter 0x3d,0x0
...
3fd0: 30 10 xor BYTE PTR \[rax],dl

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

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

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

Присоединяйся к сообществу «Xakep.ru»!

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

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии