Статья ASM. РЕ файл – ломаем стереотипы. Часть - 4 Импорт (практика)

Marylin

Mod.Assembler
Red Team
05.06.2019
326
1 452
BIT
705
Logo.png

Статья является логическим продолжением предыдущей, а потому для лучшего понимания происходящего рекомендуется ознакомиться с начала с ней. Рассматриваются простые трюки с таблицей импорта РЕ-файла, которые дают совсем не простые результаты на практике. Тесты проводились с использованием таких инструментов как: просмотрщик «PE Anatomist», редактор исполняемых файлов «CFF Explorer», отладчик «х64dbg», и дизассемблер «IDA-Free». Цель считалась достигнутой, если любой из этих инструментов не сможет правильно опознать содержимое и структуру файла, что исказит в конечном счёте смысл.


1. Общее положение дел с импортом

Создать универсальный инструмент анализа исполняемых файлов очень сложно по той причине, что беспечно лежащий на диске файл и его образ в памяти всё-же отличаются как по содержимому, так и по внутренней структуре. Поэтому софт классифицируют по признакам «или диск, или память», но не оба варианта сразу. Например первые версии IDA были примитивны, поскольку работали именно с файлом на диске, в результате чего малварь начала атаковать её приёмами в памяти, к которым IDA была совсем не готова. Так, у почти всех дизассемблеров наших дней появился свой загрузчик образов лишь для того, чтобы макс приблизить анализ к реальным условиям в памяти. При этом бинарь на диске отошёл уже на второй план, т.к. совмещать полезное с приятным не получается. Загрузчики для Win, Linux, Mac лежат в папке IDA\Loaders.

Что касается импорта API из системных dll, то здесь царит полная анархия, ведь за структуры борются аж три участника театра действий: компилятор говорит одно, загрузчик трактует реалии по своему, и эту кашу приходится переваривать дизассемблеру. Понаблюдав за импортом с высоты птичьего полёта (и как следуя осмыслив происходящее), мы можем вклиниться в их цепочку со-своим самоваром так, что нарушим привычный ход событий. Однако в лице интеллектуальной Иды соперник у нас достойный, а потому не нужно рассчитывать на простоту реализации методов байпаса – всё это сложно, и фактически сводится к написанию альтернативного загрузчика, так-что придётся потуже подтянуть пояса.


2. Импорт API по ординалу

Мы уже знаем, что найти любую функцию в экспорте библиотек можно не только по привычному нам имени в виде текстовой строки, но и по её порядковому номеру, т.н. «ординалу». Эта классная штука позволяет быстро находить функцию во-всём списке экспорта и всё-бы хорошо, только номера API меняются от версии к версии dll, а значит доверять им опасно. Инженеров из Microsoft абсолютно не беспокоят проблемы программистов, иначе можно-же было придерживаться совместимости хотя-бы на уровне ничего не значащих номеров. Трудно что ли добавить функцию в хвост dll без нарушения последовательности предыдущих, а если наоборот вырезать, то вместе с номером? Ан нет.. нужно чтобы всё было через известное место. На рис.ниже экспорт из msvcrt.dll, где это отчётливо видно.

Ordinal_1.png

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

Когда мы импортируем API по имени, компилятор сохраняет эти имена в массиве таблицы Name, а указатели на них прописывает в массиве таблицы Lookup. Теперь лоадер берёт имена из Lookup, находит их вирт.адреса в экспорте той библиотеки, которую мы импортируем, и прописывает эти адреса в нашу таблицу IAT. Это типичный алгоритм импорта функций из dll.

Но когда импорт осуществляется по ординалу, компилятор оставляет таблицу Name пустой, а в массиве Lookup вместо указателей сохраняет номера функций. Чтобы загрузчик не принял ординал за указатель, в соответствующей записи ImageThunkData в качестве флага взводится старший бит 31/63 (отсчёт с нуля). Таким образом, если старший бит сброшен, то перед нами указатель на имя, а если взведён – значит ординал.

Lockup.png

Уязвимость в том, что обнаружив ординал в записи ThunkData, буквально все загрузчики по определению не смогут получить доступ к имени в таблице Name, т.к. указателя теперь попросту нет и он превратился в порядковый номер. Это касается и умных инструментов анализа, включая Иду с x64dbg. А раз до имени никто не сможет дотянуться, значит оно может быть любым и не соответствовать ординалу. Главное перед вызовом API найти адрес функции в экспорте dll, и просто записать его в свою таблицу IAT, что и продемонстрировано на рис.выше.

Значит план должен быть примерно такой:

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

2. Открываем готовый к употреблению бинарь во-вьювере «РЕ Anatomist», и записываем в блокнот все указатели на имена из таблицы Lookup. В файле на диске, RVA-адреса можно считать уже константами, поскольку загрузчик если и изменит, то только базу образа в памяти. Мы сохраняем указатели для того, чтобы избавить себя от лишних вычислений в коде. Каждую API должны будут описывать по три константы: RVA на имя (хинт не нужен, а потому +2), длина имени в байтах, и RVA на привязанную к имени запись в IAT. Теперь в секцию-данных своей программы прописываем все эти константы, и заново перекомпилируем код.

NameLink.png

3. На следующем шаге, открываем файл в редакторе «CFF Explorer», и меняем указатели в Lookup на (!)любые ординалы. При этом таблица Name в бинарнике остаётся не тронутой, т.к. мы правим файл на диске. С этого момента, никто, кроме нас не сможет уже получить доступ к именам в таблице Name, т.к. перед правкой мы позаботились о том, чтобы сохранить указатели на них. Как видим, инструменты анализа файла на диске тут-же потеряли фокус на имя функции. Проделываем эту операцию «по смене пола» для остальных API.

NameToOrdinal.png

4. Главное для нас – это беречь как зеницу ока линки на имена используемых в коде API, а ординалы не играют здесь абсолютно никакой роли. Они существуют лишь для того, чтобы запутать инструменты анализа. Поэтому можно навести жути на реверсера, и ознакомившись с экспортом dll, выбрать ординалы каких-нибудь «страшных» функций типа DeleteReg(), DeleteFile() и прочих. Как результат, если мы используем в своей программе, например тот-же printf(), то в дизассемблере/отладчике она будет видна совсем под другим именем.

Ida_1.png

5. Но это результат уже проделанной работы, в процессе которой нам нужно будет перед вызовом API подменять в таблице IAT адреса вызываемых функций. Эта необходимость связана именно с беспорядочным разбросом ординалов в разных версиях одной DLL. В принципе организовать правку записей в IAT не так сложно (см.спойлер). Здесь я написал специальную процедуру для этого, которой в аргументе передаётся флаг вызываемой API в диапазоне 0..2. Но в идеале нужно было перехватить API например сплайсингом, и заниматься в его обработчике правкой до.., и опять восстановлением адресов после вызова.

C-подобный:
format   pe64 console
entry    start
include 'win64ax.inc'
;//----------
section '.data' data readable writeable
text       db  10,' Calling API functions by ordinals.'
           db  10,' Works on all versions from WinXP to Win11.',0

imgBase    dq  0
impTable   dq  0

dllBase    dq  0
expTable   dq  0

gtch       dw  7, 0x307a, 0x3058, 0  ;// константы fn. _getch, exit, printf()
ext        dw  5, 0x3084, 0x3060, 0  ;// nameLen, name, iat, padding
prn        dw  7, 0x308c, 0x3068, 0

;//----------
section '.code' code readable executable
start:   sub     rsp,8

;// Находим свою базу, хотя стоит в дефолте 0х00400000, но мало-ли что
         call    @f
@@:      pop     rsi                   ;// дельта
         and     rsi, not 0xfff
@@:      cmp     word[rsi],'MZ'
         je      @mz
         sub     rsi,0x1000
         jmp     @b
@mz:     mov     [imgBase],rsi

;// Адрес таблицы-импорта в нашем каталоге
         mov     eax,dword[rsi+0x3c]   ;// PE-Header
         add     rax,rsi
         mov     eax,dword[rax+0x90]   ;// Import_DIR
         add     rax,rsi
         mov     [impTable],rax

;// Поиск базы msvcrt.dll
@@:      mov     rax,[rax]
         mov     eax,dword[rax+16]     ;// FirstThunk = IAT
         add     rax,rsi
         mov     rsi,[rax]             ;// взять адрес любой api
         and     rsi, not 0xfff
@@:      cmp     word[rsi],'MZ'        ;// ищем базу..
         je      @f
         sub     rsi,0x1000
         jmp     @b
@@:      mov     [dllBase],rsi

;// Поиск таблицы-экспорта в msvcrt.dll
         mov     eax,dword[rsi+0x3c]
         add     rax,rsi
         mov     eax,dword[rax+0x88]   ;// запись #00 в каталоге dll
         add     rax,rsi
         mov     [expTable],rax
frame
        stdcall  FindValidOrdinal,0    ;// зовём свою процедуру с аргументом
        cinvoke  printf,text

        stdcall  FindValidOrdinal,1
        cinvoke  _getch

        stdcall  FindValidOrdinal,2
        cinvoke  exit,0
endf

;//********************************************************
;// Динамический поиск функции, и запись её адреса в IAT
;//********************************************************
proc  FindValidOrdinal fnCode
         mov     rbx,qword[prn]    ;// значения сразу 3-х констант
         cmp     ecx,0             ;// printf() требуется вызвать?
         je      @ok
         mov     rbx,qword[gtch]
         cmp     ecx,1
         je      @ok
         mov     rbx,qword[ext]

;// Разбрасываем константы из RBX по регистрам
@ok:     movzx   ecx,bx          ;// RCX = длина имени выбранной функции
         shr     rbx,16
         movzx   eax,bx          ;// RAX = линк на имя выбранной функции
         add     rax,[imgBase]
         shr     rbx,16
         add     rbx,[imgBase]   ;// RBX = линк на адрес в IAT

         mov     rsi,[expTable]      ;// экспорт msvcrt.dll
         mov     r10d,dword[rsi+28]  ;// Address table
         mov     r11d,dword[rsi+32]  ;// Name pointer

@@:      push    rcx r11
         add     r11,[dllBase]
         mov     edi,dword[r11]
         add     rdi,[dllBase]
         mov     rsi,rax
         repe    cmpsb           ;// поиск fn по имени в экспорте dll
         jecxz   @found
         pop     r11 rcx
         add     r10,4
         add     r11,4
         jmp     @b

@found:  add     rsp,16
         add     r10,[dllBase]
         mov     r10d,dword[r10]
         add     r10,[dllBase]

         mov     [rbx],r10           ;// подмена адреса в IAT !!!
      ret
endp
;//----------
section '.idata' import data readable writeable  ;//<--- нужно открыть на запись
library  msvcrt,'msvcrt.dll'
include 'api\msvcrt.inc'


3. Ошибки в реализации дескрипторов импорта

Очередной трюк связан с некоторыми свойствами самой таблицы импорта. Как уже говорилось в предыдущей части цикла, IMAGE_IMPORT_TABLE включает в себя N дескрипторов (см.схему выше), каждый из которых описывает полный импорт из одной библиотеки DLL. В первом поле дескриптора «OriginFirstThunk» хранится указатель на таблицу имён функций Lookup, а в последнем поле «FirstThunk» указатель на таблицу адресов этих функций IAT.

Чтобы загрузчик не пошёл пахать поле до самого конца файла, компилятор вставляет терминальные нули в конец таблиц INT и IAT – это флаг того, что таблица закончилась. Но если в HEX-редакторе мы принудительно прихлопнем IAT не дав ей и начаться, то загрузчик посчитает дескриптор невалидным, и вовсе откажется загружать в память нашего процесса эту DLL. А раз так, значит как имя самой либы, так и имена экспортируемых её функций могут быть абсолютно любыми, причём нет ограничения и на длину их текстовых строк.

NullDesriptor.png

В качестве пруфов напишем консольный «HelloWorld» с функциями из msvcrt.dll, но чтобы вручную не создавать дескриптор фиктивной DLL, добавим в хвост после exit() любые функции из kernel32.dll. Они вызываться не будут, а потому даже параметры можно не указывать – нам главное получить второй дескриптор в таблице импорта. Теперь откроем бинарь в редакторе «CFF Explorer», и изменим всего одну (самую первую) запись в IAT на нуль. Там-же сразу меняем имя библиотеки от фонаря, а так-же имена всех её функций.

C-подобный:
format   pe64 console
entry    start
include 'win64ax.inc'
;//----------
section '.data' data readable writeable
text     db  10,' Fix IAT entry.'
         db  10,' Works on all versions from WinXP to Win11.',10,0
;//----------
section '.code' code readable executable
start:   sub     rsp,8
frame
        cinvoke  printf,text
@exit:  cinvoke  _getch
        cinvoke  exit,0
endf
         invoke  DeleteFile
         invoke  CreateFile
         invoke  FindFirstFile
;//----------
section '.idata' import data readable writeable
library  msvcrt,'msvcrt.dll',kernel32,'kernel32.dll'
include 'api\msvcrt.inc'
include 'api\kernel32.inc'

Чтобы не оставлять следов, можно после всех правок затереть нулями и сами инструкции kernel32.dll в секции-кода, хотя можно и оставить. В результате дизассемблер увидит библиотеку, которой по факту нет в теле нашего процесса, а от списка её функций может и вовсе хватить удар. Зато под отладчиком будет чисто, т.к. DLL не загрузится в память (хотя это уже совсем другая история).

FixIat.png


Заключение

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

Вложения

  • Pe_Tricks.zip
    1,3 КБ · Просмотры: 83
Мы в соцсетях:

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