Привет, Хабр!
Несмотря на то, что с момента публикации второй части прошло почти полгода — прокрастинация все-таки выиграла битву, но не войну, и поэтому повествование о нелегких буднях в НИИ Велосипедостроения продолжается. В предыдущих частях (часть 1, часть 2) рассказывалось об общей концепции и предпосылках, MVP для сервера, отвечающего за запуск преднастроенного контейнера с openssh и отслеживание состояния клиента, подключенного через websocket. В третьей части статьи поговорим о рисках в части безопасности и реализации клиентского приложения.
Безопасность
Перефразируя известного персонажа, чью роль исполнил Саша Барон Коэн, можно с уверенностью утверждать, что безопасность данного решения это боль в моя…в общем, если смотрели фильм, то понимаете о чем я.
Перечислим риски, возможные последствия их реализации, и очертим круг задач, которые могут быть решены с помощью этого приложения:
1. Недостаточная аутентификация
Ввиду того, что постановка задачи предполагала максимальное упрощение доступа для конечного пользователя (который, как известно, славится своей способностью влипать в трудности на ровном месте), в реализации решено было использовать короткую ссылку, содержащую UID, указывающий на конечный ресурс для подключения. Таким образом, ссылка является единственным необходимым ключом для получения доступа.
Риски:
Компрометация
По сути, возможность передать ссылку третьим лицам, либо выложить в открытый доступ - это фатальный недостаток, который может привести к получению доступа до внутренних ресурсов, скрытых за ssh-туннелями, третьими лицами.
Возможное решение: усилить аутентификацию с помощью дополнительного атрибута (например пин-кода, по аналогии с закрытыми конференциями в Zoom).
Подбор
Исходя из того, что ссылка для доступа пользователя на ресурс генерируется единожды, и не меняется в течение всего времени использования (пока пользователь не совсем не перестанет работать с конечным ресурсом), попытки перебора все-таки возможны.
Возможное решение: усиление аутентификации, мониторинг обращений к несуществующим ресурсам, и своевременное принятие ответных мер.
2. Доступ во внутреннюю сеть
Если с первой уязвимостью все более-менее стандартно, то что будет в случае, когда злоумышленником является легальный пользователь? Заходя на сервер по RDP, клиент получает доступ не только к прикладному ПО, а еще и к операционной системе и сегменту локальной сети, в котором этот сервер находится. "Букет" рисков, вектора развития атак, и возможные последствия в таком случае могут быть самыми разнообразными, и есть проверенный свести эти самые риски к минимуму - разработка и реализация полноценной политики ИБ на вверенном участке (настройка фильтрации сетевого трафика на маршрутизаторах, настройка брандмауэров, ограничение привилегий на сервере, к которому подключается пользователь, антивирусная защита и т.д.).
Однако, стоит сказать, что в случае использования классических решений эта головная боль никуда не денется, а посему поставить этот класс уязвимостей в вину описываемому решению на 100% нельзя.
3. Некорректные настройки и безопасность openSSH-сервера
В sshd_config прописывается ограничение на количество сессий, конечную точку туннеля, на лету генерируется пользователь и пароль. Выявленные уязвимости в openSSH, docker и любом другом ПО, обслуживающем эту связку, можно отнести к вопросам, изложенным в п.2.
Резюме
Фактически, при детерминировании рисков, проявился явный недостаток, заложенный в систему, что называется by design. Справиться с ним не составляет особого труда, как в принципе и не составляет труда навесить на веб-сервер дополнительную аутентификацию, либо использовать SSO для внешней аутентификации пользователя, переходящего по ссылке.
Где, когда и зачем использовать это решение?
Условия и задачи, под которые проектировалось решение, можно уложить в одном предложении — дать пользователю доступ на его сервер по RDP (один пользователь — одна ВМ).
В моем случае расклад следующий:
Есть несколько пользователей, которые не являются сотрудниками компании, и в корпоративный VPN они доступа не получают;
На ресурсах компании размещены демонстрационные виртуальные машины с набором преднастроенного ПО;
Пользователям нужно дать доступ к ПО, при этом не выставляя ничего вовне;
Сами виртуальные машины и данные на них не представляют никакой ценности, в случае потери\гибели\повреждения\приведения в нерабочее состояние любой из машин, она заново раскатывается из сэмпла менее чем за 5 минут;
Все эти машины, не являющиеся частью основной сети компании, выделены в изолированный контур, и никак не пересекаются с физической сетью компании.
Является ли это заменой VPN? Частично, в случае с VPN вы получаете доступ к сети, здесь — конкретный порт на конкретной машине.
Использование as is возможно, если конечному пользователю нужен один конкретный порт на конкретном хосте.
Клиент
Обычно ролики про приготовление "быстрых блюд из того что есть под рукой за 5 минут" начинаются с долгого перечисления того, что же нам всё-таки понадобится. Суровые викинги из Regular Ordinary Swedish Meal Time делают то же самое с завидной порцией ярости, что, увы, мне недоступно. А было бы весело! Нам понадобится клиент для вебсокетов! Шмяк! Библиотека для работы по SSH! Бабах! И тема с пассивной агрессией закроется сама собой.
Но, что-то я отвлекся. Для реализации клиента нужно всего ничего:
Получить данные для подключения через вебсокет;
Подключиться и пробросить порт через ssh;
Запустить удаленное подключение по проброшенному порту.
Для работы с вебсокетами в .NET имеется встроенный класс клиента, его-то и задействуем.
public class WebSocketWrapper
{
public static async Task<ConnectionDetails> GetConnectionDetails(ClientWebSocket client, string uri)
{
Uri serverUri = new Uri(uri);
var cancellationToken = new CancellationTokenSource();
cancellationToken.CancelAfter(30000);
await client.ConnectAsync(serverUri, cancellationToken.Token);
if(client.State != WebSocketState.Open)
{
throw new Exception("Websocket connection error");
}
WebSocketReceiveResult result;
using (var ms = new MemoryStream())
{
do
{
var messageBuffer = WebSocket.CreateClientBuffer(1024, 16);
result = await client.ReceiveAsync(messageBuffer, CancellationToken.None);
ms.Write(messageBuffer.Array, messageBuffer.Offset, result.Count);
}
while (!result.EndOfMessage);
var response = Encoding.UTF8.GetString(ms.ToArray());
var deserializedResult = JsonSerializer.Deserialize<ConnectionDetails>(response);
return deserializedResult;
}
}
public static async Task HandleEvents(ClientWebSocket client)
{
WebSocketReceiveResult result;
do
{
var messageBuffer = WebSocket.CreateClientBuffer(1024, 16);
result = await client.ReceiveAsync(messageBuffer, CancellationToken.None);
}
while (true);
}
}
Основным индикатором жизни клиента является установленное и работоспособное соединение с вебсокет-сервером, поэтому объект, реализующий низкоуровневые методы (чтение, запись, ping-pong), передается в качестве параметра, поскольку разрыв соединения приведет к остановке и удалению контейнера с openssh.
Метод HandleEvents нужен для "закадрового" обмена ping-pong`ами, сигнализирующими серверу о том, что клиент еще жив.
Для работы с SSH используем Renci.SshNet.
public class SshTunnel
{
private static SshClient client;
private static ForwardedPortLocal forwardedPort;
public static int Create(string sshHost, ConnectionDetails details)
{
client = new SshClient(new ConnectionInfo(sshHost, details.ExternalPort, details.SshUsername, new AuthenticationMethod[1]
{
new PasswordAuthenticationMethod(details.SshUsername, details.SshPassword)
}));
client.Connect();
int freePort = Util.GetFreePort(new Random().Next(12000, 13000));
forwardedPort = new ForwardedPortLocal("localhost", Convert.ToUInt32(freePort), details.InternalHost, 3389U);
client.AddForwardedPort(forwardedPort);
forwardedPort.Start();
return freePort;
}
}
Считаем, что хост, полученный из ссылки, и есть хост SSH-сервера, на котором запускаются контейнеры. Для подключения необходимы также порт, логин, пароль, и внутренний адрес, куда нужно в итоге попасть. Эти данные десериализуются в ConnectionDetails из обертки для работы с вебсокет-сервером. Кроме того, для проброса порта нужно получить свободный порт в ОС пользователя, после чего, в случае успешного подключения, метод возвращает выбранный для туннелирования номер локального порта.
Теперь, когда первые два пункта выполнены, можно собрать все воедино, и протестировать.
public class VClient
{
public static void Connect(string shortLink)
{
var logger = Util.GetLogger();
logger.Info("Connecting to {0}", shortLink);
// Получаем адрес для подключения к вебсокет-серверу
Uri uri = new Uri(shortLink);
var host = uri.Host;
var key = uri.PathAndQuery.Remove(0, 1);
var wsScheme = uri.Scheme == "https" ? "wss" : "ws";
var wsUri = String.Format("{0}://{1}/cjrpc/{2}", wsScheme, host, key);
var wsClient = new ClientWebSocket();
logger.Info("Requesting ssh params from websocket cinnection");
// Обращаемся к серверу за параметрами для ssh-туннеля
var details = WebSocketWrapper.GetConnectionDetails(wsClient, wsUri).Result;
logger.Info("Params received, waiting for container startup");
// Контейнер стартует некоторое время, нужна небольшая задержка перед подключением
Thread.Sleep(5000);
var forwardedPort = SshTunnel.Create(host, details);
logger.Info("SSH tunnel estabilished, forwarded port {0}", forwardedPort);
// Вебсокет-серверу нужно получать пакеты, подтверждающие, что клиент жив
// Так как приложение однопоточное, и глаынй поток будет заблокирован ожиданием закрытия процесса mstsc - нужно поддерживать соединение
Task.Run(() => { WebSocketWrapper.HandleEvents(wsClient); });
logger.Info("Starting remote desktop connection");
var rdcProcess = new Process();
// Стартуем удаленное подключение сразу на проброшенный порт
rdcProcess.StartInfo = new ProcessStartInfo("mstsc.exe", string.Format("/v:localhost:{0}", forwardedPort)); ;
rdcProcess.Start();
// Ожидаем закрытия процесса
rdcProcess.WaitForExit();
// Закрываем вебсокет, сервер остановит и удалит контейнер с openssh
wsClient.Abort();
logger.Info("Session closed");
}
}
Это и есть минимально жизнеспособный пример, способный получить по ссылке данные для подключения, и поддерживать соединение с сервером в одном потоке, отслеживая работу процесса подключения к удаленному рабочему столу, и завершая соединение (вызывающее остановку контейнера на сервере) при закрытии процесса.
На конец порекомендую открытый урок «Что полезного в новых версиях C#?», который скоро пройдет в OTUS. На нем участники разберут ключевые нововведения релиза .NET 6.0 с C# 10 и познакомятся с полезными и часто используемыми новшествами последних версий языка C#. Участие бесплатное, регистрация доступна для всех желающих по ссылке. А начинающие разработчики могут при желании ознакомиться с программой специализации "C# Developer", которая помогает с нуля освоить принципы программирования и развиваться в С#-разработке.