Перехват видео в браузере или TCP сниффер под Windows на коленке (часть вторая)

в 16:49, , рубрики: cниффер, windows, внедрение DLL, ловушки, перехват системных функций, Программирование, Софт, метки: , , , ,

Это обещанное дополнение к моему предыдущему посту, в котором рассказывается про альтернативную технику установки ловушек на функции. Одним из недостатков реализации ловушек в предыдущем посте было постоянное переписывание кода в начале перехваченной функции. В качестве альтернативы было упомянуто решение, которое не требовало бы постоянной перезаписи. Принципиальная схема его работы предоставлена здесь:

image
Исходники, детали и пояснения под катом…

Итак, я нашел время чтобы дописать реализацию «т.н. постоянных» ловушек, которые не требуют перезаписи кода при вызове изначальной функции. Исходный код находится тут, а скомпилированная версия находится тут.

Мы-таки открыли банку с червями или ящик Пандоры, как вам больше нравится. И самое время для меня снять с себя обязательства по написанию идеального перехватчика, т.к. количество ветвлений и проверок для его реализации плавно переводит эту задачу в недетерминированную область.

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

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

Поэтому мы будем исполнять первые инструкции оригинального кода из точки входа непосредственно из того места куда мы их скопировали, а в конце перейдем обратно на те инструкции которые мы не затерли. И тут мы подходим вплотную к особенностям машинного кода процессоров x86.

Инструкции процессора имеют разный размер, который может варьироваться от 1 до 15 байт. Таким образом априори неизвестно что и как именно мы поломаем после установки ловушки методом патча из пяти байт. В точке входа, по идее, может быть все что угодно, поэтому есть два варианта — либо перейти от общего метода к частному либо усложнить логику.

«От общего к частному» означает что мы будем устанавливать наши ловушки для конкретных функций, предварительно изучив их все и найдя лучший метод для каждой в отдельности. Это не очень удобно, потому что например, для того же самого WinSock, существует множество его версий типа XP, XPSP1, XPSP2, XPSP3, Vista, VistaSP1, Vista PU, Windows 7 и т.д. А еще есть всякие обновления безопасности, которые тоже могут устанавливать свои версии системных библиотек. Поэтому для корректной работы ловушек по частному методу придется отсматривать все возможные версии одной и той же библиотеки. К счастью в большинстве своем разные версии библиотек не так сильно отличаются друг от друга именно в точке входа.

«Усложненная логика» подразумевает написание анализатора инструкций который
a) Сможет вычислять размер опкодов процессора чтобы всегда передавать управление на «целую» инструкцию, а не куда-то в середину.
b) Сможет обнаруживать проблемные места и корректно их обрабатывать

Самым наглядным примером проблемного места будет служить «чужая» ловушка. Предположим, что мы не первые кто внедрился в процесс и перехватил функцию. В таком случае мы первой инструкцией увидим в большинстве случаев JMP XX XX XX XX, в ряде случаев PUSH XX XX XX XX RETN, а в теории вообще что угодно. Стало быть в случае JMP мы не сможем корректно выполнить скопированный код из другого места, потому что переход относительный и считается относительно адреса его исполнения. Поэтому переход этот придется пересчитывать. А вот в случае PUSH-RETN адрес пересчитывать не надо. А в случае «что угодно» может быть вот такой пример проблемного места:

00004EE1: 3C01		cmp         al,1
00004EE3: 7404		je          000004EE9
00004EE5: E916010000	jmp         000005000

В данном случае мы с вами получаем комплексный гемморой случай:

  • В наличии относительный условный переход, который затрется нашими 5 байтами и который мы не сможем корректно выполнить в своем обработчике по причине его относительности. Выхода два: либо пересчитывать переход на изначальный адрес и, как следствие, весьма вероятно менять длину инструкции с двух на шесть байт, ибо двухбайтовый переход короткий и работает на плюс/минус 128 байт; либо копировать к себе еще и тот код на который вероятно прыгнет условный переход. В первом случае поскольку мы поменяем размер инструкции, то придется пересчитывать весь последующий код по новым адресам. Во втором случае очевидно что нам надо моделировать ситуацию когда переход произойдет и когда не произойдет и втискивать возврат в то место которое гарантированно получит управление. Опять же если учесть что код, куда указывает условный переход, тоже может иметь свои условные и безусловные переходы, то мы сталкиваемся с весьма интересной задачей
  • Часть безусловного перехода, а точнее сама инструкция JMP тоже затрется нашими пятью байтами. Если мы перейдем обратно на следующую неповрежденную инструкцию, то очевидно что изначальный код будет работать некорректно, ибо мы пропускаем безусловный переход. В другом случае, если мы копируем всю инструкцию целиком, то безусловный переход будет совершен куда-то в гиперпространство, поскольку он относителен. Поэтому надо будет высчитывать правильный адрес из нового места, что вкупе с предыдущим пунктом обещает нам нескучную жизнь.

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

По вышеприведенным соображениям в данном примере я сфокусировался на частном решении для WS2_32. Итак, все функции которые мы перехватываем, начинаются стандартно:

8BFF	mov	edi,edi
55	push	ebp
8BEC	mov	ebp,esp
83ECXX	sub	esp,0000000XX

Подозреваю что Майкрософт это сделал специально чтобы самим использовать похожую технику в каком-нибудь Microsoft Detours. Но сам лично я его не видел и не щупал, поэтому фантазировать дальше на эту тему не буду. Так вот, начало всех требуемых WinSock функций у нас идеально подходит под размер безусловного перехода в начале функции, потому что:
a) Первые три инструкции занимают ровно пять байт
b) Первые три инструкции не привязаны к конкретному адресу и могут быть исполнены внутри нашего обработчика

За сим мы приступаем к модификации кода Interceptor.cpp… Первым дело заведем директиву которая отделит зерна от плевел старый метод от нового и допишем код установки:

/************************************************************************/
/* Установка ловушки на функцию                                         */
/************************************************************************/
BOOL hookInstall(PAPIHOOK thisHook)
{
...
#ifdef PERSISTENT_HOOKS // Ловушки без перезаписи кода
    thisHook->oldCodeSize = 0;
    // Нам нужно 5 байт чтобы воткнуть безусловный переход в начало функции
    // и при этом не поломать следующую за ним инструкцию. Поэтому едем вниз 
    // по инструкциям и считаем нужное количество байт (5 или больше)
    for (; thisHook->oldCodeSize < HOOK_CODE_SIZE; )
    {
        // Вычисляем размер следующей инструкции
        int opSize = getX86InstructionLength((PBYTE) thisHook->oldAddr + thisHook->oldCodeSize);
        // Двигаемся дальше
        thisHook->oldCodeSize += opSize;
    }
    // Резервируем память для копирования начала функции + переход обратно
    thisHook->oldCode = (UCHAR *) xmalloc(thisHook->oldCodeSize + HOOK_CODE_SIZE);
    if (NULL == thisHook->oldCode) {
        SetLastError(ERROR_NOT_ENOUGH_MEMORY);
        return FALSE; 
    }
    DWORD fl; // Устанавливаем EXECUTE права чтобы не ругался DEP
    VirtualProtect(thisHook->oldCode, thisHook->oldCodeSize + HOOK_CODE_SIZE, 
        PAGE_EXECUTE_READWRITE, &fl);
    // Копируем просчитанное начало функции к себе в укромное место
    memcpy(thisHook->oldCode, thisHook->oldAddr, thisHook->oldCodeSize);
    // В оконцовке копированного к себе начала вставляем переход обратно...
    thisHook->oldCode[thisHook->oldCodeSize] = asmJMP;
    // ...на следующую инструкцию после скопированного фрагмента кода,
    // который затер наш JMP
    DWORD *d = (DWORD*) ((PBYTE) (thisHook->oldCode + thisHook->oldCodeSize + 1));
    *d = (DWORD) ((PBYTE) thisHook->oldAddr + thisHook->oldCodeSize) - (DWORD) d - sizeof(DWORD);
#else // Ловушки с перезаписью кода 
    // Сохраняем старый код в укромном месте
    thisHook->oldCodeSize = HOOK_CODE_SIZE;
    memcpy(thisHook->oldCode, thisHook->oldAddr, HOOK_CODE_SIZE);
#endif 
	// Все готово для перехвата
	thisHook->isInstalled = TRUE;
    // Включаем перехват
	hookEnable(thisHook); 
#ifdef PERSISTENT_HOOKS
    // Восстанавливаем изначальные права страницы кода
    VirtualProtect(thisHook->oldAddr, HOOK_CODE_SIZE, oldFlags, &oldFlags);
#endif
    return TRUE;
}

Здесь должно быть все понятно за исключением вызова магической функции getX86InstructionLength(). Чтобы не мучаться с вопросами, отвечу. Изначально эта функция всегда возвращала 5. Поскольку мы договорились реализовать частный случай, то и спросу большого нет — 5 байт это длина трех первых инструкций в точке входа. Но я пошел немного дальше и в исходниках вы найдете настоящий анализатор длины инструкций x86. Это так сказать мой вклад в чей-то будущий умный перехватчик… В данном посте я бы не хотел отклоняться в ту сторону по причине обширности вопроса.

Итак, вышеописаный код копирует первые 5 байт из точки входа к себе и опосля них ставит безусловный переход обратно в изначальную функцию на инструкцию под порядковым номером 4, которая остается неповрежденной. Таким образом мы «пробрасываем» статичный вызов изначальной функции без использования трюков с переписыванием кода в точке входа.
Похожим образом дописываем снятие ловушек и восстановления исходного состояни функции:

/************************************************************************/
/* Снятие ловушки с функции                                             */
/************************************************************************/
BOOL hookRemove(PAPIHOOK thisHook)
{
	// Если не установлен, то и спросу нет
	if (!thisHook->isInstalled)
		return FALSE;
#ifdef PERSISTENT_HOOKS
    DWORD oldFlags;
    if (!VirtualProtect(thisHook->oldAddr, HOOK_CODE_SIZE, PAGE_EXECUTE_READWRITE, &oldFlags)
        || IsBadWritePtr(thisHook->oldAddr, HOOK_CODE_SIZE)) {
            SetLastError(ERROR_WRITE_PROTECT);
            return FALSE; // Невозможно переписать точку входа
    }
#endif
	// Восстанавливаем изначальное состояние
	hookDisable(thisHook);
#ifdef PERSISTENT_HOOKS
    VirtualProtect(thisHook->oldAddr, HOOK_CODE_SIZE, oldFlags, &oldFlags);
    if (thisHook->oldCode != NULL)
        xfree(thisHook->oldCode);
#endif
	// Маркируем перехватчик как не установленный
	thisHook->isInstalled = FALSE;
	// Чистим код перехвата и сохраненный код функции
	thisHook->newAddr = (LPVOID) NULL;
	thisHook->oldAddr = (LPVOID) NULL;
	return TRUE;
}

Стоит отметить что для корректной работы с DEP (Data Execution Prevention) нам нужно установить права EXECUTE на кусок памяти где будет храниться копия оригинального кода. Иначе система просто не даст этому коду выполниться. Поскольку теперь нам не надо ничего переписывать, стоит дописать и макросы на объявление перехватываемых функций:

#ifdef PERSISTENT_HOOKS
/************************************************************************/
/* Удобный макрос для определения своего обработчика функции            */
/* Реализует логику вида: 

typedef int (WSAAPI *PF_send) (SOCKET s, char *buf, int len, int flags);
int WSAAPI my_send(SOCKET s, char *buf, int len, int flags)
{
	PAPIHOOK thisHook = hookFind(my_send); 
	PF_send p_send = (PF_send) thisHook->oldProc;
	if (NULL == thisHook || NULL == p_send) 
		return (int) 0;
	int rv;
...
/************************************************************************/
#define DEFINE_HOOK(RTYPE, CTYPE, NAME, ARGS)
    typedef RTYPE(CTYPE *PF_##NAME) ##ARGS; 
    RTYPE CTYPE my_##NAME ##ARGS 
    { 
        PAPIHOOK thisHook = hookFind(my_##NAME); 
        PF_##NAME p_##NAME = (PF_##NAME) thisHook->oldProc; 
        if (NULL == thisHook || NULL == p_##NAME) 
            return (RTYPE) 0; 
        RTYPE rv;

#define LEAVE_HOOK() } 
    return rv; 

Как видно из кода, вместо изначальной импортированной функции мы теперь вызываем ее прототип по адресу thisHook->oldProc. Это как раз то место куда мы сохранили оригинальные пять байт и дополнили их переходом обратно.

По большому счету вобщем-то и все. В исходниках директива PERSISTENT_HOOKS отвечает за переключение старого и нового методов перехвата, и по умолчанию используется новый метод без перезаписи.

Кстати я сознательно не стал упоминать еще один метод при котором перехват осуществляется глубоко изнутри изначального кода. Это еще более интересная задача, хотя и более сложная. Кто-то меня спрашивал, «зачем это вообще надо?». Отвечаю намеком, есть программы которые считают контрольные суммы фрагментов кода в точке входа и чутко реагируют на их изменения. Но к счастью браузеры пока не относятся к их числу.

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

С уважением,
//st

Автор: stpark


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js