Статья является логическим продолжением предыдущей, а потому для лучшего понимания происходящего рекомендуется ознакомиться с начала с ней. Рассматриваются простые трюки с таблицей импорта РЕ-файла, которые дают совсем не простые результаты на практике. Тесты проводились с использованием таких инструментов как: просмотрщик «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, где это отчётливо видно.
Нет проблем, когда импорт гарантированно осуществляется внутри одной ОС между системными процессами – они точно знают, что номер API в dll именно такой и не может быть другим. Но для нашего софта это непозволительная роскошь, т.к. он должен работать на всех версиях ОС. Ординалы привлекательны тем, что позволяют скрыть используемые в приложении функции от инструментов анализа, причём как в файле на диске, так и после загрузки его в память. Если взять половину обязанностей загрузчика на себя, то получим заслуживающий внимание финт против Иды с отладчиком.
Когда мы импортируем API по имени, компилятор сохраняет эти имена в массиве таблицы Name, а указатели на них прописывает в массиве таблицы Lookup. Теперь лоадер берёт имена из Lookup, находит их вирт.адреса в экспорте той библиотеки, которую мы импортируем, и прописывает эти адреса в нашу таблицу IAT. Это типичный алгоритм импорта функций из dll.
Но когда импорт осуществляется по ординалу, компилятор оставляет таблицу Name пустой, а в массиве Lookup вместо указателей сохраняет номера функций. Чтобы загрузчик не принял ординал за указатель, в соответствующей записи ImageThunkData в качестве флага взводится старший бит 31/63 (отсчёт с нуля). Таким образом, если старший бит сброшен, то перед нами указатель на имя, а если взведён – значит ординал.
Уязвимость в том, что обнаружив ординал в записи ThunkData, буквально все загрузчики по определению не смогут получить доступ к имени в таблице Name, т.к. указателя теперь попросту нет и он превратился в порядковый номер. Это касается и умных инструментов анализа, включая Иду с x64dbg. А раз до имени никто не сможет дотянуться, значит оно может быть любым и не соответствовать ординалу. Главное перед вызовом API найти адрес функции в экспорте dll, и просто записать его в свою таблицу IAT, что и продемонстрировано на рис.выше.
Значит план должен быть примерно такой:
1. Собираем приложение как обычно с импортом по имени, чтобы получить от компилятора заполненную таблицу Name. Имена нам будут нужны для динамического поиска нужной функции в экспорте dll, что позволит запускать приложение под любой ОС.
2. Открываем готовый к употреблению бинарь во-вьювере «РЕ Anatomist», и записываем в блокнот все указатели на имена из таблицы Lookup. В файле на диске, RVA-адреса можно считать уже константами, поскольку загрузчик если и изменит, то только базу образа в памяти. Мы сохраняем указатели для того, чтобы избавить себя от лишних вычислений в коде. Каждую API должны будут описывать по три константы: RVA на имя (хинт не нужен, а потому +2), длина имени в байтах, и RVA на привязанную к имени запись в IAT. Теперь в секцию-данных своей программы прописываем все эти константы, и заново перекомпилируем код.
3. На следующем шаге, открываем файл в редакторе «CFF Explorer», и меняем указатели в Lookup на (!)любые ординалы. При этом таблица Name в бинарнике остаётся не тронутой, т.к. мы правим файл на диске. С этого момента, никто, кроме нас не сможет уже получить доступ к именам в таблице Name, т.к. перед правкой мы позаботились о том, чтобы сохранить указатели на них. Как видим, инструменты анализа файла на диске тут-же потеряли фокус на имя функции. Проделываем эту операцию «по смене пола» для остальных API.
4. Главное для нас – это беречь как зеницу ока линки на имена используемых в коде API, а ординалы не играют здесь абсолютно никакой роли. Они существуют лишь для того, чтобы запутать инструменты анализа. Поэтому можно навести жути на реверсера, и ознакомившись с экспортом dll, выбрать ординалы каких-нибудь «страшных» функций типа DeleteReg(), DeleteFile() и прочих. Как результат, если мы используем в своей программе, например тот-же printf(), то в дизассемблере/отладчике она будет видна совсем под другим именем.
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. А раз так, значит как имя самой либы, так и имена экспортируемых её функций могут быть абсолютно любыми, причём нет ограничения и на длину их текстовых строк.
В качестве пруфов напишем консольный «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 не загрузится в память (хотя это уже совсем другая история).
Заключение
Думаю для разминки достаточно, а в следующий раз рассмотрим более сложные приёмы, с использование статического и отложенного импортов. Если дотошно копать все детали, то тема эта просто огромна, и на каждом шаге нас поджидает что-то особенное. Поэтому лучше заниматься практическим анализом файлов самостоятельно, а данный тред воспринимать как обычную шпаргалку. В скрепке найдёте 2 исполняемых бинаря для тестов, пока!