вторник, 21 августа 2012 г.

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

    Как уже описывалось ранее, стандартным приемом, осложняющим изучение вашего приложения, является эмуляция выполнения 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.

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

Подсветка кода выполнена при помощи: http://highlight.hohli.com/

9 комментариев:

  1. Александр, спасибо за еще одну прекрасно раскрытую тему.

    Те, кто пользуется дебаггером, осознают, насколько обычное приложение не защищено от взлома. Сократить многочисленные вызовы API в своих программах мешают незнание тонкостей и различия ОС. Описанная техника схожа с той, которая используется некоторыми вирусами. Несомненно, статья и исходники будут в равной мере интересны многим программистам и исследователям.

    К описанным в статье способам обхода защиты можно добавить 2 способа ее обнаружения:
    1. API-шные имена хранятся в открытом виде, что позволяет проследить их использование. Конечно, вместо этого можно было бы хранить хеш, но поиск по таблице все равно трудно спрятать.
    2. Вызов ParamStr(0) и, соответственно, неприкрытый вызов API – лучшей подсказки исследователю трудно ожидать.

    ОтветитьУдалить
    Ответы
    1. Саш, спасибо за рецензию, я ее достаточно долго ждал именно в твоем исполнении :)
      По поводу твоих примечаний - это абсолютно верно, изначально я хотел показать более сложный вариант, реализующий поиск АПИ без текста функций и детектирующий наблюдение за алгоритмом поиска по хэшу как через BP на область памяти так и через int 3 BP и dr регистры, а так-же получение пути через PEB с такими-же предосторожностями, но во первых посчитал что данный код будет через чур уж сложным, а во вторых, готовится к выпуску статья о защите с использованием метода DebugBlock и простейшей реализации наномитов, в которой данные тонкости вероятно мне придется рассмотреть :)
      Я постоянно боюсь что просто останусь не понятым из-за черезмерного усложнения и так не самого простого кода, поэтому приходится себя сдерживать :)

      Удалить
    2. Ну, во-первых, можно растянуть удовольствие: по одной небольшой статье на каждую фишку. А, во-вторых, у тебя действительно здорово получается доносить до мозга довольно сложные вещи. Не так часто это встречается в наше время между батонами и абырвалгами. В общем, ждем.

      Удалить
    3. спасибо за статью!
      попробую все это переварить и понять)

      Удалить
  2. Доброго времени суток Александр, спасибо за статью.

    Хотелось бы заметить, что инструкции sysenter и int 2e при мутации или виртуализации протектором не буду изменены, а так же протектор не сможет заменять адреса возврата, мы можем спокойно трассировать такие скрытые вызовы сервисов и применять к ним bp или скрипты. Метод взятия индекса SDT указанный в статье крайне не желательный, разумнее использовать диззасемблер и по возможности чтение образа из файла (дабы избежать сплайсинга и т.п.).

    Интересно были ли какие-то исследования совместимости такого метода с антивирусами?

    ОтветитьУдалить
    Ответы
    1. Да, такие инструкции не эмулируются, но легко их обнаружить и установить BP на адрес возврата в коде находящемся под VM ExeCryptor-а достаточно проблематично.
      По поводу антивирусов, есть небольшие нюансы, но они все решаемы.

      Удалить
    2. > По поводу антивирусов, есть небольшие нюансы, но они все решаемы.
      Можно чуть по подробнее, просто сам думаю над защитой своего софта (легального), есть идеи об описанной вами технике, но понятия не имею как это воспримут антивирусы

      Удалить
    3. Симантику не нравится такое поведение, пишет незарегистрированный вызов (или что-то на подобие), лечится подписыванием файла.
      DrWeb последний зачем-то начал сплайсить вызовы функции прямо в третьем кольце - в этом случае приходится зачитывать с диска.
      По остальным жалоб от пользователей не было, правда в "боевом" проекте применяется немного не такая методика, но похожая.

      Удалить
    4. Ясно, спасибо за информацию

      Удалить