Отключение главной нити приложения от отладчика и уход от перехвата CreateFile()

от автора

Это будет пробная статья на хабре, скажем так, первый блин 🙂

Основывается она на материалах блога: alexander-bagel.blogspot.ru/
Поэтому все отсылки к контексту, подразумевают изучение ранее опубликованных материалов.

Собственно приступим к цитированию:

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

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

Можно поступить даже еще проще, взяв утилиту Process Monitor, за авторством небезызвестных Марка Руссиновича и Брюса Когсвела.

Данная утилита, абсолютно спокойно показывает полный стек вызовов, включая интересующие нас адреса возврата.

image

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

image

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

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

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

Одним из вариантов обхода CreateFile() является прямой вызов соответствующей функции ядра, в обход kernel32->kernelbase->ntdll. Результат такого вызова можно увидеть на картинке:

image

При прямом вызове уже не идет речь о установке 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 реагирует вот такими ошибками:

image

После чего остается только срывать процесс и запускать среду заново.

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

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

Результат работы демо приложения будет примерно такой:

image

Забрать пример можно здесь.

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

1. подмена параметров в драйвере
2. подмена пути к приложению в блоке окружения процесса
3. подмена результата функции на свой собственный, встроив переходник на свой код по адресу возврата функции, который нам покажет тот-же Process Monitor.

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

ссылка на оригинал статьи http://habrahabr.ru/post/177625/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *