Статья ASM для х86. (4.4.) Структура заголовка PE-файла

Рассмотрим формат исполняемого файла..
Важной его частью является формируемый компилятором заголовок PE-Header – этакий паспорт с полной информацией о своём клиенте. Когда система запускает файл на исполнение, загрузчик образов в Ntdll.dll сначала берёт только заголовок из дискового файла, а остальную его часть не трогает. На основании этой информации, лоадер создаёт указанное число секций и прочих ресурсов, и только потом заполняет эти секции кодом и данными. Под заголовок выделяется одна 4К- страница виртуальной памяти размером 0х1000 байт.

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

Всё-что будет сказано ниже, нужно воспринимать как вводную часть для программирования Shell-кода и внедрения его в чужой процесс. Любой шелл устроен так, что у него нет секции-импорта ..и вообще нет ничего, кроме своих мозгов. А раз нет импорта, значит он не может вызывать API по их именам, и ему нужно самому искать точки-входа в нужные функции. Вот тут-то и приходит на помощь PE-заголовок, в котором перечисляются ординалы, имена и адреса экспортируемых функций.

Шелл – это скрытый агент, блуждающий в чужом контексте в маске-анонимуса. Излюбленными его API являются функции из Kernel32.dll. В этой библиотеке есть всё для создания полноценного кода, и в большинстве случаях остальные либы просто не нужны. Так-что сделаем упор на Kernel32, которая вместе с Ntdll.dll присутствует во-всех процессах, причём всегда по одинаковому адресу – для WinXP это 0х7с800000, для семёрки 0х77а90000:


pe_0.png


Комбинация Ctrl+G в окне отладчика позволяет просматривать регионы памяти. Если ввести туда базу кернела 0х7с800000, то попадём в начало его РЕ-заголовка (см.рис.ниже). Сигнатура ‘MZ’ гарантирует, что перед нами именно заголовок исполняемого файла, а не что-то иное. MZ – это инициалы разработчика досовских экзешников Mark Zbikowski (видимо любил себя чел), так-что каждый файл формата РЕ начинается с такой заглушки Dos-Stub. Непосредственно РЕ-заголовок следует ниже, а его конкретный RVA-адрес лежит по смещению 0x3с от начала файла:

pe_1.png


В документации на РЕ-файл фигурируют три типа адресов: базовый Base, виртуальный VA и относительный RVA (Relative-Virtual-Address). Виртуальный адрес получается из комбинации: VA = Base+RVA. Например, в данном случае по смещению 0х3с лежит значение 0х000000F0 – это адрес относительно базы, который назвали RVA-адресом. Чтобы получить виртуальный адрес начала РЕ-заголовка, нужно к базе 0х7с800000 прибавить 0xF0. Как показывает скрин выше, по этому адресу находится сигнатура РЕ, от Portable-Executable (портируемый экзе). Теперь посмотрим на формат этого заголовка..

pe_3.png


Выделенный красным блок – это и есть РЕ-заголовок, который описывает глобальные свойства файла. Так, после сигнатуры по смещению(04) указывается привязка программы к процессору – 0х014С означает i80386 и выше. Смещение(06) хранит кол-во секций в файле = 0х0004. Дальше идёт закодированные дата/время создания файла = 0х480381ЕА. Следущие 8-байт зарезервированы и всегда равны нулю. Предпоследние два по смещению(14h) – это размер двух/следующих блоков, он жёстко зафиксирован на отметке 0х00Е0. И последнее слово 0х210Е хранит атрибуты данного файла – исполняемая Win32 библиотека.

В коде, удобно указывать все смещения относительно начала РЕ-заголовка, поместив его например в регистр ESI в качестве базы. Поэтому здесь и далее мы будем придерживаться этих правил (одна строка – это параграф памяти размером 10h-байт). В опциональном заголовке нас будут интересовать всего несколько полей:

  • РЕ+28h = 0х0000b63e: RVA точки-входа в программу (Entry_Point);
  • РЕ+2Сh = 0х00001000: RVA начала секции-кода;
  • РЕ+30h = 0х00080000: RVA начала секции-данных;
  • РЕ+34h = 0х7с800000: Image_Base, база образа в памяти;
  • РЕ+38h = 0х00001000: Выравнивание секций в памяти;
  • РЕ+74h = 0х00000010: Кол-во элементов в следующем каталоге-секций (всегда 0х10=16);
Серый блок ‘Image_Directory’ для нас представляет особый интерес, именно он послужит проводником в секцию-экспорта системных API. Формат каталога такой, что первые 4 байта это RVA-указатели на соответствующую таблицу, а вторые – её размер. Hiew непонаслышке знаком с этим диром, только указанные им адреса не совпадают с адресами в памяти, т.к. он работает с образом файла на диске, а отладчик OllyDbg отображает виртуальные адреса в памяти. Но сейчас важна структура каталога, для просмотра которой нужно пройтись по цепочке меню: Enter (Hex) -> F8 (РЕ_Header) -> F10 (Dir):

pe_4.png


На что здесь стОит обратить внимание, так это на отсутствие в данном списке линков на секцию-кода и данных. Дело в том, что эти две секции вообще не требуют таблиц для своего описания. Загрузчик образов просто выделяет 1000h байтные страницы и тупо копирует в них данные и код в том виде, в котором они хранятся на диске. Если код превышает размер одной страницы (например 1200h), то выделяются две страницы, а лишние 800h забиваются нулями. Это известно как ‘выравнивание секций в памяти на 1000h байтную границу’ – Section_Aligment.

Нужно сказать, что выравниваются секции не только в памяти, но и на диске, только не на 1000h, а на 200h байт - File_Aligment. Такая разница позволяет экономить дисковое пространство. Заражающая файлы малварь, любит дописывать себя в такие бесхозные байты-выравнивания. Если посмотреть на дампы файлов (хоть в памяти, хоть на диске), то у них всегда имеется болото нулей в хвосте секций.

Кому интересно назначение полей всех заголовков, некто Ю.С.Лукач разложил это по-полочкам в своём туториале:
Нужно отдать должное автору за сбор такого мануала. Это одно из лучших описаний РЕ-файла в сети рунет.


4.4.0. Разбор таблицы-экспорта в поисках адресов функций

Теперь посмотрим, каким образом шелл может получить адреса API без системной поддержки типа GetProcAddress(). Во-первых, на системах х32 каталог секций IMAGE_DIRECTORY всегда начинается по смещению РЕ+78h. Первым элементом в нём лежит как-раз RVA-указатель на таблицу экспорта (см.скрин Hiew’a выше), в данном случае он равен 0х0000262с. Соответственно, чтобы получить из него виртуальный адрес VA, суммируем RVA с базой и получаем 0х7с80262с. Топаем туда в отладчике и видим такую таблицу-экспорта размером 28h байт (выделена красным):

pe_5.png


Соберём всё вышесказанное вместе..
Значит таблица-экспорта имеет три вложенные таблицы:

• Таблица точек-входов в экспортируемые функций, RVA-указатель на которую лежит по смещению 0х1с,
• Таблица указателей на имена функций по смещению 0х20,
• Таблица-ординалов функций, которая в данном случаем нам не нужна.

Кроме того, у нас имеется счётчик общего кол-ва функций в либе по смещению 0х14 – здесь он равен 0х03b9 или 953 функции API. Таким образом, первой экспортируемой функцией из библиотеки Kernel32.dll является ActivateActCtx(), а точкой-входа в неё – адрес 0x7c80a6d4 (выделенный синим блок). На всякий/пожарный, проверим наши доводы в WinDbg ..и точно совпадает:

wdb_10.png


Получив по такому алгоритму адрес функции в памяти, шелл-код может вызывать её по адресу, а не по имени. Например можно считать адрес функции в регистр EDX, и дальше CALL_EDX. Это прекрасно работает, только есть одна загвоздка – как среди такого кол-ва функций (953), найти имя нужной нам API, ..не сравнивать-же все строки контекстным поиском?


4.4.1. Вычисление хеша имени функций

Любой поиск – это сравнение двух значений, а значит нужна маска для поиска. Если зашить в шелл имя искомой функции в формате ASCIIZ, то это получиться не шелл, а ёлка с гирляндами - ASCII-строки сдадут его с потрахами. Одним из возможных вариантов является вычисление хеша строки, или её контрольной суммы. То-есть мы складываем все байты имени функции и получаем их сумму. Так, имя API длинною например в 20-символов, можно будет сжать до пару байт. Разрядность хеша должна быть минимум 16-бит (слово), тогда вероятность коллизии (совпадении) хешей на порядок уменьшится. Вычислить хеш строки можно в цикле тремя строчками кода:

C-подобный:
include 'win32ax.inc'
.data
frmt      db  'Хеш от строки "CreateToolhelp32Snapshot" = 0х%04x',0
funcName  db  'CreateToolhelp32Snapshot'
strLen    =   $-fName
text      db   128 dup(0)
;-------
.code
start:   mov     ecx,strLen       ;// ECX = длина строки с именем функции
         mov     esi,funcName     ;// её адрес
         xor     eax,eax          ;// обнулить EAX и EBX
         xor     ebx,ebx          ;//
@hash:   lodsb                    ;// AL = очередной байт из ESI
         add     ebx,eax          ;// суммируем их в EBX
         loop    @hash            ;// повторить ECX-раз..

        cinvoke  wsprintf,text,frmt,ebx    ;// хеш в EBX - переведём его в символы
         invoke  MessageBox,0,text,0,0
         invoke  ExitProcess, 0
.end start

4.4.1. Поиск базы Kernel32.dll в памяти

До этих пор мы рассматривали внутренности кернела, теперь выйдем наружу и найдём её адрес в виртуальной памяти. Это первое, что должен будет сделать шелл попав в тело жертвы из эксплоита. Известных лично мне способов поиска базы только два, а остальные – их модификация. В основе первого лежит идея, что если найти любую функцию из Kernel32.dll и потянуть за неё, она обязательно выведёт нас к базе. Недостатком является поиск перебором, что отнимает относительно много времени. Альтернативой служит второй способ, когда мы берём уже готовый адрес из структуры РЕВ процесса. Рассмотрим оба метода подробней..

Во-первых нужно взять во-внимание то, что базы всех модулей одного приложения всегда выровнены на 64К-байтную границу, а это 10000h байт (см. MemoryMap в оле). Соответственно, чтобы найти базу Kernel32 в памяти, нужно получить указатель на любую функцию из этой библиотеки, и сделать её кратной 0х10000. Если повезёт и по выровненному адресу увидим сигнатуру ‘MZ’, значит мы у цели. Иначе, нужно в цикле отнимать по 0х10000 и мы обязательно упрёмся на базу. На резонный вопрос ‘от куда взять адрес функции’ ответит нам стек в отладчике OllyDbg:


seh_00.png


Здесь я выделил зелёным два адреса внутри Kernel32.dll, однако первый отпадает сразу, т.к. жертва могла поместить уже что-нибудь в стек. Зато второй (обозначеный красным) блок будет присутствовать всегда – это отлавливающий системные исключения SEH-фрейм Structured_Exeption_Handler.

Замечу, что у одной программы может быть несколько таких фреймов (для каждого исключения свой), и каждый последующий указывает на предыдущий – именно поэтому один фрейм содержит в себе два значения, где нижнее это указатель на обработчик исключения, а верхнее – указатель на следующий фрейм в цепочке. Маркером последнего фрейма является значение 0xFFFFFFFF, его-то процедура обработки и находится внутри кернела. Указатель на первый SEH-фрейм прошит в структуре ТЕВ по смещению(00h), а на сам ТЕВ всегда указывает сегментный регистр FS.

C-подобный:
include 'win32ax.inc'
.data
frmt      db  'База Kernel32 = 0х%08X',0
text      db  64 dup(0)
;-------
.code
start:    nop
;// Этот код будет внутри шелла,
;// и в нём нельзя напрямую вызывать API-функции
          mov     esi,[fs:0]         ; указатель на начало SEH-цепочки
@findSeh: cmp     dword[esi],-1      ; проверить значение из ESI на 0хFFFFFFFF (маркер окончания)
          je      @found             ; перейти, если нашли
          mov     esi,[esi]          ; иначе: двигаемся по цепочке вверх
          jmp     @findSeh           ; повторить, пока не найдём последний SEH

@found:   mov     esi,[esi+4]        ; нашли! сл.DWORD - это адрес обработчика SEH
          and     esi,-0x10000       ; выровнить адрес на 64К границу
          mov     ecx, 0x10          ; кол-во возможных блоков в памяти
@findPE:  cmp     word[esi],'MZ'     ; проверить блок на сигнатуру РЕ-заголовка
          je      @kernelBase        ; если совпало..
          sub     esi, 0x10000       ; иначе: двигаемся вверх по адресу
          loop    @findPE            ; проверить все блоки!
;//ннннннннннннннннннннннннннннннннннннннннннннн
@kernelBase:
        cinvoke  wsprintf,text,frmt,esi    ;// в ESI лежит база Kernel32.dll
         invoke  MessageBox,0,text,0,0
         invoke  ExitProcess, 0
.end start

Как видим, код получается довольно объёмный, да ещё и с циклами внутри, что отнимает время. Поэтому рассмотрим альтернативу, когда мы просто берём готовый адрес из структуры РЕВ процесса-жертвы. Указатель на РЕВ лежит в структуре ТЕВ по смещению +30h.

При создании системой любого процесса, она логирует свои действия в его блоке окружения PEB – Process_Environment_Block. На определённом этапе рождения нового процесса, в игру вступает загрузчик образов из Ntdll.dll – именно он подгружает системные либы в память процессов. Как и следовало ожидать, этот факт фиксируется в структуре РЕВ, а точнее в его поле ‘PEB_LDR_DATA’, которое предоставляет информацию о загруженных модулях DLL. Заглянем в структуру РЕВ отладчиком WinDbg:


peb_10.png


Если вскрыть эту вложенную структуру PEB_LDR_DATA (лоадер), можно увидеть в ней указатели на три двусвязных списка LIST_ENTRY (двусвязные списки уже упоминались – это Forward (следующий в цепочке), и Backward (предыдущий)). Два из эти списка нам особенно интересны – InInitializationOrderModuleList (содержит список DLL в порядке их инициализации), и InMemoryOrderModuleList – либы в порядке их появления в памяти. Базовый адрес библиотеки DLL хранится в 0x10 байтах от блока, на который указывает поле Flink в списке LIST_ENTRY.

peb_11.png


Вот пример, в который заложены эти 'высокие принципы.'
Его трейс в отладчике скажет сам-за-себя:

C-подобный:
include 'win32ax.inc'
.data
frmt      db  'База Kernel32 = 0х%08X',0
text      db  64 dup(0)
;-------
.code
start:    nop
;// Этот код будет внутри шелла,
;// и в нём нельзя напрямую вызывать API-функции
          mov    esi,[fs:0x30]       ; берём указатель на PEB из ТЕВ
          mov    esi,[esi+0x0C]      ; смещаемся в РЕВ к LDR_DATA,
          mov    esi,[esi+0x14]      ;   ..и дальше к 'InMemoryOrderModuleList'
          mov    esi,[esi]           ; первый указатель в нём будет Ntdll.dll (нам не нужен)
          mov    esi,[esi]           ; второй Kernel32.dll - наш клиент!
          mov    esi,[esi+0x10]      ; по смещению 10h от начала, найдём его базу.
;//ннннннннннннннннннннннннннннннннннннннннннннн
@kernelBase:
        cinvoke  wsprintf,text,frmt,esi    ;// в ESI лежит база Kernel32.dll
         invoke  MessageBox,0,text,0,0
         invoke  ExitProcess, 0
.end start

Этот код опирается на то, что все системы класса Win32/64 загружают библиотеки всегда в определённом порядке – сначала загрузчик Ntdll.dll, потом Kernel32.dll, и только потом все/остальные либы типа User32.dll, GDI32.dll и прочие. Поэтому мы пропускаем первый 'InMemoryOrderModuleList', и берём второй. Имена библиотек в этом варианте поиска базы нигде не фиксируются, и шелл надеется только на порядок загрузки либ в память.

В демке ниже приводится вариант, как можно получить имена и адреса всех функций из Kernel32.dll. Хотя если учесть, что вывод осуществляется в цикле, я не буду искать все 953 функции, а ограничусь только первыми 20-30 штук, число которых указывается в счётчике ECX.

Значит сначала ищем базу кернела в памяти, потом IMAGE_DIRECTORY, ну и дальше разбор её секции-экспорта по указанному выше алго. Большую часть кода занимает тут оформление вывода на экран, т.к. после каждой функции нужно править буфер (вставлять перевод строки 13,10, чтобы не затереть предыдущие данные):

C-подобный:
include 'win32ax.inc'
.data
capt      db  'Список функций из Kernel32.dll',0
frmt      db  '0х%08X     %s',13,10,0
text      db   1024 dup(0)
;-------
.code
start:    nop
;// Кладём базу Kernel32 в регистр EBX
          mov    ebx,[fs:0x30]
          mov    ebx,[ebx+0x0C]
          mov    ebx,[ebx+0x14]
          mov    ebx,[ebx]
          mov    ebx,[ebx]
          mov    ebx,[ebx+0x10]

;// Содержимое регистров:
;// EBX - база, ESI - адрес функции, EDX - имя
          mov    esi,[ebx+0x3c]       ; RVA на РЕ-заголовок
          add    esi,ebx              ;   ..(делаем из него VA)
          mov    esi,[esi+0x78]       ; RVA на таблицу-экспорта
          add    esi,ebx              ;   ^^^
          mov    edx,esi              ; запомнить начало в EDX
          mov    esi,[esi+0x1c]       ; RVA на таблицу-адресов
          add    esi,ebx              ;   ^^^
          mov    edx,[edx+0x20]       ; RVA на указатели имён функций
          add    edx,ebx              ;   ^^^

          mov    ecx,20               ; сколько функций вывести на экран
          mov    ebp,edx              ; адресуем имена через EBP
          mov    edi,text             ; приёмник для wsprintf()

@addr:    lodsd                       ; EAX = очередной адрес функции из ESI
          mov    edx,dword[ebp]       ; EDX = указатель на её имя
          add    eax,ebx              ; перевод обоих из RVA,
          add    edx,ebx              ;    ..в виртуальные VA-адреса.
          add    ebp,4                ; следующее имя..
          pusha                       ; wsprintf портит все регистры, поэтому запомнить их
        cinvoke  wsprintf,edi,frmt,eax,edx    ; сбрасываем данные в буфер, как строку
          popa                        ; восстанавливаем все регистры
          push   ecx                  ; смещем указатель в буфере
          mov    ecx,-1               ;   ..(счёчик для SCASB на максимум)
          xor    al,al                ;     ..(что искать в буфере - нуль)
          repne  scasb                ;        ..(поиск AL в EDI) !!!
          dec    edi                  ;           ..(не считая терминального нуля)
          pop    ecx                  ; ECX на родину.
          loop   @addr                ; промотать цикл ECX-раз..

         invoke  MessageBox,0,text,capt,0
         invoke  ExitProcess, 0
.end start

K32func.png



 

Marylin

Mod.Assembler
Red Team
05.06.2019
309
1 393
BIT
387
В семерке же есть ASLR.
дефолтную базу можно узнать из "Опционального заголовка" библиотеки (в тотале нажать Ctrl+Q), а потом ASLR уже рандомизирует её. У Win-7 она помоему такая-же (проверить не могу), а вот у 64-битной кернел - уже другая.

kBase.png
 
  • Нравится
Реакции: bin1101d и CKAP

dreamseller

Green Team
02.03.2019
36
7
BIT
0
метод через PEB зависим от версии windows, в универсальных шеллкодах лучше не использовать
В семерке он насколько помню находит kernelbase.dll, можно найти kernel32 в списке LIST_ENTRY, если шеллкод заточен под конкретную систему
или (если это инжект длл) попробовать использовать LoadLibraryExA
 
  • Нравится
Реакции: bin1101d и Marylin

dreamseller

Green Team
02.03.2019
36
7
BIT
0
допилил для семерки вариант:
Код:
;search kernel32 addr
        mov     ebx, dword [fs:0x30]
        mov     ebx, dword [ebx + 0x0C]
        mov     ebx, dword [ebx + 0x14]
@krnl32_search:
        mov     ebx, dword [ebx]
        mov     ecx, [krnl32_size]
        mov     esi, [ebx + 0x28]
        mov     edi, krnl32
        repe cmpsb
        or      ecx, ecx
        jnz     @krnl32_search
        
@krnl32_founded:
        mov     ebx, [ebx + 0x10]
        db 0xcc
        
krnl32          du      'kernel32.dll'
krnl32_size     dd      krnl32_size - krnl32
но _LDR_DATA_TABLE_ENTRY меняется от версии к версии, поэтому ну ее нафиг :)
 
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!