Эта статья появилась как следствие моего желания к обобщению опыта, полученного при изучении внутреннего строения подсистемы работы с RPC в Windows. В течение множества лет я сначала работал с COM/DCOM, создавал кастомные сервера и клиенты, которые использовали эти технологии. При этом вся работа с COM велась с использованием стандартных средств: MIDL и библиотеки ATL. Потом я начал более глубоко вникать в устройство внутренних механизмов Windows и тут возникла необходимость в использовании RPC на гораздо более низком уровне, который бы позволял как можно более полно использовать все возможности этой технологии. Однако, как оказалось, в сети довольно сложно найти какой-то материал, который бы освещал RPC с необходимых мне точек зрения. Поэтому волей-неволей, но мне пришлось самому изучать то, что нужно мне и с той детализацией, которая мне была нужна. Как итог сейчас, например, я могу строить RPC сервера и клиенты абсолютно без использования MIDL или NDR для передачи совершенно произвольных данных. Могу реализовать клиента, который бы осуществлял десятки одновременных параллельных запросов к одному и тому же RPC серверу. Могу возвращать с RPC сервера расширенную информацию об ошибках, а также получать максимально возможную информацию о клиенте, который произвёл вызов. Кроме того я был вынужден достаточно плотно изучить и стандартную подсистему кодирования NDR и теперь у меня есть собственные расширенные примеры кодирования и декодирования всех основных типов на основе официально не декларируемых функций. И теперь весь этот опыт я постараюсь как можно полно и подробно представить в этой статье. Если у кого-либо из читателей возникнут дополнения/замечания, то буду рад услышать полезную информацию от умных людей.
В статье приведены следующая информация:
Пример с кодированием и декодированием всех основных типов NDR с помощью низкоуровневых функций Windows;
Пример работы с сервисом EPMAPPER с использованием "ручного" кодирования и декодирования NDR с помощью низкоуровневых функций Windows;
Пример реализации RPC сервера, который принимает и обрабатывает неструктурированные произвольные входные данные, во множестве параллельно исполняемых потоков, для различных входных object ID, с использованием всех допустимых транспортных протоколов, включая HTTP;
Пример реализации RPC клиента, позволяющего передавать неструктурированные произвольные данные и использующий все возможности аутентификации в RPC, а также использующий все возможные транспортные протоколы, включая HTTP;
Приведено объяснение концепции "manager EPV" (Endpoint Vector), а также связь этой концепции с использованием object ID и дальнейшей внутренней обработке входящих сообщений на стороне сервера в зависимости от использованного object ID;
Даны отдельные пояснения по поводу практического конфигурирования и применения RPC over HTTP;
RPC Server
Для начала необходимо заметить, что в Windows абсолютно любая программа может реализовать функции как RPC сервера, так и RPC клиента. Получается это из-за того, что DLL с функциями RPC клиента и сервера загружается в любую программу по-умолчанию. И сразу после загрузки этой DLL в текущей программе создаётся глобальная переменная (экземпляр внутреннего класса), отвечающая за функции RPC сервера. В дальнейшем мы просто можем с помощью различных функций только взаимодействовать с этой глобальной переменной и получать (и передавать) от встроенного RPC сервера какую-то информацию.
Первым в моём повествовании будет рассказ о последовательности реализации RPC сервера. Для начала необходимо, чтобы текущий RPC сервер начал "слушать" на нужных нам протоколах. Полный перечень протоколов, для которых реализована поддержка в Windows RPC можно найти здесь. Однако в реальности Windows сильно ограничивает этот список. Обычно ссылаются на перечень протоколов из реестра Windows, находящийся по адресу "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Rpc\ClientProtocols". Но, как показала практика, даже если добавить в реестр нужный тебе протокол Windows всё-равно может отказать в регистрации прослушивания RPC сервером по этому протоколу. В моём случае добавление в реестр "ncadg_ip_udp" привело именно к такому результату: у меня всё ещё нет возможности регистрировать свой RPC сервер для прослушивания по UDP. Да, мой RPC сервер запускался на контроллере домена, и есть отдельные политики, запрещающие использование UDP для RPC серверов. Однако даже с убранными запретами из политик UDP всё-равно остаётся мне недоступным. Кстати, потенциально возможно создать свой собственный протокол, по которому будут передаваться стандартные сообщения RPC. Но сейчас в Windows всё-таки проверяется, что протокол есть во внутреннем списке протоколов, а также что он реализован именно внутри "rpcrt4.dll".
Для получения реального полного списка протоколов (prototype sequences), которые может использовать RPC сервер, можно использовать функцию RpcNetworkInqProtseqs. Пример её использования вы можете увидеть по ссылке.
Итак, вернёмся к нашей первичной задаче: регистрации текущего RPC сервера для "прослушивания" по нужным нам сетевым протоколам. Наиболее простым способом реализовать это является использование функции RpcServerUseAllProtseqs. При использовании этой функции загружается список всех возможных протоколов и инициируется работа RPC сервера по этому протоколу. К недостаткам использования этой функции относится то, что в ней отсутствует возможность задания "конечных точек" для каждого из протоколов. Под "конечными точками" здесь подразумевается либо номер порта, если протокол работает через TCP, либо какое-то буквенное обозначение "конечной точки" (в случае использования named pipes или LRPC). Также у функции RpcServerUseAllProtseqs есть ограничение: она предотвращает автоматическую регистрацию RPC сервера с использованием ncacn_nb_nb, ncacn_nb_tcp, ncacn_nb_ipx, ncadg_mq, ncacn_at_dsp, а также ncacn_http. Так что для того, чтобы ваш RPC сервер всё-таки начал "слушать" по HTTP нужно использовать другую функцию: RpcServerUseProtseqEp. В этой функции уже даже можно указать порт, на котором вы желаете "слушать". Однако я рекомендую передавать в третьем параметре данной функции nullptr, что, в свою очередь, позволит EPMAPPER автоматически выбрать порт, на котором будет "слушать" ваш RPC сервер. Про EPMAPPER я более подробно напишу чуть позже в этой статье, а также предоставляю отдельный пример, показывающий низкоуровневое взаимодействие с данным RPC сервером.
После закрепления за вашим RPC сервером нужных "конечных точек" на нужных протоколах можно далее переходить к менее очевидным вещам. В моём примере далее выполняется внутренняя регистрация object IDs. Так как использование object ID и "manager EPV" является достаточно важным, то я решил вставить информацию по этой теме прямо в обсуждение RPC сервера.
Одним из интересных и слабо освещаемых нюансов использования RPC в Windows является использование "manager EPV". Сокращение EPV здесь расшифровывается как "EndPoint Vector". Использование "manager EPV", в свою очередь, неразрывно связано с такими понятиями как "object ID" и "type ID".
Для начала расскажу про "object ID". Понятие "object ID" присутствует в изначальном стандарте DCE/RPC и описывается в пункте 6.1.1.2 "RPC Objects" этого документа. По сути использование "object ID" позволяет более гибко настраивать поведение сервера RPC. Для более полного понимания предлагаю представить, что у нас есть RPC сервер, который предоставляет какой-то сервис для объектов из файловой системы. При использовании "object ID" возможно каждому объекту из файловой системы присвоить свой "object ID" и в дальнейшем автоматически, используя только этот идентификатор, отслеживать для какого файла приходит тот или иной запрос на RPC сервер. Для ещё большей гибкости "object ID" также связывается с "type ID": множество "object ID" могут принадлежать одному и тому же "type ID". На примере RPC сервера, предоставляющего сервис для файловой системы отдельные "object ID" могут иметь "type ID" соответствующий файлу, а другие - "type ID" соответствующий директориями, третьи - файловым ссылкам и так далее.
Переходя обратно к "manager EPV": данная сущность позволяет установить связь между "type ID" и функциональным поведением RPC сервера. Для дальнейшего понимания первоначально необходимо поэтапно пояснить процесс использования "manager EPV". Для начала в RPC сервере регистрируются связи между "object ID" и "type ID". Делается это при помощи функции RpcObjectSetType. Далее с помощью второго и третьего параметров функции RpcServerRegisterIf во внутренние переменные RPC engine передаётся связка между "type ID" и некой сущностью, определяющей поведение данного RPC сервера при поступлении запроса с "object ID", который соответствует данному "type ID". В официальном примере предлагается, что эта "сущность" будет иметь тип массива указателей на функции, количество которых соответствует количеству функций из интерфейса данного RPC сервера. Работать этот "массив ссылок на функции" начинает, когда на вход RPC сервера приходит запрос с "object ID", который соответствует "type ID", для которого с помощью RpcServerRegisterIf был зарегистрирован "manager EPV". При поступлении подобного запроса внутренние механизмы реализации RPC определяют, к какому "type ID" относится "object ID" из поступившего запроса, а также определяют, есть ли во внутренних структурах информация о "manager EPV", зарегистрированном для данного "type ID". В случае если информация о "manager EPV" найдена, то ссылка на "manager EPV" передаётся в RPC_MESSAGE->ManagerEpv. И уже далее разработчик данного RPC сервера может реализовать логику использования "manager EPV", в которой для каждого отдельного "type ID" может быть вызвана собственная, независимая функция. Но так как использовать или нет данную функциональность отдано на "волю разработчика", то использование "manager EPV" представляется мне очень интересным и гибким механизмом, который, по сути, добавляет возможность очень гибкой настройки логики RPC сервера. Кроме того, хотелось бы сказать, что реализация RPC в Windows позволяет динамически добавлять как новые "object ID", так и новые "type ID". Формально можно даже придумать вариант, когда "массив ссылок на функции" определяется и устанавливается динамически на основании каких-то внешних поступивших на вход RPC сервера данных. Ещё раз обращу внимание, что использование "object ID" прописано в изначальном стандарте DCE/RPC и реализуется с помощью опционального поля в заголовке RPC сообщения. Специфики Microsoft тут нет. Однако сам механизм использования "object ID" был адоптирован Microsoft для использования в так называемом ORPC - Object Remote Procedure Call. В свою очередь ORPC используется в технологии DCOM.
В своём коде я постарался продемонстрировать, что можно использовать "manager EPV" нестандартно: в отличие от официального примера я использовал ссылку на "manager EPV" для сохранения обычных целых чисел. Дело в том, что на самом деле значение "manager EPV" имеет тип "ссылка на void", что позволяет сохранить в этом значение всё что угодно, лишь бы значение умещалось в размер "sizeof(void*)". Таким образом, при поступлении на вход моего RPC сервера запроса, содержащего известный "object ID", мне в серверную функцию приходит значение RPC_MESSAGE->ManagerEpv которое я в дальнейшем могу просто сравнить с заранее известными целыми числами и далее могу принять решение о выполнении того или иного действия внутри моего кода. То есть внутренней подсистеме RPC совершенно нет дела до того, что и в каком виде хранится в той ссылке на "manager EPV", которую в эту внутреннюю подсистему передаёт разработчик. Вся реализация работы с "manager EPV" полностью определяется самим разработчиком, внутренняя подсистема RPC лишь делает работу по автоматической подстановке "manager EPV" для каждого известного "object ID".
Ещё одна интересная особенность использования "object ID" проявляется при работе с EPMAPPER. Предположим, что RPC сервер зарегистрировал один интерфейс, который работает по трём различным транспортным протоколам (например LRPC, TCP/IP и HTTP). Пусть в этом интерфейсе зарегистрированы 4 "object ID". При регистрации подобного сервера внутри EPMAPPER с помощью функции RpcEpRegister мы получим 12 различных записей внутри базы данных EPMAPPER. Дело в том, что в таблице базы данных EPMAPPER есть отдельное поле "object ID" и если мы регистрируем три транспортных протокола с 4-мя "object ID", то каждый "object ID" добавляется в базу данных EPMAPPER для каждого транспортного протокола. Кстати, RPC сервер может опустить использование "manager EPV" и использовать значения по-умолчанию при регистрации сервера. В этом случае внутри EPMAPPER "object ID" будет иметь нулевое значение. По факту это означает, что к данному RPC серверу можно обратиться с любым "object ID". То есть даже если RPC сервер явно опустил регистрацию используемых им "object ID" всё-равно внутренняя подсистема RPC позволит осуществлять вызовы к его функциям с использованием любого "object ID".
Замечу ещё, если при регистрации RPC сервера используется динамическое выделение портов (внутренняя подсистема RPC сама определяет на каком порту будет слушать RPC сервер), то на клиенте возможно автоматическое обращение к EPMAPPER на целевой машине и получение с неё нужного номера порта. При этом EPMAPPER по-умолчанию использует нулевой "object ID". Если при регистрации RPC сервера нулевой "object ID" был опущен, то запрос к EPMAPPER со стороны клиента завершится с ошибкой.
Возвратимся к основному процессу создания RPC сервера. Далее можно перейти к регистрации непосредственно интерфейсов. То есть сначала мы регистрируем транспортные протоколы и "конечные точки", на которых будет "слушать" RPC сервер, а затем мы выполняем более "тонкую настройку": ограничиваем входящие (и исходящие) сообщения только теми, в которых будет содержаться нужный нам идентификатор интерфейса и object ID. Выполняется этот процесс с помощью вариантов функции RpcServerRegisterIf. В моём примере я использую RpcServerRegisterIf3, так как на текущий момент она предоставляет наибольшие возможности. На самом деле RpcServerRegisterIf3 отличается от RpcServerRegisterIf2 только возможностью использования security descriptor. Однако на деле security descriptor, устанавливаемый с помощью RpcServerRegisterIf3, практически бесполезен. Более подробно по этой теме я чуть позже в этой статье.
На самом деле наибольший интерес представляют отдельные параметры функции RpcServerRegisterIf3. Если с первым параметром всё более-менее понятно (это ссылка на структуру-описатель для интерфейса), то второй и третий параметр в официальной документации описаны достаточно слабо и, на мой взгляд, это описание крайне непонятно. Однако, надеюсь из моего более раннего объяснения, касающегося использования "manager EPV", теперь стало немного более понятнее.
Далее в RpcServerRegisterIf3 идёт также интересный параметр "Flags", который является просто жизненно важным для любого RPC сервера. Более подробно прочитать про некоторые важные флаги можно по этой ссылке. Для моего примеры были важны только два флага: RPC_IF_ALLOW_CALLBACKS_WITH_NO_AUTH и RPC_IF_OLE. Первый флаг позволяет использовать security callback function при работе с RPC сервером, а также позволяет вызывать её перед любым новым входящим "диалогом" с RPC клиентом. В этой функции у себя в примере я получаю все данные по клиенту, а также информацию по пользователю, от имени которого RPC клиент осуществляет соединение. Второй же флаг оказался интереснее. В официальной документации написано "RPC_IF_OLE это флаг для внутреннего использования, не используйте его в своих программах". Но на самом деле его очень даже можно и в некоторых случаях нужно использовать. Дело в том, что флаг RPC_IF_OLE позволяет направлять входящие вызовы к любой функции внутрь одной единственной функции. Тут необходимо пояснить. Дело в том, что в RPC есть отдельно понятие интерфейса (набора предоставляемых функции), и есть отдельно понятие функции, предоставляемой данным интерфейсом. Когда RPC клиент хочет передать какие-то данные к какой-то отдельной функции из нужного ему интерфейса, то он просто передаёт порядковый номер этой самой функции. То есть в RPC совершенно неважно, как именно называется та или иная функция в RPC интерфейсе: главное на каком порядковом месте она стоит. То есть если в IDL для данного интерфейса "Func1" стоит на первом месте, то для её вызова RPC клиент формирует пакет с некими входными параметрами для данной функции, а затем устанавливает порядковый номер этой функции. Для первой функции порядковый номер будет 0 и так далее. Кстати, как оказалось, максимальным порядковым номером функции в Windows будет 32767 (0x7FFF). Получается это вследствие автоматического приведения 4-х байтового целого к двум младшим байтам. То есть даже если вы пошлёте в качестве порядкового номера функции 0xFFFF0000 то в итоге запрос уйдёт к нулевой функции. Ну а значение 32767 получается из-за того, что старший бит в этих младших 16-ти битах используется как флаг "корректного номера функции" (RPC_FLAGS_VALID_BIT). И, наконец, когда мы внутри RPC сервера используем флаг RPC_IF_OLE то вне зависимости от номера функции, указанного во входящем RPC сообщении, вызов будет передан на функцию с номером 0. Используя эту возможность можно создавать RPC сервера с поддержкой практически произвольного интерфейса. То есть в зависимости от тех или иных внешних данных RPC сервер может имитировать тот или иной интерфейс. Этот функционал получается возможным, в частности, и из-за того, что на низком уровне (который я и использую в своих примерах) у функции интерфейса фактически нет входных параметров: у неё есть только массив байт, который эта функция вольна интерпретировать как ей хочется. Более подробно о "низкоуровневых функциях интерфейса" я расскажу позже в этой статье.
В следующих параметрах функции RpcServerRegisterIf3 устанавливается максимальное количество вызовов, которое одновременно может обрабатывать данный RPC сервер, а также максимальную длину буфера входящего RPC сообщения. Надо отметить, что любой RPC сервер может одновременно обрабатывать множество разных сообщений в разных, независимых потоках, работающих абсолютно асинхронно. Кроме того, RPC сервер в Windows может одновременно работать с несколькими интерфейсами. В своём примере я постарался показать все эти возможности, работая в потоках с различной задержкой, а также выполняя обработку сообщений от нескольких интерфейсов одновременно.
Далее идут два параметра, которые призваны отвечать за часть работы подсистемы безопасности RPC. За безопасность при использовании RPC отвечают три составляющие: использование аутентификации на уровне транспортного протокола, использование аутентификации на уровне RPC, использование аутентификации на "RPC proxy" (при использовании HTTP), использование "security callback function", а также использование security descriptors при регистрации RPC сервера.
Начну с использования security descriptors. Сразу скажу, что в моих тестах этот способ ограничения доступа к RPC серверу показал практически никакие результаты. То есть если я делаю запрещающий DACL и устанавливаю его для RPC сервера, то вне зависимости от транспортного протокола, уровня аутентификации или других факторов всё-равно запросы к RPC серверу успешно проходят. То есть использование функции RpcServerRegisterIf3 в моих тестах выглядит бесполезным. Единственное на что повлияло установление security descriptor это на регистрацию RPC сервера в EPMAPPER. То есть если я ставлю запрещающий DACL и потом использую такой security descriptor в функции RpcServerUseProtseqEp или RpcServerUseAllProtseqs то мне возвращается ограниченный набор зарегистрированных транспортных протоколов. Что там за логика с выбором этих транспортных протоколов - мне неизвестно. Также, например, если я использую запрещающий DACL и пытаюсь зарегистрироваться на протоколе HTTP, то мне возвращается ошибка "endpoint is a duplicate" хотя я использовал динамическую регистрацию порта. Так что использование security descriptors при регистрации RPC сервера это, наверное, круто, но с результатами там пока всё очень плохо. В своём исходном коде я оставил возможность для читателя поэкспериментировать с security descriptors, так что рекомендую попробовать - может быть на вашем стенде всё заработает как надо.
Гораздо более эффективным способом ограничения доступа к RPC серверу является использование "security callback function". В этом случае перед непосредственно поступлением входящего вызова на функцию RPC сервера происходит вызов специальной функции, результат возврата из которой позволяет ограничить дальнейшее прохождение вызова. При этом нет никакой разницы какой используется транспортный протокол, использовал или нет клиент аутентификацию, какого уровня эта аутентификация и так далее: в любом случае вызов будет проходить через "security callback function". Внутри функции можно получить любую необходимую информацию для принятия решения о допуске данного вызова: какой IP адрес использовал клиент для вызова, все его группы и привилегии на текущем компьютере (если была использована аутентификация), какой был использован "object ID" и многое другое. Если данная функция возвратит нулевой код то прохождение вызова продолжится, если возвратит код отличный от нулевого - этот код будет возвращён на клиента в качестве статуса ошибки. В данной функции также можно проверять осуществляется ли вызов с применением аутентификационных данных или нет, по какому интерфейсу поступили входные данные и так далее. На основе всей этой информации принимается решение о продолжении "общения" с тем или иным RPC клиентом. В стандартных RPC серверах Windows именно в этой функции, например, проверяется принадлежность аутентификационной информации, полученной от RPC клиента, той или иной группе пользователей (например принадлежность к группе администраторов).
За аутентификацию на уровне транспортного протокола отвечает функция RpcServerRegisterAuthInfo. В этой функции наиболее важны два параметра: ServicePrincipalName и AuthnSvc. Первый параметр является опциональным и имеет смысл только для отдельных видов AuthnSvc, например для RPC_C_AUTHN_GSS_KERBEROS и RPC_C_AUTHN_GSS_NEGOTIATE. Второй параметр является обязательным и идентифицирует аутентификационную схему, которую будет поддерживать RPC сервер. Надо заметить, что вызовов RpcServerRegisterAuthInfo может быть несколько и каждый такой вызов может добавлять новую аутентификационную схему.
После вызова функции RpcServerRegisterIf3 мы получаем готовый к использованию RPC сервер. Однако дело в том, что в сервисе EPMAPPER, отвечающем за обнаружение RPC серверов на локальной машине, будет отсутствовать информация о нашем RPC сервере. Для исправления этой ситуации необходимо выполнить несколько шагов. Во-первых, необходимо создать перечень всех используемых object IDs. Также необходимо получить список всех RPC bindings, которые были зарегистрированы для нашего RPC сервера. Делается это с помощью функции RpcServerInterfaceGroupInqBindings. И вот для конечной регистрации в EPMAPPER вся эта информация, вместе со ссылкой на структуру-описатель RPC интерфейса, должна быть передана в функцию RpcEpRegister. Например, у нас в RPC сервере используются два object IDs и три различных протокола (prototype sequences). После регистрации в EPMAPPER у нас образуются шесть записей: каждый object ID будет скомбинирован с каждый prototype sequences.
Сам по себе EPMAPPER (Endpoint Mapper) является стандартным RPC интерфейсом, описанном в изначальном стандарте DCE/RPC. Сервис EPMAPPER предоставляет интерфейс для поиска локально зарегистрированных RPC серверов. То есть на каждой машине, на которой могут существовать RPC сервера, должен быть запущен RPC сервер, в котором реализуется поддержка интерфейса Endpoint Mapper. В Windows данный интерфейс также реализован, однако с некоторыми нюансами. Например, на самом деле в Windows есть два отдельных интерфейса, реализующих поддержку EPMAPPER: первый является стандартным и имеет идентификатор e1af8308-5d1f-11c9-91a4-08002b14a0fa, а второй является внутренним и имеет идентификатор 0b0a6584-9e0f-11cf-a3cf-00805f68cb1b. В первом интерфейсе реализованы только механизмы поиска, а во втором - механизмы добавления и удаления записей (RpcEpRegister/RpcEpUnregister). Первый интерфейс работает с использованием заранее известных "конечных точек" и протоколов (в том числе и по LRPC), а второй работает только по LRPC (передача RPC сообщений между процессами на одной машине). Таким образом достигается изоляция функций: поиск может осуществляться как через локальное, так и через удалённые соединения, а добавление и удаление - только с помощью контролируемых локальных функций. Механизмы добавления/удаления новых записей, которые изначально заложены в стандарте DCE/RPC в Windows просто удалены - про попытке обращения к этим функциям возвращается ошибка.
Так как реализация RPC сервера, поддерживающего интерфейс EPMAPPER, имеется на всех машинах Windows, то я решил реализовать отдельный пример, в котором бы на как можно более низком уровне использовал обращение к EPMAPPER. В этом примере я реализован буквально "ручное" кодирование и декодирование входных и выходных параметров для функции ept_lookup интерфейса EPMAPPER. Также в этом примере реализован разбор всех значений из tower, которые возвращает эта функция. Кроме этого в примере также существует работа с EPMAPPER с использованием протокола HTTP. Этот пример использования EPMAPPER может быть найден по этой ссылке.
Ещё одним слабоосвещённым моментом является использование расширенной информации об ошибках в RPC. Сама возможность возврата расширенной информации об ошибках по-умолчанию заблокирована и настраивается дополнительно с помощью политик. После включения возможности получения расширенной информации об ошибках программист получает возможность возвращать с RPC сервера буквально произвольные данные, в том числе и бинарные. Например, в случае ошибки при использовании RPC over HTTP стандартный RPC proxy возвращает ошибку, одним из элементов которой является бинарной представление X.509 сертификата, используемого на HTTP сервере. Полный перечень типов, которые могут быть использованы при передаче расширенной информации об ошибке, содержится в структуре RPC_EE_INFO_PARAM. Пример использования всех типов (создание "расширенной ошибки") находится в функции Server_RiseException, пример разбора информации из "расширенной ошибки" находится в функции GetRpcError. Также считаю, что будет немаловажным заметить, что возвращать "расширенную информацию об ошибке" в RPC можно исключительно только после использования RpcRaiseException. И на самом деле только использование RpcRaiseException позволяет возвратить какой-то код ошибки из RPC сервера. То есть если RPC сервер завершился без генерации исключения, то подсистема RPC считает, что код завершился полностью удачно. Если же было сгенерировано какое-либо исключение, то RPC клиенту возвращается тот или иной код ошибки. И только в случае возврата кода ошибки RPC сервер имеет возможность также добавить к этому коду ошибки какую-либо "расширенную информацию".
Также в разделе про RPC сервер мне бы хотелось рассказать какую информацию о входящем сообщении можно получить на стороне сервера. Первым делом это, конечно, информация, связанная с аутентификацией клиента. Однако такая информация может быть получена исключительно только когда сам RPC клиент инициировал использование явной аутентификации. Получение аутентификационной информации начинается с вызова функции RpcImpersonateClient. После этого локальный поток на RPC сервере инициализируется с security token, соответствующем клиенту, приславшему тот или иной входящий запрос. И уже основываясь на информации из этого токена можно получить нужные части: информацию по SID пользователя, информацию по SID для групп, в которые входит данный пользователь, информацию по привилегиям, которые назначены пользователю на машине RPC сервера и так далее.
Кроме аутентификационной информации RPC сервер также может получить данные по клиентской машине, с которой был выполнен запрос. Делается это с помощью функции I_RpcServerInqLocalConnAddress. В результате функция возвратит полную информацию по IP адресу клиентской машины. Однако дело в том, что существуют prototype sequences, в которых отсутствует возможность получения IP адреса клиентской машины. Например такая информация невозможна к получению для ncacn_np (named pipes).
Дополнительную информацию о входящем соединении можно также получить из client binding. Дело в том, что на стороне RPC сервера можно также получить практически полную информацию из binding, которую использовали на RPC клиенте. Для получения нужной информации необходимо первично вызвать RpcBindingServerFromClient, затем RpcBindingToStringBinding и затем RpcStringBindingParse. В результате может быть получена информация по использованному object ID, prototype sequence, использованный сетевой адрес машины RPC сервера, информация по endpoint RPC сервера (порт или имя pipe). Кроме использования RpcStringBindingParse для получения информации по использованному object ID можно также использовать функцию RpcBindingInqObject. Для получения информации по локальному "manager EPV" можно использовать функцию RpcObjectInqType.
RPC Client
Несмотря на то, что основную функциональность реализуют внутри RPC сервера работа RPC клиента тоже имеет немаловажные нюансы. Прежде всего рассмотрим понятие, которое в англоязычном варианте звучит как binding. Переводить этот термин - только портить, так что в своей статье я оставлю его как есть. Итак, binding, прежде всего, служит для установления ряда параметров, которые позволяют корректно определить конечную "endpoint", на которой "слушает" нужный нам RPC сервер. В зависимости от "полноты" параметров, которые установлены в binding различают "partial resolved binding" и "full resolved binding". В случае "partial resolved binding" необходимо использовать EPMAPPER для получения необходимой дополнительной информации. Обычно под "partial resolved binding" понимается binding, в котором отсутствует указание на "endpoint" (порт или имя named pipe) на котором "слушает" RPC сервер. Получить "full resolved binding" из "partial resolved binding" можно двумя способами: явно используя вызов функции RpcEpResolveBinding или же положится на внутренние механизмы реализации RPC в Windows. Во втором случае EPMAPPER также вызывается, но уже неявно, просто как часть выполнения общего запроса к RPC серверу. Так что на самом деле гораздо проще использовать возможности динамической аллокации "endpoints" при регистрации RPC сервера: всё-равно RPC клиент сможет автоматически найти нужные "endpoint" и корректно выполнить запрос.
Также здесь необходимо немного боле подробно рассказать про установку информации об object ID в binding. Дело в том, что это можно сделать тремя разными способами. Первым (самым простым) является прямое задание object ID в первом параметре функции RpcStringBindingCompose. В этом случае этот object ID будет использоваться как внутри "автоматического", так и внутри явного вызова RpcEpResolveBinding. Другим способом установки object ID является дополнительный вызов RpcBindingSetObject, который позволяет отдельно задать object ID внутри binding. И в этом случае также заданный object ID будет использован как внутри "автоматического", так и внутри явного вызова RpcEpResolveBinding. Третьим способом задания object ID является использование недокументированной функции I_RpcGetBufferWithObject. В случае использования этой функции object ID устанавливается только внутри объекта, отвечающего за непосредственно выполнение запроса к RPC серверу и поэтому установленный таким образом object ID будет "отсутстовать" внутри "автоматического" вызова RpcEpResolveBinding. То есть когда мы запустим автоматические механизмы реализации RPC клиента с помощью, например, функции I_RpcSend, то запрос к EPMAPPER будет произведён с использованием "нулевого object ID" (object ID где все элементы являются нулями), а вот непосредственно запрос к RPC серверу уже будет выполнен с использованием того object ID, который мы передали в функции I_RpcGetBufferWithObject. Так что использование I_RpcGetBufferWithObject добавляет неудобств, но я бы хотел, чтобы читатели моей статьи знали об этой особенности. Кстати, обращу внимание, что "нулевой object ID" позволяет RPC серверу на самом деле принимать входящие запросы с любым object ID, а не только с "нулевым". То есть RPC сервер может зарегистрировать в EPMAPPER "нулевой object ID" для того, чтобы иметь возможность принимать любые object ID и в зависимости от какой-то внутренней логики принимать решения по дальнейшей обработке того или иного вызова.
Следующим важным для RPC клиента моментом является установка данных, необходимых для успешной аутентификации на стороне RPC сервера. Выполняется это с помощью функций RpcBindingSetAuthInfo/RpcBindingSetAuthInfoEx. Всё достаточно стандартно: задаётся authentication service с помощью которого будет выполняться аутентификация (Kerberos, SPNERO, NTLM и так далее), задаются параметры, необходимые для успешной аутентификации и всё это связывается с ранее созданным binding. Ничего особо нового и интересного. В качестве нюанса хочу только упомянуть, что в представленных примерах RPC клиента я использовал редко используемую возможность ограничения использованых нижележащих протоколов при использовании SPNEGO (RPC_C_AUTHN_GSS_NEGOTIATE). Как известно, SPNEGO на самом деле является вспомогательным протоколом, который позволяет произвести автоматическое согласование для использования "нижележащих" протоколов, таких как Kerberos и NTLM. То есть на начальном этапе клиент посылает серверу запрос, в котором указывает, что клиент хочет использовать SPNEGO, который, в свою очередь, может использовать либо Kerberos, либо NTLM. После этого сервер производит свой выбор (обычно в пользу Kerberos) и далее выполняется обычный обмен билетами Kerberos. Но на самом деле в Windows реализован механизм, позволяющий "убрать" тот или иной "нижележащий" протокол из SPNEGO. То есть существует возможность явного указания, что, например, клиенту нужно использовать SPNEGO, но при этом ограничится только Kerberos (или только NTLM). Достигается это с использованием параметра PackageList структуры SEC_WINNT_AUTH_IDENTITY_EX. Другой особенностью использования RpcBindingSetAuthInfoEx является возможность задания "security QoS (Quality of Service)". Фактически в этом параметре указываются тонкие настройки для всей подсистемы аутентификации, и, кроме того, существует возможность задать аутентификационные данные для RPC proxy при использовании RPC over HTTP.
Далее перейдём к собственно передаче сообщений RPC. В случае применения стандартных функций (таких как NdrClientCall2) программист просто передаёт произвольное количество параметров, далее внутри RPC engine происходит кодирование в NDR и прочая "магия". На самом же деле в реализации RPC от Microsoft необязательно применение NDR: передаётся просто буфер данных произвольной длины. Что внутри этого буфера - безразлично. Однако, поскольку в начальном стандарте DCE/RPC указано, что все параметры функций кодируются в NDR, то и все стандартные функции автоматически реализуют этот функционал.
Для реализации пересылки по RPC произвольного буфера данных достаточно просто инициализировать структуру типа RPC_MESSAGE, затем вызвать I_RpcGetBufferWithObject или I_RpcGetBuffer, затем инициализировать полученный буфер теми самыми передаваемыми данными и затем передать эти данные либо с помощью I_RpcSend (в случае асинхронного вызова RPC функций), либо с помощью I_RpcSendReceive (в случае синхронного вызова). Теперь немного более подробно. При инициализации структуры типа RPC_MESSAGE основными полями для нас являются Handle, BufferLength, ProcNum и RpcFlags. Информация об интерфейсе и transfer syntax является стандартной. В поле Handle передаётся ранее сформированный binding, содержащий "контактную информацию" для соединения с RPC сервером. В поле BufferLength передаётся количество байт, которое мы хотим передать в буфере. В поле ProcNum содержится порядковый номер функции, которую мы хотим вызвать в RPC интерфейсе, число начинается с нуля. В поле RpcFlags содержится комбинация недокументированных флагов, которые, однако, задекларированы в Windows SDK (файл rpcdcep.h). В своих примерах я применяю только либо RPCFLG_NON_NDR, либо RPC_BUFFER_ASYNC (при работе с асинхронными вызовами). Функции I_RpcGetBuffer/I_RpcGetBufferWithObject, собственно, выделяют буфер необходимого размера, а также сохраняют переданный object ID для дальнейшего его использования при передаче RPC запросов. Далее необходимо выбрать: использует ли RPC клиент асинхронные или синхронные вызовы. Сразу отмечу: рекомендую всегда использовать именно асинхронные вызовы. Всё дело в том, что на самом деле внутри реализации RPC синхронные вызовы реализуются просто как асинхронные, к которым добавлена функция ожидания (например WaitForSingleObject). Необходимо сказать, что в Windows реализовано несколько вариантов получения результата от асинхронного вызова. Полный список вариантов доступен здесь. То есть подсистема RPC может сделать самые разные действия при получении ответа от RPC сервера: послать сообщение какому-то окну, установить значение event (сгенерировать событие), вызвать напрямую какую-то функцию, послать сообщение через I/O completion port. Интересно также, что при RpcNotificationTypeNone также можно получить состояние асинхронного сообщения: для этого можно использовать вызов функции RpcAsyncGetCallStatus.
Несмотря на то, что RPC на самом деле поддерживает передачу произвольных данных в большинстве случаев всё-таки придётся выполнять кодирование в формате NDR. Обычно кодирование в RPC NDR обходится стороной: NDR является всего лишь форматом кодирования, используемым почти исключительно только внутри RPC. И при использовании MIDL всё кодирование в NDR производится прозрачно для пользователя и без какой-то необходимости уделять внимание этому формату. Однако я решил самостоятельно разобраться можно ли использовать RPC в Windows на более низком уровне и вот для этой задачи мне и понадобилось дополнительное исследование возможностей кодирования в NDR.
Изначально всё кодирование NDR описано всего лишь в одной главе стандарта. И вроде бы там всё понятно и просто для реализации. Однако существует ряд особенностей и нюансов, приводящих к усложнению кода для обработки NDR. Одним из таких нюансов является то, что NDR нужен именно для кодирования вызовов RPC, то есть вызовов удалённых процедур, с возможностью передачи входных параметров, а также возможностью возврата (в том числе и по ссылке) значений из вызванной удалённой процедуры. Также свои ограничения накладывает возможность использования языка описаний IDL, в котором можно задать различные дополнительные ограничения на передаваемые параметры и их зависимости от других параметров и их значений. Кроме того, если рассматривать кодирование NDR в Windows то для этой реализации кодирования также есть ряд особенностей и нюансов, существующих только в Windows. И несмотря на то, что реализация кодирования NDR существует в Windows более 30-то лет, но до сих пор существует достаточно мало документации по этому кодированию. Я постарался достаточно глубоко, на мой взгляд, разобрать существующие функции кодирование/декодирования NDR и на основе этого опыта сделал как можно более подробные примеры низкоуровневого кодирования и декодирования NDR для всех возможных типов данных. Возможно даже, что в будущем я могу написать отдельную статью по кодированию в данном формате в Windows, однако для текущей статьи я решил ограничиться лишь примерами. Кроме этого примера для ознакомления с возможностями низкоуровневого кодирования и декодирования в NDR можно ознакомиться с моим примером использования сервиса EPMAPPER.
Заключение
Надеюсь, что в данной статье я смог в достаточной для большинства читателей мере описать возможности продвинутого использования RPC в Windows. Все примеры, разработанные для данной статьи можно найти по ссылке. Если у кого-то возникнет желание что-то добавить/изменить в коде примеров: с удовольствием рассмотрю merge reguests.