Обдин из стандартных приемом, осложняющим изучение вашего приложения, является эмуляция выполнения API функций.
Рассмотрим некий частный случай.
Например, когда требуется определить критическое место в приложении, допустим, открытие самого себя с целью проверить контрольную сумму приложения, устанавливается BP на API функцию CreateFile(), где мы будем ждать входного параметра с путем к нашему исполняемому файлу, после чего в отладчике пройдем до кода, вызвавшего данную функцию и приступим непосредственно к анализу.
Можно поступить даже еще проще, взяв утилиту Process Monitor, за авторством небезызвестных Марка Руссиновича и Брюса Когсвела.
Данная утилита, абсолютно спокойно показывает полный стек вызовов, включая интересующие нас адреса возврата.
Нам остается только запустить отладчик и установить BP на нужный адрес.
Правда данная утилита показывает уже адреса возврата, а не сам адрес вызова непосредственной функции. Но об этом позже.
Представьте, что тело нашего приложения уже изменено. Самым простым решением обхода проверки контрольной суммы приложения, будет подмена параметра lpFileName в перехваченной функции CreateFile() на путь к не измененному телу приложения. После данной операции не нужно даже изучать механизм расчета контрольной суммы, не важно что там применяется, проверка цифровой подписи, MD5 хэш или банальная CRC32. Т.к. данный алгоритм будет работать с телом оригинального приложения — все проверки будут успешно пройдены.
Стало быть задача выглядит в первом приближении так: максимально затруднить возможность изменения параметров вызываемой функции. Использование навесных защит в данном случае не спасет, т.к. в итоге всегда останется вызов функции CreateFile(), тело которой поместить под защиту виртуальной машины мягко говоря проблематично (тут с нюансом, некоторые навесные защиты могут самостоятельно проэмулировать данный вызов, но сейчас речь не о них).
Одним из вариантов обхода CreateFile() является прямой вызов соответствующей функции ядра, в обход kernel32->kernelbase->ntdll. Результат такого вызова можно увидеть на картинке:
При прямом вызове уже не идет речь о установке BP на какие либо функции, т.к. такие вызовы попросту отсутствуют. Да, у нас остается на руках адрес возврата после вызова, но нюанс в том, что подавляющее большинство современных навесных протекторов достаточно толково размазывают код и наличие на руках кода возврата вызова еще не означает, что мы сможем определить место самого вызова с целью изменить тот или иной параметр.
Для реализации данного алгоритма давайте разберемся, что происходит при вызове вот такого кода (delphi7 + Windows 7 32бит):
hFile := CreateFile(PChar(ParamStr(0)), GENERIC_READ,
FILE_SHARE_READ or FILE_SHARE_WRITE, nil, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, 0);
1. Происходит переход на таблицу импорта
2. Передача управления функции CreateFileA() библиотеки kernel32.dll
3. Передача управления функции CreateFileW() библиотеки kernel32.dll
4. Передача управления функции CreateFileW() библиотеки KernelBase.dll
5. Передача управления функции ZwCreateFile() библиотеки ntdll.dll
6. Передача управления в ядро
Мы хотим уйти от первых пяти пунктов и выполнить непосредственно пункт №6.
Для этого нам поможет реализация функции ZwCreateFile в библиотеке ntdll.dll, описание параметров самой функции и небольшой ликбез :)
Рассмотрим реализацию ZwCreateFile третьего кольца (UserMode) под различными системами.
Обратите внимание на машинный код инструкции MOV.
Windows Vista — 32 бита (6.0.6002.18005)
.text:77F343D4 public ZwCreateFile
.text:77F343D4 ZwCreateFile proc near
.text:77F343D4 B8 3C 00 00 00 mov eax, 3Ch
.text:77F343D9 BA 00 03 FE 7F mov edx, 7FFE0300h
.text:77F343DE FF 12 call dword ptr [edx]
.text:77F343E0 C2 2C 00 retn 2Ch
.text:77F343E0 ZwCreateFile endp
Windows 7 — 32 бита (6.1.7601.17725)
.text:77F055C8 public ZwCreateFile
.text:77F055C8 ZwCreateFile proc near
.text:77F055C8 B8 42 00 00 00 mov eax, 42h
.text:77F055CD BA 00 03 FE 7F mov edx, 7FFE0300h
.text:77F055D2 FF 12 call dword ptr [edx]
.text:77F055D4 C2 2C 00 retn 2Ch
.text:77F055D4 ZwCreateFile endp
Windows 8 — 32 бита (6.2.8400.0)
.text:6A21629C public ZwCreateFile
.text:6A21629C ZwCreateFile proc near
.text:6A21629C B8 64 01 00 00 mov eax, 164h
.text:6A2162A1 E8 03 00 00 00 call sub_6A2162A9
.text:6A2162A6 C2 2C 00 retn 2Ch
.text:6A2162A6 ZwCreateFile endp
Как видите, различия достаточно минимальные. На что стоит обратить внимание, это на то, что в регистр EAX заносится некое число, и производится вызов некоей функции. Данная функция называется KiFastSystemCall и выглядит примерно вот таким образом (в зависимости от ОС):
mov edx, esp
sysenter
ret
Вместо SYSENTER может быть вызов INT 0x2E, но это уже не существенно.
Немного отличается реализация данной функции под 64-битными системами:
Windows 8 — 64 бита, 32-битная ntdll (6.2.8400.0)
.text:6B2BF470 public ZwCreateFile
.text:6B2BF470 ZwCreateFile proc near
.text:6B2BF470 B8 53 00 00 00 mov eax, 53h
.text:6B2BF475 64 FF 15 C0 00 00 00 call large dword ptr fs:0C0h
.text:6B2BF47C C2 2C 00 retn 2Ch
.text:6B2BF47C ZwCreateFile endp
Т.к. код у нас 32-битный, а система 64-битная, здесь уже происходит вызов шлюза FS:0C0h который в итоге передает выполнению родной 64-битной функции, выглядящей вот так:
Windows 8 — 64 бита, 64-битная ntdll (6.2.8400.0)
.text:0000000180003110 public NtOpenFile
.text:0000000180003110 NtOpenFile proc near
.text:0000000180003110 4C 8B D1 mov r10, rcx
.text:0000000180003113 B8 31 00 00 00 mov eax, 31h
.text:0000000180003118 0F 05 syscall
.text:000000018000311A C3 retn
.text:000000018000311A NtOpenFile endp
Но, не смотря на данный нюанс, регистр EAX инициализируется даже в этом случае.
Число, помещаемое в EAX является индексом из таблицы KeServiceDescriptorTable, посредством которого ядро определяет, какую именно функцию необходимо вызвать в данный момент времени. Данные индексы вшиты непосредственно в код NTDLL, меняются от версии к версии (изменение таблицы может произойти даже в результате минорного патча), поэтому нам необходимо научиться их получать динамически.
В этом нам поможет следующая функция:
type
// типы STD индексов
TSDTIndex = (
sdtNtSetInformationThread,
sdtZwOpenFile,
sdtNtQueryObject,
WOW64ReservedAddr);
var
FunctionSDTIndex: array [TSDTIndex] of DWORD = (0, 0, 0, 0);
procedure InitSDTTable;
const
// имена функций, индексы которых мы будем получать
ApiNames: array [TSDTIndex] of string =
(
'NtSetInformationThread',
'ZwOpenFile',
'NtQueryObject',
''
);
const
KSEG0_BASE = $80000000;
MM_HIGHEST_USER_ADDRESS = $7FFEFFFF;
MM_USER_PROBE_ADDRESS = $7FFF0000;
MM_SYSTEM_RANGE_START = KSEG0_BASE;
MustWrite = PAGE_READWRITE or PAGE_WRITECOPY or
PAGE_EXECUTE_READWRITE or PAGE_EXECUTE_WRITECOPY;
OBJ_CASE_INSENSITIVE = $00000040;
FILE_SYNCHRONOUS_IO_NONALERT = $00000020;
FILE_READ_DATA = 1;
var
pSectionAddr, dwLength: DWORD;
lpBuffer: TMemoryBasicInformation;
pNtHeaders: PImageNtHeaders;
ExportAddr: TImageDataDirectory;
ProcessExport: Boolean;
ImageBase: DWORD;
IED: PImageExportDirectory;
I: Integer;
FuntionAddr: Pointer;
NamesCursor: PDWORD;
OrdinalCursor: PWORD;
Ordinal: DWORD;
CurrentFuncName: string;
SDT: TSDTIndex;
begin
// начинаем искать с адреса, по которому загружена NTDLL
pSectionAddr := GetModuleHandle('ntdll.dll');
ImageBase := 0;
ExportAddr.VirtualAddress := 0;
ExportAddr.Size := 0;
dwLength := SizeOf(TMemoryBasicInformation);
// зачитываем WOW регистр, в нем содержится адрес функции,
// которая должна быть вызвана вместо sysenter
// для 32-битных систем данный регистр обнилен
asm
push eax
mov eax, fs:[$c0]
mov I, eax
pop eax
end;
FunctionSDTIndex[WOW64ReservedAddr] := I;
_Write(Format('WOW64Reserved: %d', [FunctionSDTIndex[WOW64ReservedAddr]]));
// бежим по страницам памяти процесса
while pSectionAddr < MM_USER_PROBE_ADDRESS do
begin
// получаем информацию о странице
if VirtualQuery(Pointer(pSectionAddr), lpBuffer, dwLength) <> dwLength then
RaiseLastOSError;
try
// если страница не используется - пропускаем ее
if (lpBuffer.State = MEM_FREE) or (lpBuffer.State = MEM_RESERVE) then
Continue;
// если страница защищена - пропускаем ее
if (lpBuffer.Protect and PAGE_GUARD) = PAGE_GUARD then
Continue;
if (lpBuffer.Protect and PAGE_NOACCESS) = PAGE_NOACCESS then
Continue;
_Write(Format('Обрабатывается адрес: %x', [pSectionAddr]));
// проверка - находится ли на странице начало РЕ файла?
if PWord(lpBuffer.BaseAddress)^ = IMAGE_DOS_SIGNATURE then
begin
// дополнительная проверка НТ заголовка
pNtHeaders := Pointer(Integer(lpBuffer.BaseAddress) +
PImageDosHeader(lpBuffer.BaseAddress)^._lfanew);
ExportAddr.VirtualAddress := 0;
ExportAddr.Size := 0;
ImageBase := DWORD(lpBuffer.BaseAddress);
if (pNtHeaders^.Signature = IMAGE_NT_SIGNATURE) and
(pNtHeaders^.FileHeader.Machine = IMAGE_FILE_MACHINE_I386) then
begin
_Write('Обнаружен PE образ.');
// файл валиден - получаем указатель на таблицу экспорта
ExportAddr := pNtHeaders.OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT];
if ExportAddr.VirtualAddress <> 0 then
Inc(ExportAddr.VirtualAddress, ImageBase)
else
ExportAddr.Size := 0;
end;
_Write(Format('Адрес таблицы экспорта: %x', [ExportAddr.VirtualAddress]));
_Write(Format('Размер таблицы экспорта: %x', [ExportAddr.Size]));
end;
// Проверка, находится ли таблица экспорта в рамках текущей страницы
ProcessExport := False;
if ExportAddr.Size <> 0 then
if ExportAddr.VirtualAddress >= DWORD(lpBuffer.BaseAddress) then
ProcessExport :=
ExportAddr.VirtualAddress + ExportAddr.Size <
DWORD(lpBuffer.BaseAddress) + lpBuffer.RegionSize;
// мы нашли экспорт - обрабатываем его
if ProcessExport then
begin
if (ImageBase = 0) or (ExportAddr.VirtualAddress = 0) then Exit;
IED := PImageExportDirectory(ExportAddr.VirtualAddress);
_Write(Format('Имя модуля: %s', [string(PAnsiChar(ImageBase + IED^.Name))]));
// проверка, экспорт ли это нашей библиотеки?
if LowerCase(string(PAnsiChar(ImageBase + IED^.Name))) = 'ntdll.dll' then
begin
_Write('Обрабатываем таблицу экспорта');
// да, это наша библиотека, теберь ищем адреса наших функций
I := 1;
NamesCursor := Pointer(ImageBase + DWORD(IED^.AddressOfNames));
OrdinalCursor := Pointer(ImageBase + DWORD(IED^.AddressOfNameOrdinals));
while I < Integer(IED^.NumberOfNames) do
begin
// поиск будет производить по имени функции
CurrentFuncName := string(PAnsiChar(ImageBase + PDWORD(NamesCursor)^));
for SDT := sdtNtSetInformationThread to sdtNtQueryObject do
if ApiNames[SDT] = CurrentFuncName then
begin
// Смотрим номер функции в таблице ординалов
Ordinal := OrdinalCursor^ + IED^.Base;
// Через ординал вычисляем реальный адрес функции
FuntionAddr := Pointer(ImageBase + DWORD(IED^.AddressOfFunctions));
FuntionAddr := Pointer(ImageBase +
PDWORD(DWORD(FuntionAddr) + (Ordinal - 1) * 4)^);
// Делаем поправку на первую инструкцию MOV
FuntionAddr := Pointer(DWORD(FuntionAddr) + 1);
// Читаем SDT индекс функции
FunctionSDTIndex[SDT] := PDWORD(FuntionAddr)^;
_Write(Format('Обнаружена функция %s - SDT индекс %d',
[CurrentFuncName, FunctionSDTIndex[SDT]]));
end;
Inc(I);
Inc(NamesCursor);
Inc(OrdinalCursor);
end;
end;
ImageBase := 0;
end;
// Проверка, нашли ли все что хотели?
if FunctionSDTIndex[sdtNtSetInformationThread] <> 0 then
if FunctionSDTIndex[sdtZwOpenFile] <> 0 then
if FunctionSDTIndex[sdtNtQueryObject] <> 0 then
Exit;
finally
// Если есть что искать, переходим на следующую страницу.
Inc(pSectionAddr, lpBuffer.RegionSize);
end;
end;
end;
Данная функция определяет адрес загрузки библиотеки NTDLL.DLL, переходит на таблицу экспорта данной библиотеки, ищет записи о требуемых нам функциях (в данном примере рассматриваются NtSetInformationThread, ZwOpenFile и NtQueryObject), определяет их фактический адрес в памяти и считывает SDT индекс требуемой функции, опираясь на машинный код функций, приведенный выше. Результаты помещаются в массив FunctionSDTIndex.
Теперь, имея на руках валидные SDT индексы требуемых для примера функций, рассмотрим непосредственно декларацию самой ZwOpenFile, которую мы собрались вызвать.
NTSTATUS ZwOpenFile(
_Out_ PHANDLE FileHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_ POBJECT_ATTRIBUTES ObjectAttributes,
_Out_ PIO_STATUS_BLOCK IoStatusBlock,
_In_ ULONG ShareAccess,
_In_ ULONG OpenOptions
);
Шесть параметров, размещаемых на стеке. Первый и четвертый идут по ссылке, третий указатель. Ну что-ж, делаем вызов:
// открываем текущий файл на чтение
// ZwOpenFile
// ===========================================================================
_Write('Эмулируем вызов ZwOpenFile');
_Write('открываем текущий файл на чтение');
SysCallArgument := FunctionSDTIndex[sdtZwOpenFile];
oa.Length := SizeOf(TObjectAttributes);
oa.RootDirectory := 0;
oa.ObjectName := @UnicodeStr;
oa.Attributes := OBJ_CASE_INSENSITIVE;
oa.SecurityDescriptor := nil;
oa.SecurityQualityOfService := nil;
UnicodeStr.Buffer := StringToOleStr('??' + ParamStr(0));
UnicodeStr.Length := Length(UnicodeStr.Buffer) * SizeOf(WideChar);
UnicodeStr.MaximumLength := UnicodeStr.Length + SizeOf(WideChar);
asm
// сохраняем значения регистров
mov SAVED_EBP, ebp
mov SAVED_ESP, esp
// заполняем параметры
// на стеке параметры размещаются с последнего по первый
push FILE_SYNCHRONOUS_IO_NONALERT // OpenOptions
push FILE_SHARE_READ + FILE_SHARE_WRITE + FILE_SHARE_DELETE // ShareAccess
lea eax, iosb // получаем адрес OUT параметра IoStatusBlock
push eax // размещаем на стеке
lea eax, oa // ObjectAttributes является ссылка, получаем адрес
push eax // и так-же размещаем на стеке
push FILE_READ_DATA + SYNCHRONIZE // DesiredAccess
lea eax, hFile // получаем адрес OUT параметра FileHandle
push eax // и опять на стек
// с подготовкой параметров для вызова функции разобрались,
// теперь определяемся как ее вызывать,
// ибо в зависимости от ОС код вызова немного отличается
movzx eax, IsWOW64
or eax, eax
jz @32Bit
// вызов для 64-битных систем
lea eax, @64bit
push eax
push eax
mov eax, WOW64Addr
push eax
mov eax, SysCallArgument
xor ecx, ecx
lea edx, dword ptr ss:[esp+4*3]
ret
@64bit:
add esp, 4
jmp @FINALIZE
@32Bit:
// вызов для 32-битных систем (XP и выше)
lea eax, @FINALIZE
push eax
push eax
movzx eax, NeedInt2E
or eax, eax
jnz @NT_CODE
mov edx, esp
mov eax, SysCallArgument
sysenter
@NT_CODE:
// вызов для W2K и ниже
pop eax
lea edx, esp + 4
mov eax, SysCallArgument
int $2E
nop
@FINALIZE:
// запоминаем результат
mov Status, eax
// восстанавливаем значения регистров
mov ebp, SAVED_EBP
mov esp, SAVED_ESP
end;
if Status <> 0 then
hFile := 0;
_Write(Format('Результат вызова %x', [Status]));
_Write(Format('Хэндл %d', [hFile]))
;
Собственно на этом задача выполнена.
Теперь нюансы: как видите код может вызываться тремя разными способами.
SYSENTER, INT2E и WOW64 регистр. Это связано с особенностями реализации различных операционных систем и их битностью. Второй нюанс, это сохранение регистров EBP/ESP. Связано в тем что после вызова функции под 64-битными системами выравнивание на стеке немного отличается, поэтому мы восстанавливаем его принудительно, дабы не разрушить приложение.
Вызовы остальных двух функций здесь рассмотрены не будут, но в кратце — NtSetInformationThread отключает главный поток приложения от отладчика. После ее вызова попытка установки BP приведет к разного плана ошибкам. Например Delphi 7 реагирует вот такими ошибками:
После чего остается только срывать процесс и запускать среду заново.
В исходном коде примера данный вызов отключен директивой DISABLE_HIDEFROMDEBUGGER во избежание, если-же хотите проверить его работу, закоментируйте декларацию данной директивы и перебилдите пример
Вторая функция NtQueryObject показывает работу с полученным хэндлом файла (просто как пример).
Результат работы демо приложения будет примерно такой:
Забрать пример можно здесь.
Ну и в качестве постскриптума. Данный подход не панацея, а просто попытка показать один из подходов к построению защиты приложения. Естественно данный код не спасет вас от грамотного исследователя. Обойти даже данный вариант реализации можно как минимум тремя способами на вскидку:
1. подмена параметров в драйвере
2. подмена пути к приложению в блоке окружения процесса
3. подмена результата функции на свой собственный, встроив переходник на свой код по адресу возврата функции, который нам покажет тот-же Process Monitor.
Но отпугнуть начинающего исследователя ПО поможет, может быть даже озадачит более продвинутого специалиста, а от профессионала спасет только распространение программы в виде исходных кодов ;)
Автор: Rouse