Перехват видео в браузере или TCP сниффер под Windows на коленке (часть первая)
Данный опус предполагает некое знание Windows, WinAPI и немного C++, поэтому если какие то очевидные для меня моменты требуют более подробного разъяснения, то спрашивайте. Сразу внесу ясность, готовой программы для перехвата видео, построенной на принципах изложенных в этом посте, не существует (по крайней мере я ничего такого не писал). Существуют некие заготовки и теоретические измышления, в основном мотивированные как антагонисты решению с NDIS Miniport драйвером и голубыми экранами. Итак, если гипотетически предположить что у нас есть некий модуль способный перехватывать HTTP или TCP/IP пакеты из браузера, то как именно мы будем ловить видео? Есть два варианта:
- Анализировать адреса в виде URL. Для этого потребуется перехватывать исходящие пакеты содержащие HTTP GET, и смотреть куда именно этот GET направлен. Решение достаточно сомнительное, поскольку требует специфических знаний о конкретном сайте. С другой стороны адреса подходящие под шаблон типа «www.youtube.com/watch?v=o78nFVB1tJA» позволят отфильтровать запросы именно потокового видео непосредственно ДО получения потока.
- Проверять ответы с сервера Для этого потребуется перехватывать входящие пакеты и проверять их HTTP заголовки на предмет Content-Type. Очевидно что для видео они будут специфичны для конкретных форматов. Например для вышеупомянутой ссылки на Flash Video от YouTube, где я играю на саксофоне, ответ сервера будет содержать заголовок «Content-Type: video/x-flv», что однозначно сообщает нам о том что за заголовком пойдет Flash Video. В случае с MPEG4 заголовок будет содержать video/mp4 ну и так далее
Первое что приходит на ум для написания перехватчика, это внедрение DLL в процесс браузера. Некоторые в этом месте прекратят читать, потому что дальше все понятно. Кому все понятно могут скачать исходники тут и скомпилированную версию вот тут (да, всего 3Кб). Если вы решили попробовать как все это работает, то настоятельно рекомендую взять 32-битный браузер и вырубить весь софт который использует похожие трюки, например AdMuncher (привет Murray & shannow!), потому что данный пример плевать хотел на корректное сожительство с таким софтом (это исправимо). Результаты работы ищите в виде .log файлов в %TEMP%.
Для всех остальных придется кто-что пояснить, хотя системные ловушки и внедрение DLL в процессы Windows это достаточно избитая тема. Конечный результат внедрения своей DLL в процесс браузера будет выглядеть примерно так:
То есть внутри каждого процесса браузера будет сидеть наша самописная DLL, которая и будет перехватывать нужные пакеты. Сразу возникает два вопроса
- Как внедрить DLL?
- Как ловить пакеты?
На первой стадии очевидно что нам необходимо написать два модуля – главное приложение и DLL. Главное приложение будет внедрять, а DLL соответственно внедряться. Не мудрствуя лукаво запустим Vistual Studio и сразу напишем главное приложение (Injector.cpp):
Что делает вышеописанный код? В первой строке из соображений компактности кода мы ставим точку входа в приложения прямо в WinMain(), без прелюдий. В исходниках я вообще отрезал MSVCRT за ненадобностью. Далее, мы загружаем наш перехватчик, находим в нем экспортную функцию под номером 1 (import by ordinal) и ставим глобальную ловушку типа CBT, используя созданые параметры. Потом мы просто выводим модальное сообщение нажать кнопку ”OK” для завершения и уходим в астрал. Все. Этого достаточно для внедрения DLL во все процессы, которые так или иначе используют User32 WinAPI для работы с окнами. CBT, это сокращение от Computer-Based Training. Ловушка WH_CBT вообще хороша тем что ”… Система вызывает данную ловушку перед активацией, созданием, уничтожением, минимизацией, максимизацией, перемещением или изменением размера окна. А таже перед завершением системной команды, перед удалением события клавиатуры или мышки из очереди сообщений, перед установкой фокуса ввода и перед синхронизацией с системной очередью сообщений. ” Такой вот вольный перевод MSDN. На деле это означает что она сработает для 99% приложений которые написаны в соответствии со стандартной оконной архитектурой. Прелесть данного метода в том что на самом деле нам не надо будет возиться с системой ловушек Windows как таковой.
Начинаем писать DLL- Приложение проинициализрует DLL при загрузке в процесс
- DLL останется в адресном пространстве процесса до его завершения
- По завершению или по внешнему событию приложение деинициализирует DLL перед выгрузкой
Кроме того нам надо указать экспорт под номером 1. Для этого мы пишем стандартный DEF файл (Interceptor.def) и не забываем скормить его линковщику через параметр /DEF:
Все. Теперь DLL у нас подклеивается к процессам и сидит в них до завершения. Для того чтобы нам не внедряться в ненужные процессы и корректно себя вести внутри главного приложения (да-да, ведь оно же загружает и инициализирует DLL) сделаем дополнительную проверку:
Таким образом, если приложение нам неизвестно, то мы в него не грузимся. Теперь займемся непосредственно перехватом функций WinSock.
Механизм перехвата функцийДля начала нужно пояснить очевидные вещи. Поскольку те кому все уже и так ясно до этой строчки все равно не дочитают, то сделаю отступ. К моему изумлению я понял что некоторые программисты, весьма умные и продвинутые, не всегда четко представляют себе как работают системные библиотеки в процессах Windows. В связи с этим настоятельно рекомендую просмотреть другой пост на Хабре – ”Пошаговое руководство к исполняемым файлам (EXE) Windows” .
Важно понимать вот что:
Все DLL библиотеки, включая системные и те что указаны в PE заголовке как импорты, загружаются непосредственно в адресное пространство приложения которое их использует. С логической точки зрения каждое запущенное приложение имеет свой индивидуальный набор копий системных и прочих DLL. Таким образом, самым простым способом перехватить пакеты в браузере будет перехватить вызов определенных функций в системной библиотеке, отвечающих за отправку и прием пакетов. На этом месте некоторые опять прекратят читать, потому что все опять понятно и ничего нового, с чем я полностью согласен. Но для всех остальных продолжу. Перехват можно сделать на разных уровнях: WinHTTP, WinINet, WinSock. Для меня наиболее универсальным представляется перехват функций WinSock из библиотеки WS2_32.DLL. Он имеет свои недостатки, особенно при работе с HTTPS, где пакеты шифруются. Для HTTPS, с моей точки зрения, наилучшим решением будет перехват функций WinHTTP и/или библиотек OpenSSL. Но начнем с простого.
- Определить адрес перехватываемой функции
- Переписать вызов функции в точке входа так чтобы вызывался собственный обработчик
- В собственном обработчике выполнить некие действия до вызова изначальной функции
- Вызвать изначальную функцию
- Сохранить результат
- В собственном обработчике выполнить некие действия после вызова изначальной функции
- Вернуть результат в процедуру вызова
- send()
- WSASend()
- recv()
- WSARecv()
- WSAGetOverlappedResult()
- connect()
- WSAConnect()
- closesocket()
Про то что такое HOOK_CODE_SIZE и от чего он зависит читаем дальше.
Немного ассемблера- Определить свой обработчик. При этом тип вызова cdecl или stdcall, а так же все входные параметры должны быть точно такими же как и в изначальной функции, иначе мы запортачим стек.
- Определить точку входа в интересующую нас функцию С этим все просто, нужно вызвать GetProcAddress() из kernel32.dll.
- Сохранить код из точки входа в функцию Тут тоже все просто – побайтно скопировать в укромное место
- Пропатчить точку входа Грубо говоря все сводится к переписыванию кода в точке входа так чтобы при вызове оной происходил переход на наш обработчик
Есть несколько разных методов перехвата, с функциональной точки зрения. Самый простой метод, это постоянно переписывать код в самом начале точки входа с изначального на свой и обратно (при вызове изначальной функции). Есть более сложные методы при которых не надо постоянно переписывать код, но которые требуют написания анализатора инструкций, дабы правильно внедрить свой перехватчик в середину изначальной функции. Остановимся пока на самом простом – перезапись в начале кода. Опять же есть несколько способов вызвать свой обработчик. Не углубляясь в детали выделю два из них: безусловный переход и возврат по стеку вызова. В первом случае концепция такова:
Это предельно просто и в 32-битном выражении требует 5 байт, один для кода инструкции безусловного перехода JMP и четыре для относительного адреса. Почему относительного, позже. Во втором случае концепция немного другая:
Это требует 6 байт — два байта на коды инструкций PUSH <32-bit DWORD> и RETN и четыре для абсолютного адреса. Да-да. В первом случае адрес считается относительно текущего адреса исполнимого кода. Во втором он постоянен и считается относительно начала адресного пространства. Я пойду первым методом. Пишем установщик перехватчика:
Не думаю что вышеописаный код нуждается в дополнительном пояснении. Стоит заметить, что патченый код мы вначале формируем в структуре, отвечающей за содержание информации о перехватчике функций, а непосредственно патч мы делаем макросами посредством memcpy(). Любители эстетики могут добавить туда lock, но на мой взгляд это лишнее, догадайтесь почему? Чтобы включить ловушку, мы копируем новый код, содержащий только переход на адрес собственного обработчика. Чтобы ловушку выключить мы восстанавливаем 5 изначальных байт, сохраненных в массиве под именем oldCode. Поскольку установку ловушки на функцию мы написали, стоит написать и восстановитель изначального состояния кода функции:
Теперь, когда мы умеем внедряться в процесс, ставить, убирать, включать и выключать ловушки, самое время заняться собственными обработчиками.
Собственные обработчики функций WinSockИтак, для начала определим массив прототипов перехватываемых функций. Как указано выше, попробуем перехватывать только самые необходимые функции:
Как видно из макроса, каждой системной функции с абстрактным именем name сответствует собственный обработчик под именем my_name. Теперь надо определить обработчики указаные в массиве. Сделаем это на примере send():
Так будет выглядеть пустая обертка системной функции, которая ничего не делает кроме того что вызывает изначальный код. Поскольку обертка по сути одна и та же для всех собственных обработчиков, то имеет смысл сделать пару макросов ради удобства определения оных:
и для выхода из функции тоже:
Далее мы используем эти макросы, чтобы определить оставшиеся ловушки из массива.
Установка ловушекПоследняя строчка в нашем OnLoad() вызывает некую магическую функцию InstallHooks(). Поскольку все составляющие решения у нас налицо, напишем пакетную установку всех определенных ловушек:
Вот так лаконично. И пакетное удаление ловушек не менее лаконичное:
Перехват HTTP пакетовНу вот мы плавно подобрались к самому интересному. Итак, у нас два типа пакетов – HTTP запросы и HTTP ответы. Соответственно, первые отправляются функциями типа send(), вторые принимаются функциями типа recv(). Функции отправки надо перехватывать ДО вызова изначального кода, пока буфер отправки еще девственно чист. Функции приема, соответственно надо перехватывать ПОСЛЕ исполнения изначального кода, иначе не увидим что именно принято. Есть еще асинхронные функции. Идея там простая. При вызове WSASend() или WSARecv() задается структура WSAOVERLAPPED в которой прописывается Event. Асинхронные функции завершаются мгновенно, и по завершению выдают SOCKET_ERROR с GetLastError() установленным в WSA_IO_PENDING. Далее основное приложение ждет события Event любым способом, например WaitForSingleObject(), и как только статус события установлен, то приложение дочитывает буфер через WSAGetOverlappedResult(). Если снять данные с синхронных функций не составляет труда, то с асинхронными придется немного повозиться. Вначале поста я упоминал что полностью от контекстов избавиться не получится, а асинхронные операции это именно то почему. Более детально. Вызов WSAGetOverlappedResult() не несет в себе никакой информации о буфере отправки или приема. Поэтому очевидно что нужно создавать контекст и хранить указатель на буфер там. Есть еще одна причина по которой нужен контекст. Поскольку для нашей задачи, перехвата поточного видео, требуются и HTTP запросы и ответы, то наиболее логично решение которое будет собирать разрозненные вызовы send(), recv() в пары. Итак, заведем структуру для контекста, который будет пригоден и для сбора пар HTTP запросов и ответов, и для работы с асинхронными функциями:
Для чего нужно все вышеозначенное? По номеру сокета socket мы будем определять соответствие запроса и ответа. То есть основное приложение отправляет и получает запрос по одному и тому же TCP сокету, иначе быть не может. Указатель request будет ссылаться на HTTP запрос. Указатель LPWSABUF будет использоваться в случае асинхронных функций. То бишь при вызове WSASend()/WSARecv() мы указатель на буфер будем сохранять, а при завершении его WSAGetOverlappedResult() мы его оттуда будем вынимать. Опять же соответствие определяется через номер сокета. Забегая вперед скажу что для WSASend() асинхронные вызовы не используются ни в одном из браузеров который я успел протестировать за время написания данного поста и болванки перехватчика. Для чего нужен next? Для организации односвязного списка. Логично, что контексты для пар запрос-ответ надо куда то помещать, чтобы они не потерялись. Чтобы не раздувать размер программы и не использовать что-то типа шаблонов STL, мне было проще всего решить задачку для школьной олимпиады и написать реализацию односвязного списка. Как будет лучше вам, смотрите сами. Не вдаваясь в продробности опишем функции для работы со связным списком контекстов запрос-ответ (детали смотрите в исходниках):
Далее мы пишем общий обработчик для всех функций send():
И общий обработчик для всех recv():
Телемаркет. Поясню для тех кто еще догоняет. При отправке пакетов проверяется их содержание на предмет наличия ’GET’ в начале. Если это HTTP GET то HTTP заголовок вырезается и сохраняется в контексте открытого сокета. Всякий прием пакетов соответственно проверяет их содержание на предмет наличия ’HTTP’ и если это ответ, то мы пытаемся найти ранее отправленный GET из контекста сокета. Если запрос и ответ найдены то можно их анализировать далее. В данном примере мы просто их сбрасываем в лог файл по адресу %TEMP%\<Имя процесса.exe>-<Идентификатор процесса>.log Осталось разобраться с асинхронными функциями. Итак, на примере WSARecv():
То есть если вызов асинхронный, то мы находим контекст сокета и прописываем туда указатель на буфер. Потом мы его извлекаем и передаем все в общий обработчик всех функций типа read():
В заключение эпопеи мы пропишем обработчик closesocket() дабы прибивать ненужные контексты, если допустим сокет сдох до получения ответа. Полируем, компилируем, запускаем, запускаем браузер, идем на YouTube…
И вот какой интересный пакет мы выловили из Google Chrome на попытку посмотреть видео по пресловутому адресу www.youtube.com/watch?v=o78nFVB1tJA (выдернуто напрямую из лога):
Ну и собственно видеопоток там начинается сразу после последней строки HTTP заголовка.
Кто-то после этого еще сомневается, можно ли написать перехватчик потокового видео без драйверов или нет? Лично я думаю что задача принципиально выполнена, поэтому переходим к выводам.
Выводы- Передавать URL видео из перехватчика в основное приложение и тянуть его уже оттуда.
- Передавать само видео из перехватчика в процессе просмотра. Траффик будет дублироваться через IPC, но это не страшно, так как затраты на прокачку локального траффика не такие существенные.
- Писать поток сразу на диск из перехватчика и информировать основное приложение только о ходе процесса.
- Постоянное переписывание кода в месте ловушки. Как я написал выше, есть методы позволяющие не переписывать код при вызове изначальной функции. Однако такие методы требуют уже более детального анализа кода, определения размера инструкций и в ряде случаев дизассемблирования. Если это кому-то реально интересно, то я могу попробовать написать об этом отдельный пост .
- Невозможность перехвата HTTPS Собственно говоря, я также не вижу возможности перехватывать HTTPS и в случае с NDIS Miniport драйвером. Тем не менее данная методика позволяет это сделать на уровне другой библиотеки типа WinHTTP или OpenSSL.
- Данная методика может использоваться не только для перехвата потоков или реализации сниффероподобных приложений. Например при желании можно написать фильтры для HTTP траффика, обрезку рекламы и так далее. Причем принципиальной разницы какой браузер нет ибо все работает именно на уровне TCP/IP.
- Данная методика работает не только для WinSock. То есть в принципе с незначительными изменениями можно подклеивать перехватчик куда угодно и перехватывать какие угодно функции. Это дает некий простор для действий и полета мысли.
UPD: Написана вторая часть про ловушки без постоянного переписывания кода.