Оконные хуки: взгляд изнутри — Архив WASM.RU

Все статьи

Оконные хуки: взгляд изнутри — Архив WASM.RU

В последнее время на форумах все чаще стали проскальзывать вопросы о том, как получить список оконных хуков, установленных на события мыши или клавиатуры. Уметь их перечислять и идентифицировать было бы довольно полезно, учитывая обилие различных видов spyware в наши дни. И хотя кейлогеры aka клавиатурные шпионы, работающие в режиме пользователя с помощью этих самых хуков, потихоньку уступают свое место драйверам-фильтрам, тема эта все еще продолжает оставаться актуальной. К сожалению, сегодня не многие представители security-софта несут в себе такой функционал, как анализ оконных хуков. До написания кода к этой статье мне была известна лишь одна такая утилита - китайский IceSword. Он перечисляет хуки, но не снимает их и не знает, в какой библиотеке dll находится обработчик «ловушки». Да и под Windows Vista этот инструмент не работает.

Надо заметить, что информации по этому вопросу в сети не так уж и много. По крайней мере я не нашел ни одного топика или, тем более, статьи, где давался бы исчерпывающий ответ. На форуме wasm.ru была замечена тема, в которой некто DelExe вкратце объяснял, как выудить нужную нам информацию, но его код по определенным причинам был непереносим и работал только с двумя версиями Windows, да и описания недокументированных структур были в корне не верны. Остальные найденные материалы даже упоминать не буду, настолько скудными они оказались. Вот я и подумал, что неплохо было бы немного шире осветить проблему оконных хуков, и решил внести свою лепту.

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

Где-то в адресном пространстве каждого gui-процесса лежит важная структура, именуемая SHAREDINFO. Она общая для всех, но может быть спроецирована по разным адресам в памяти. Поэтому система хранит указатель на нее в не экспортируемой из user32.dll переменной gSharedInfo. Благо со времен предыдущей статьи нашелся способ отыскать gSharedInfo динамически, а не хранить жёстко прописанные адреса в коде для каждой версии ОС. Способ основан на поиске по сигнатурам программного кода примерно следующего вида. Код этот находится в конце процедуры UserRegisterWowHandlers():

.text:77D735D2                 mov     dword ptr [eax+44h], offset _GetFullUserHandle@4 
.text:77D735D9                 mov     dword ptr [eax+48h], offset _NtUserGetMenuIndex@8 
.text:77D735E0                 mov     dword ptr [eax+4Ch], offset _WowGetDefWindowProcBits@
.text:77D735E7                 mov     dword ptr [eax+50h], offset _NtUserFillWindow@16 
.text:77D735EE                 mov     dword ptr [eax+54h], offset _aiClassWow
.text:77D735F5                 mov     eax, offset _gSharedInfo
.text:77D735FA                 pop     ebp
.text:77D735FB                 retn    8

Вся фишка в том, что во всех проверенных мной версиях системы от win_2k до win_vista инструкция «mov eax, offset _gSharedInfo» всегда располагается за последней инструкцией «mov dword ptr [eax+xxh], …». А вот и функция для автоматического нахождения gSharedInfo:

function GetSharedInfoAddress(): PSHAREDINFO;
var
  WowHandlers: pointer;
  i: DWORD;
begin
result := nil;
WowHandlers := GetProcAddress(GetModuleHandle('user32.dll'), 'UserRegisterWowHandlers');
if WowHandlers <> nil then
  for i := DWORD(WowHandlers) to DWORD(WowHandlers) + $1000 do
    if (WORD(pointer(i)^) = WORD($40C7)) and (BYTE(pointer(i + 7)^) = BYTE($B8)) then
      begin
      result := pointer(DWORD(pointer(i + 8)^));
      exit;
      end;
end;

Итак, указатель добывать научились, пора познакомиться с самой структурой SHAREDINFO:

PSHAREDINFO = ^SHAREDINFO;
    SHAREDINFO = packed record
      psi: pointer; 
      aheList: PHANDLEENTRY_ARRAY;
      pDispInfo: pointer;
      ulSharedDelta: DWORD;
      end;

Нас интересует ее второй элемент под названием aheList. Это указатель на массив структур HANDLEENTRY, каждая из которых, кроме первой, неиспользуемой, описывает соответствующий gui-объект в ядре. В этом массиве лежат описатели всех окон, меню, хуков. Кстати, младшее слово хэндла окна или хука есть не что иное, как индекс соответствующей структуры в этом массиве. К сожалению, мне не удалось отыскать то место, где хранится текущая размерность (кол-во элементов) массива, поэтому для перебора мы будем использовать цикл от нуля до 0x0000FFFF – 1 (константа HMINDEXBITS, содержит максимально допустимый индекс в массиве) и ловить исключение при попытке доступа к невыделенной памяти. Давайте посмотрим на HANDLEENTRY поближе:

PHANDLEENTRY = ^HANDLEENTRY;
    HANDLEENTRY = packed record
      pHead: pointer;
      pOwner: pointer;
      bType: BYTE;
      bFlags: BYTE;
      wUniq: WORD;
      end;

pOwner указывает на родительский объект. Это может быть W32THREAD или W32PROCESS, но о них позже. bType отражает тип хэндла и содержит константу TYPE_HOOK (0x05) в случае, если HANDLEENTRY описывает хук. bFlags держит в себе информацию о текущем состоянии хэндла (закрыт, в процессе закрытия и т.д.) и большую часть времени обнулен. Что-то мне подсказывает, что это поле используется системой не так активно, как изначально задумывали разработчики. Хотя если ради эксперимента установить bFlags равным HANDLEF_DESTROY (0x01), то через некоторое время структура будет автоматически стерта из массива и хэндл станет невалиден. wUniq в ранних версиях ОС должен был совпадать со старшим словом хэндла, но в Windows Vista это не актуально, так что можно забыть про этот элемент. Самое интересное я оставил напоследок, это поле pHead. Оно содержит указатель на объект в ядре, в случае с хуком на tagHOOK: PHOOK = ^HOOK;

    tagHOOK = packed record
      head: THRDESKHEAD;
      phkNext: PHOOK;
      iHook: integer;
      offPfn: DWORD;
      flags: DWORD;
      ihmod: integer;
      ptiHooked: pointer;
      rpdesk: pointer;
      end;
    HOOK = tagHOOK;

Имея эту структуру, мы можем выудить такую информацию о хуке, какую только возможно. Но начнем по порядку. Поле head это стандартный заголовок объектов такого типа, он встречается и в оконной структуре tagWND и в других местах. Мы вернемся к нему чуть позже, так как в этом заголовке хранятся кое-какие важные для нас данные. phkNext содержит указатель на следующий хук в цепочке. Именно на этот элемент опирается работа функции CallNextHookEx(). iHook говорит о том, какого типа хук – WH_CBT, WH_KEYBOARD или другого. Полный список типов есть в msdn. offPfn в случае локального хука представляет собой полный адрес callback-обработчика событий в АП соответствующего процесса. В случае же глобального хука offPfn содержит смещение обработчика относительно базового адреса загрузки dll, так как этот адрес может разниться от процесса к процессу. Мы ведь помним, что для того, чтобы фильтровать сообщения во всех процессах, система должна подгрузить dll с обработчиком фильтра в каждый из них. К слову скажу, что библиотека подгружается не сразу во все процессы, а при обработке хука функцией CallNextHookEx() или DispatchMessage(), в случае если она не была подгружена ранее. ptiHooked содержит указатель на структуру W32THREAD, описывающую «похуканный» поток, но только в том случае, если хук локален. Если же хук глобален, ptiHooked содержит ноль, однако мы все равно можем «достучаться» до потока, создавшего хук. В этом нам поможет заголовок head, который описывается структурой типа THRDESKHEAD – нужный нам указатель лежит в head.throbjhead.pti:

PTHRDESKHEAD = ^THRDESKHEAD;
    THRDESKHEAD = packed record
      throbjhead: THROBJHEAD;
      deskhead: DESKHEAD;
      end;
PTHROBJHEAD = ^THROBJHEAD;
    THROBJHEAD = packed record
      head: HEAD;
      pti: pointer;                   // То, что нам нужно – указатель на W32THREAD потока-создателя
      end;
PHEAD = ^HEAD;
    HEAD = packed record
      h: DWORD;                       // Хэндл объекта
      cLockObj: DWORD;
      end;

  PDESKHEAD = ^DESKHEAD;
    DESKHEAD = packed record
      rpdesk: pointer;                // PDESKTOP
      pSelf: pointer;
      end;

Из структуры W32THREAD легко получить указатель на ETHREAD потока, так как он всегда является ее первым элементом, а от туда Id потока и процесса, точнее из CLIENT_ID. Описание самой структуры я приводить не буду - оно очень сильно разнится от версии к версии. Так же небольшой дискомфорт доставляет то, что смещение CLIENT_ID в ETHREAD тоже «гуляет» на разных системах, но сей незамысловатый код, суть которого, я думаю, разъяснять не нужно, легко справляется с этой неувязкой:

ULONG
GetClientIdOffset()
{
		PCWSTR		pFuncName       = L"PsGetCurrentThreadId";
		UNICODE_STRING	usFuncName;
		PVOID			pFuncAddr;
		ULONG			result = 0;

		RtlInitUnicodeString(&usFuncName, pFuncName);
		pFuncAddr = MmGetSystemRoutineAddress(&usFuncName);
		if (pFuncAddr) {
			result = *(PULONG)((ULONG)pFuncAddr + 8);
			}

		return	result - 4;
}

Самый интересный элемент в tagHOOK это ihmod. Его я не зря затронул последним. Если ihmod равен -1, то хук локален. Если же нет, то это индекс в одном загадочном, нигде не описанном и не экспортируемом массиве, имя которому aatomSysLoaded. Состоит он предположительно из 60-ти элементов, каждый из которых размером в слово и представляет собой глобальный атом из UserAtomTable. Как мы знаем, существует две таблицы глобальных атомов – одна ntoskrnl, другая win32k. С первой работают такие функции, как NtAddAtom(), NtFindAtom() и другие. Со второй, которая UserAtomTable, в основном работают внутренние функции win32k. Так вот, в массиве aatomSysLoaded хранятся атомы, значением каждого из которых является wide-строка с полным путем к dll, содержащей обработчик хука. Теперь все упирается в то, как найти в памяти этот самый массив. Именно на этом моменте споткнулся вышеупомянутый DelExe, он не нашел метода отыскать aatomSysLoaded динамически и хранил в коде адреса, верные лишь для win 2k sp2 и win xp sp1. Надо заметить, это довольно странно, так как поиск не представляет собой ни чего сложного. К примеру, в win32k.sys от Windows XP SP1 этот массив «упоминается» целых 25 раз! Мое внимание сразу привлек следующий код:

.text:BF82CE6A                 cmp     aatomSysLoaded[esi*2], di
.text:BF82CE72                 jz      short loc_BF82CE79

Дело в том, что инструкция типа «cmp some_addr[esi*2], di» встречается в win32k лишь единожды. Причем это справедливо абсолютно для всех версий линейки NT начиная от w2k! Однако мне не хотелось делать поиск всего лишь по одной инструкции, и я начал искать еще одну, на которую можно было бы сделать дополнительный упор, для подстраховки, так сказать. Долго искать не пришлось. Обратите внимание на вторую строчку приведенного выше дизассемблерного листинга. Это JZ – условный прыжок на 5 байт вперед. Он так же присутствует везде и стоит всегда на своем месте. Правда, по ходу тестирования на разных системах, в которое свой неоценимый вклад внес небезызвестный EP_X0FF, был обнаружен единственный билд win32k, в котором на месте JZ находился JNZ с прыжком на 0x1C байт вперед. Номер этого билда - 5.0.2195.6708 (Windows 2000). Итак, учитывая все вышеизложенное, можно смело «стряпать» универсальный код поиска aatomSysLoaded, который будет работать везде:

ULONG
GetaatomSysLoadedAddress()
{
	ULONG		i, result = 0;
	PVOID		Win32kBase = GetWin32kBase();

	if (Win32kBase) {
		for (i = (ULONG)Win32kBase + 0x1000; i < (ULONG)Win32kBase + 0x000FFFFF; i++) {
			if ( (*(ULONG*)i == 0x753C3966) 
			     ( (*(SHORT*)(i + 8) == 0x0574) ||
			     (*(SHORT*)(i + 8) == 0x1C75) ) ) {
				result = *(ULONG*)(i + 4);
				break;
				}
			}
		}

	return result;
}

Думаю, стоит упомянуть, что выполнение данного кода возможно лишь в контексте gui-процесса, поэтому если контекст заранее неизвестен, то следует приаттачиться к нужному процессу с помощью KeStackAttachProcess(), например к csrss.exe – он всегда присутствует в системе. После того, как aatomSysLoaded найден, мы уже можем найти нужный нам атом, связанный с конкретным хуком:

Atom = aatomSysLoaded[Hook.ihmod];

Дальше можно получить полный путь к dll. На самом низком уровне это реализуется с помощью недокументированного экспорта ntoskrnl.exe, функции RtlQueryAtomInAtomTable():

NTSTATUS
RtlQueryAtomInAtomTable(
    __in PVOID AtomTableHandle,
    __in RTL_ATOM Atom,
    __out_opt PULONG AtomUsage,
    __out_opt PULONG AtomFlags,
    __inout_bcount_part_opt(*AtomNameLength, *AtomNameLength) PWSTR AtomName,
    __inout_opt PULONG AtomNameLength
    )

Но нам не известно значение UserAtomTableHandle, которое надлежит передать в функцию, а поиск его – лишний геморрой. Не будем усложнять себе жизнь и пойдем более простым путем. В драйвере win32k существует внутренняя функция UserGetAtomName(). Сама она не экспортируется, зато есть известные функции, активно ее использующие. Это NtUserGetClipboardFormatName() и NtUserGetAtomName(). Обе две они присутствуют в Shadow SSDT, и именно с их помощью мы достигнем цели:

W32KAPI
int
NtUserGetClipboardFormatName(
    IN UINT format,
    OUT LPWSTR lpszFormatName,
    IN UINT chMax);

Описания NtUserGetAtomName() я ни где не нашел, но установил, что у нее всего два параметра, по назначению эквивалентные первым двум параметрам NtUserGetClipboardFormatName(). Обе эти функции можно использовать и в ядре, и в юзермоде - user32.dll с успехом это делает. В случае успешного вызова искомый нами путь к dll запишется по адресу, указанному в lpszFormatName. Если же вы хотите свести работу с недокументированными функциями к минимуму, то в юзермоде можно использовать GetClipboardFormatNameW(), как я и сделал (смотрите пример, приложенный к статье):

GetClipboardFormatNameW(Atom, @dllPath, 256);

Собственно говоря, на этом вся теория заканчивается. Теперь у нас достаточно информации, чтобы перечислить и идентифицировать все установленные в системе хуки. К статье приложен пример, который на практике реализует все вышеизложенное – в случае, если что-то окажется непонятным, вы всегда сможете обратиться к этим исходникам. На картинке внизу запечатлена работа моей программки в реальном времени, там вы можете заметить и множество локальных хуков, и несколько глобальных, установленных довольно известной утилитой Punto Switcher:

Файлы к статье

2002-2013 (c) wasm.ru