Одной из важнейших частей любой системы защиты корпоративных данных от утечек является модуль анализа исходящего сетевого трафика. Чаше всего модуль реализуется в виде сервиса прозрачного проксирования, т.е. сервиса, который «прозрачно» встаёт между сетевым приложением и целевым сервером, и задачей которого является перехват потока данных между приложением и сервером.
Cтатья посвящена сервису прозрачного проксирования и способам реализации проксирования трафика. В ней не будут рассматриваться вопросы перенаправления сетевого трафика на сервис прозрачного проксирования, хотя это тоже достаточно интересная техническая задача.
Так как целевые приложения могут работать по любым, в том числе и нестандартным, портам, то обрабатывать нужно весь трафик. Количество соединений, которые создаются во время работы «высокоэффективных» сетевых приложений, превышает 100 в секунду. В связи с этим сервис прозрачного проксирования должен быть максимально эффективным. Общий алгоритм действий сервиса следующий:
Какие же API в операционной система Microsoft Windows могут помочь решить эту проблему?
Для организации проксирования с помощью этого API нужно сделать следующее:
1. Создать сокет
2. Создать событие, на котором будет происходить отслеживание изменения состояния сокета
3. Связать сокет с событием, указав при этом, какие изменения состояния сокета нас интересуют. При передаче трафика нас интересует завершение передачи данных и завершение приёма данных. Кроме этого интересен момент закрытия соединения, так как это является признаком того, что пора заканчивать обработку трафика
4. Инициировать чтение данных из сокета
5. Организовать ожидание событий изменения состояния сокета. Чаще всего это делается следующим образом: запускается отдельная нить, а в ней вызывается функция ожидания изменения состояния
Событие, связанное с сокетом, придёт в сигнальное состояние, если данные будут получены или данные будут отправлены, или соединение будет закрыто. Ошибки ввода/вывода также приводят событие в сигнальное состояние.
6. Проверить, как именно изменилось состояние сокета, и осуществить соответствующую обработку
Какие подводные камни ждут нас при использовании данного API:
Программы могут устанавливать десятки соединений одновременно, а сервис прозрачного проксирования должен создавать в два раза больше сокетов, т.е. на одно соединение программы сервис проксирования создаёт два сокета. Используемая функция WSAWaitForMultipleEvents имеет ограничение – она не может принять больше 64 объектов за раз. Поэтому нужно запускать несколько нитей ожидания и каким-то образом распределять сокеты между ними.
Длительная обработка данных в одной из нитей ожидания может привести к тому, что события от других сокетов, которые ожидаются в этой нити, не будут обработаны. Для решения этой проблемы нужно запускать отдельные нити обработки данных и следить за их загрузкой.
Получение данных из сокета требует вызова трёх функций: recv, WSAWaitForMultipleEvents и WSAEnumNetworkEvents. Каждая из этих функций потенциально «переходит в режим ядра», что является достаточно затратной операцией.
Если пул нитей ожидания событий сокетов и обработки данных реализован неэффективно, то увеличение количества вычислительных ресурсов (ядер процессора) не приведёт к увеличению скорости проксирования соединений, а для терминальных серверов такая возможность очень важна.
Таким образом, данный API не очень подходит для реализации эффективного способа прозрачного проксирования. Рассмотрим другой набор API.
1. Создаём сокет. Но теперь для выполнения асинхронных операций нам понадобится некоторая контекстная структура, которая описывает асинхронную операцию. Особенностью данной структуры является то, что её первым элементом стоит стандартный тип данных OVERLAPPED. Данный порядок позволит реализовать корректную работу функций обратного вызова.
2. Связываем сокет с портом завершения ввода/выводы, события от которого обрабатываются внутри системного пула потоков. Так как для инициирования асинхронной операции мы будем использовать указатель на OVERLAPPED структуру, никто не мешает нам выделить вместе с этой структурой больше памяти под наши нужды. И адрес именно этой структуры мы получим в обратном вызове порта завершения ввода/ вывода.
3. Инициируем асинхронную операцию чтения из сокета. При этом нужно помнить, что если операция завершилось немедленно, т.е. либо без ошибки, либо с ошибкой, отличной от ERROR_IO_PENDING, то завершать обработку нужно в нитке, которая инициировала чтение. Функция обратного вызова порта завершения ввода/вывода в таком случае вызвана не будет. Контекст асинхронной операции стоит хранить в структуре, которая описывает перехватываемое соединение, так как время жизни данной структуры совпадает со временем жизни контекста соединения. Более того, данная структура может быть переиспользована для операций чтения из сокета.
Реализация ReceiveDoneCallback аналогична синхронному случаю.
4. Обрабатываем полученные данные. Так как мы уже используем системный пул нитей для обработки ввода/вывода, то и для обработки данных нужно использовать системный пул нитей. При этом нужно помнить: данные должны обрабатываться и передаваться нашему парному сокету в той же последовательности, в которой они были получены. Следовательно, должна быть организована очередь обрабатываемых и передаваемых данных. Функция системного пула должна работать именно с очередью. При этом важно, чтобы очередь обрабатывала только одна нить пула. Организовывать очередь можно произвольным образом.
Доступ к очереди обрабатываемых элементов, а также доступ к информации о статусе обработки должны быть синхронизированы. Асинхронная передача данных нашей «паре» организуется аналогичным образом, но вместо ReadFile используется функция WriteFile.
Что мы получили, когда стали использовать данный набор API:
Данный набор API позволяет увеличивать количество обрабатываемых соединений путём увеличения количества ядер процесса, т.е. данная схема будет работать на терминальном сервере.
Но у данного API всё же есть недостатки:
Эти проблемы можно решить, используя другой набор API.
Данный набор функций позволяет и создавать отдельные пулы нитей, и конфигурировать каждый их них. Рассмотрим шаги, которые нужно предпринять для организации проксирования сетевых соединений с использованием данного API.
1. Создаём и конфигурируем окружение, в котором будет работать пул нитей. Это окружение позволяет корректно ожидать завершения всех заданий, которые были переданы заданному пулу
2. Создаём и конфигурируем пул нитей
Теперь у нас есть выделенный пул нитей, в котором не может быть меньше двух и более десяти нитей. Кроме того, мы можем использовать переменную io_pool_cleanup для ожидания завершения всех операций, которые были инициированы в данном пуле. Аналогичным образом можно сконфигурировать пул нитей для обработки перехваченных данных (processing_pool).
3. Создаём сокет и структуры, которые необходимы для инициирования асинхронных операций
Реализация функций IoDoneCallback(ReceiveDoneCallback) и WorkRoutine аналогична реализациям, которые приведены для предыдущего набора API. Т.е. можно переиспользовать уже существующую бизнес-логику обработки перехваченных данных.
4. Инициируем асинхронную операцию чтения данных из сокета
Обработка результатов операции аналогична тому, как описано для варианта с портом завершения ввода/вывода, но с одной особенностью. Если мы не хотим получать обратный вызов в пуле для случая синхронного завершения операции (а он будет выполнен «по умолчанию»), нужно специальным образом пометить сокет после его создания:
Кроме этого важно помнить, что каждая инициированная операция ввода/вывода должна быть либо завершена, либо отменена, т.е. если операция завершилась синхронно, с ошибкой или без – нужно вызвать:
5. Инициируем обработку полученных данных. Функция обработки аналогична варианту с QueueUserWorkItem
Описанный набор API всем хорош, но существует он только в версиях операционной системы, начиная с Windows Vista. Для Windows XP и Windows Server 2003 нужно использовать порты завершения ввода/вывода и старый системный пул. Тем не менее, интерфейс обоих вариантов позволяет обрабатывать перехваченные данные одинаковым образом, поэтому кодовая база получается одна, хотя и собирается под разные операционные системы.
Любой качественный программный продукт должен использовать максимально эффективные способы решения технических проблем из тех, что предоставляет операционная система. Сервис прозрачного проксирования нашего продукта прошёл долгий путь развития, и на данный момент он реализован, как мне кажется, максимально эффективно. Надеюсь, выводы из пройденного нами пути помогут другим быстрее разобраться в технологиях и принять правильное решение.
Cтатья посвящена сервису прозрачного проксирования и способам реализации проксирования трафика. В ней не будут рассматриваться вопросы перенаправления сетевого трафика на сервис прозрачного проксирования, хотя это тоже достаточно интересная техническая задача.
Так как целевые приложения могут работать по любым, в том числе и нестандартным, портам, то обрабатывать нужно весь трафик. Количество соединений, которые создаются во время работы «высокоэффективных» сетевых приложений, превышает 100 в секунду. В связи с этим сервис прозрачного проксирования должен быть максимально эффективным. Общий алгоритм действий сервиса следующий:
- Принять перенаправленное соединение.
- Получить информацию о том, куда нужно установить «проксированное» соединение.
- Создать соединение с сервером (из пункта 2).
- Получить данные от приложения и передать их серверу.
- Получить данные от сервера и передать их приложению.
- Повторять пункты 4 и 5 до тех пор, пока либо сервер, либо приложение не закроют соединение.
- Закрыть «парное» соединение.
Какие же API в операционной система Microsoft Windows могут помочь решить эту проблему?
Сокеты + WSA events
Для организации проксирования с помощью этого API нужно сделать следующее:
1. Создать сокет
SOCKET socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
2. Создать событие, на котором будет происходить отслеживание изменения состояния сокета
WSAEVENT sock_event = WSACreateEvent();
3. Связать сокет с событием, указав при этом, какие изменения состояния сокета нас интересуют. При передаче трафика нас интересует завершение передачи данных и завершение приёма данных. Кроме этого интересен момент закрытия соединения, так как это является признаком того, что пора заканчивать обработку трафика
WSAEventSelect(socket, sock_event, FD_READ|FD_WRITE|FD_CLOSE);
4. Инициировать чтение данных из сокета
int res = recv(socket, buf, buf_len, 0);
5. Организовать ожидание событий изменения состояния сокета. Чаще всего это делается следующим образом: запускается отдельная нить, а в ней вызывается функция ожидания изменения состояния
int res = WSAWaitForMultipleEvents(1, sock_event, FALSE, INFINITE, FALSE);
Событие, связанное с сокетом, придёт в сигнальное состояние, если данные будут получены или данные будут отправлены, или соединение будет закрыто. Ошибки ввода/вывода также приводят событие в сигнальное состояние.
6. Проверить, как именно изменилось состояние сокета, и осуществить соответствующую обработку
WSANETWORKEVENTS wsaNetworkEvents;
WSAEnumNetworkEvents(socket, sock_event, &wsaNetworkEvents);
if( ( wsaNetworkEvents.lNetworkEvents & FD_READ) ) {
//Данные получены, можно их обработать и передать нашей «паре»
ProcessReceivedData();
}
if( ( wsaNetworkEvents.lNetworkEvents & FD_WRITE) ) {
//Данные переданы, можно запрашивать следующую порцию у «пары»
IssuerRead();
}
if( ( wsaNetworkEvents.lNetworkEvents & FD_CLOSE) ) {
//Соединение закрылось,
//закрываем нашу пару (но только после того, как все данные будут переданы)
ClosePeer();
}
Плюсы и минусы
Какие подводные камни ждут нас при использовании данного API:
Программы могут устанавливать десятки соединений одновременно, а сервис прозрачного проксирования должен создавать в два раза больше сокетов, т.е. на одно соединение программы сервис проксирования создаёт два сокета. Используемая функция WSAWaitForMultipleEvents имеет ограничение – она не может принять больше 64 объектов за раз. Поэтому нужно запускать несколько нитей ожидания и каким-то образом распределять сокеты между ними.
Длительная обработка данных в одной из нитей ожидания может привести к тому, что события от других сокетов, которые ожидаются в этой нити, не будут обработаны. Для решения этой проблемы нужно запускать отдельные нити обработки данных и следить за их загрузкой.
Получение данных из сокета требует вызова трёх функций: recv, WSAWaitForMultipleEvents и WSAEnumNetworkEvents. Каждая из этих функций потенциально «переходит в режим ядра», что является достаточно затратной операцией.
Если пул нитей ожидания событий сокетов и обработки данных реализован неэффективно, то увеличение количества вычислительных ресурсов (ядер процессора) не приведёт к увеличению скорости проксирования соединений, а для терминальных серверов такая возможность очень важна.
Таким образом, данный API не очень подходит для реализации эффективного способа прозрачного проксирования. Рассмотрим другой набор API.
Overlapped I/O + Thread Pool + Completion Ports
1. Создаём сокет. Но теперь для выполнения асинхронных операций нам понадобится некоторая контекстная структура, которая описывает асинхронную операцию. Особенностью данной структуры является то, что её первым элементом стоит стандартный тип данных OVERLAPPED. Данный порядок позволит реализовать корректную работу функций обратного вызова.
struct AsyncOperationContext
{
//Важно, что бы эта структура была первой
OVERLAPPED ov;
//Функция обратного вызова – по завершении операции
//Определяется пользователем)
CALLBACK_FUNC pfFunc;
//Произвольный контекст операции
PVOID pContex;
}
SOCKET sock =
::WSASocket( AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
2. Связываем сокет с портом завершения ввода/выводы, события от которого обрабатываются внутри системного пула потоков. Так как для инициирования асинхронной операции мы будем использовать указатель на OVERLAPPED структуру, никто не мешает нам выделить вместе с этой структурой больше памяти под наши нужды. И адрес именно этой структуры мы получим в обратном вызове порта завершения ввода/ вывода.
BindIoCompletionCallback(sock, IoSockCompletionRoutine, 0);
VOID CALLBACK IoCompletionRoutine(
DWORD error,
DWORD bytes,
LPOVERLAPPED ov)
{
AsyncOperationContext* actx = reinterpret_cast< AsyncOperationContext*>(ov);
actx->pfFunc(actx->pContext,error,bytes);
}
3. Инициируем асинхронную операцию чтения из сокета. При этом нужно помнить, что если операция завершилось немедленно, т.е. либо без ошибки, либо с ошибкой, отличной от ERROR_IO_PENDING, то завершать обработку нужно в нитке, которая инициировала чтение. Функция обратного вызова порта завершения ввода/вывода в таком случае вызвана не будет. Контекст асинхронной операции стоит хранить в структуре, которая описывает перехватываемое соединение, так как время жизни данной структуры совпадает со временем жизни контекста соединения. Более того, данная структура может быть переиспользована для операций чтения из сокета.
AsyncOperationContext receive_ov;
//Инициализируем системную часть структуры
memset(&receive_ov, 0, sizeof(OVERLAPPED));
//Инициализируем функцию обратного вызова и контекст обратного вызова
receive_ov.pfFunc = ReceiveDoneCallback;
receive_ov.pContext = this;
//Инициируем операцию чтения из сокета
BOOL res = ReadFile((HANDLE)sock, buf, buf_len, &received, (LPOVERLAPPED)&receive_ov);
if(res)
{
//Операция закончилась синхронно.
//Порт завершения ввода/вывода использован не будет
if(received > 0)
{
//Данные получили сразу, обрабатываем их
ProcessReceivedData();
//Инициируем следующую операцию чтения
InitiateRead();
}
else
{
//Ничего не получили. Считаем, что удалённый конец
//соединения закрыл сокет.
ProcessConnectionClose();
}
}
else
{
DWORD error = GetLastError();
if(error != ERROR_IO_PENDING)
{
//Ошибка получения данных. Закрываем соединение
ProcessConnectionClose();
}
}
Реализация ReceiveDoneCallback аналогична синхронному случаю.
4. Обрабатываем полученные данные. Так как мы уже используем системный пул нитей для обработки ввода/вывода, то и для обработки данных нужно использовать системный пул нитей. При этом нужно помнить: данные должны обрабатываться и передаваться нашему парному сокету в той же последовательности, в которой они были получены. Следовательно, должна быть организована очередь обрабатываемых и передаваемых данных. Функция системного пула должна работать именно с очередью. При этом важно, чтобы очередь обрабатывала только одна нить пула. Организовывать очередь можно произвольным образом.
//Добавляем полученные данные к очереди необработанных и неотправленных данных
AddReceivedDataToQueue(buf, buf_len);
//Проверяем, что обработка очереди не запущена и
//меняем состояние, если запуск требуется
If(!IsQueueProcessingAndMark())
{
QueueUserWorkItem(DataProcessingRoutine, this, 0);
}
DWORD WINAPI WorkRoutine(LPVOID param)
{
DataItem* dataItem;
while( dataItem = GetQueueProcessingItem() )
{
ProcessDataItem(dataItem);
//Передаём обработанные данные
InitiateWrite();
}
MarkQueueProcessing(FALSE);
}
Доступ к очереди обрабатываемых элементов, а также доступ к информации о статусе обработки должны быть синхронизированы. Асинхронная передача данных нашей «паре» организуется аналогичным образом, но вместо ReadFile используется функция WriteFile.
Плюсы и минусы
Что мы получили, когда стали использовать данный набор API:
- Нам больше не нужна собственная реализация пула нитей – используется пул нитей, который реализован операционной системой.
- Нет ограничений, которые связаны с количеством обрабатываемых соединений.
- Данные, которые получены на сокете, сразу передаются в функцию обратного вызова. Соответственно, нужно просто инициировать операцию и обработать результат. Дополнительных вызовов к API не требуется.
Данный набор API позволяет увеличивать количество обрабатываемых соединений путём увеличения количества ядер процесса, т.е. данная схема будет работать на терминальном сервере.
Но у данного API всё же есть недостатки:
- API не позволяет управлять пулом, т.е. мы не может ограничить количество нитей в пуле.
- Мы не может «гарантировано» разделить нити, которые занимаются обработкой ввода/вывода, и нити, которые занимаются бизнес-обработкой перехваченных данных.
- Нужно специальным образом организовывать ожидание «зависших» операций ввода/вывода.
Эти проблемы можно решить, используя другой набор API.
Использование Vista Thread Pool API
Данный набор функций позволяет и создавать отдельные пулы нитей, и конфигурировать каждый их них. Рассмотрим шаги, которые нужно предпринять для организации проксирования сетевых соединений с использованием данного API.
1. Создаём и конфигурируем окружение, в котором будет работать пул нитей. Это окружение позволяет корректно ожидать завершения всех заданий, которые были переданы заданному пулу
PTP_CALLBACK_ENVIRON io_pool_env;
InitializeThreadpoolEnvironment(io_pool_env);
PTP_CLEANUP_GROUP io_pool_cleanup = CreateThreadpoolCleanupGroup();
SetThreadpoolCallbackCleanupGroup(io_pool_env,io_pool_cleanup,NULL);
2. Создаём и конфигурируем пул нитей
PTP_POOL io_pool = CreateThreadpool(NULL);
SetThreadpoolThreadMaximum(io_pool,10);
SetThreadpoolMinimum(io_pool,2);
SetThreadpoolCallbackPool(&io_pool_env, io_pool);
Теперь у нас есть выделенный пул нитей, в котором не может быть меньше двух и более десяти нитей. Кроме того, мы можем использовать переменную io_pool_cleanup для ожидания завершения всех операций, которые были инициированы в данном пуле. Аналогичным образом можно сконфигурировать пул нитей для обработки перехваченных данных (processing_pool).
3. Создаём сокет и структуры, которые необходимы для инициирования асинхронных операций
SOCKET sock =
WSASocket( AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
PTP_IO io_item =
CreateThreadpoolIo((HANDLE)sock, IoDoneCallback, this, io_pool);
PTP_WORK process_item =
CreateThreadpoolWork(WorkRoutine,this, processing_env);
Реализация функций IoDoneCallback(ReceiveDoneCallback) и WorkRoutine аналогична реализациям, которые приведены для предыдущего набора API. Т.е. можно переиспользовать уже существующую бизнес-логику обработки перехваченных данных.
4. Инициируем асинхронную операцию чтения данных из сокета
//Указываем, что следующая операция ввода/выводя пойдёт через наш пул
StartThreadpoolIo(io_item)
//Инициируем операцию ввода/вывода.
BOOL res = ReadFile((HANDLE)sock, buf, buf_len, &received, &ov);
Обработка результатов операции аналогична тому, как описано для варианта с портом завершения ввода/вывода, но с одной особенностью. Если мы не хотим получать обратный вызов в пуле для случая синхронного завершения операции (а он будет выполнен «по умолчанию»), нужно специальным образом пометить сокет после его создания:
SetFileCompletionNotificationModes((HANDLE), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)
Кроме этого важно помнить, что каждая инициированная операция ввода/вывода должна быть либо завершена, либо отменена, т.е. если операция завершилась синхронно, с ошибкой или без – нужно вызвать:
CancelThreadpoolIo(io_item);
5. Инициируем обработку полученных данных. Функция обработки аналогична варианту с QueueUserWorkItem
//Добавляем полученные данные к очереди не отправленных данных
AddReceivedDataToQueue(buf, buf_len);
//Проверяем, что обработка очереди не запущена и
//меняем состояние, если запуск требуется
If(!IsQueueProcessingAndMark())
{
SubmitThreadpoolWork(processing_item);
}
Плюсы и минусы
Описанный набор API всем хорош, но существует он только в версиях операционной системы, начиная с Windows Vista. Для Windows XP и Windows Server 2003 нужно использовать порты завершения ввода/вывода и старый системный пул. Тем не менее, интерфейс обоих вариантов позволяет обрабатывать перехваченные данные одинаковым образом, поэтому кодовая база получается одна, хотя и собирается под разные операционные системы.
Выводы
Любой качественный программный продукт должен использовать максимально эффективные способы решения технических проблем из тех, что предоставляет операционная система. Сервис прозрачного проксирования нашего продукта прошёл долгий путь развития, и на данный момент он реализован, как мне кажется, максимально эффективно. Надеюсь, выводы из пройденного нами пути помогут другим быстрее разобраться в технологиях и принять правильное решение.