Используем socket в Powershell с серверной и клиентской частью


16 июня 2022


Создаем сетевой сокет на Powershell для обмена данными

При создании клиент-серверных приложений используется понятие сокета. Он используется при взаимодействии 3-ого уровня TCP/IP с 4-ым. В этой статье будет показан пример его создания и применения используя Powershell и .NET, а так же использование NTLM.

 

 

Где используется сетевой сокет

Клиент-серверные приложения должны каким-то образом обмениваться информацией между собой. Самый частый способ обмена информацией - это сеть с использованием протоколов TCP/IP. Кроме TCP, который имеет несколько преимуществ (одно из которых гарантированная доставка пакетов), есть и UDP (не имеющий гарантированной доставки, но чаще всего работающий быстрее).

В протоколах TCP и UPD указывается номер порта, в диапазоне 1-65535 для каждого из протоколов. При написании программы, которая использует TCP или UDP, вы и создаете "сетевой сокет". Сетевым сокетом так же называют сопоставление IP адреса и порта, например "192.168.1.1:80".

Когда вы открываете какой-то сайт - вы используете сокет что бы отправить данные, а сервер использует сокет что бы их прочитать. У процесса создания сокета есть что-то вроде стандарта (придуманный в Беркли), который совпадает во многих языках - он описан на скриншоте.

Обмен данными между сокетами

Само создание сокета не подразумевает, что вы создали 4-ый уровень TCP/IP (уровень приложения). Вы сможете создать его сами, обработав биты с помощью понятия стримов.

 

Создание сокета на сервере

Перед тем как написать серверную часть - нужно выбрать порт, который не занят. В примерах ниже будет использоваться 1330 TCP порт на сервере. Проверить его можно так (в случае, если порт не используется, будет ошибка):

Get-NetTcpConnection -LocalPort 1330

Кроме выбора порта мы должны выбрать IP адрес (или сетевой интерфейс), с которого мы будем принимать подключение. Чаще всего используется адрес '0.0.0.0', который обозначает, что мы будем принимать подключения со всех сетевых интерфейсов:

[system.net.ipaddress]::any

Это не отменяет работу фаерволла, который может блокировать трафик по IP или порту.

После выбора IP и порта мы используем класс "IPEndPoind", который можно назвать сокетом. Что бы операционная система узнала, что мы планируем читать указанный TCP порт, мы используем класс "TCPListener" (на схеме выше - метод bind). Использование метода "start" переводит порт в состояние "Listen":

$port = 1330
# создание объекта сокета
$endpoint = New-Object System.Net.IPEndPoint([system.net.ipaddress]::any, $port)
# привязывание сокета к системе
$socket = New-Object System.Net.Sockets.TcpListener $endpoint
# открытие сокета
$socket.start()

Создание и монтирование сокета в Powershell и .NET

Мы используем "System.Net.Sockets" для принятия пакетов TCP, но его можно изменить (с дальнейшими изменениями в коде) для работы с UDP мультикастом и т.д. Посмотреть какие классы еще доступны можно в документации Microsoft.

Открытие сокета и состояние "Listening" обозначает, что трафик может приниматься портом, метод "AcceptTcpClient" указывает какого именно типа он может быть ():

$data = $socket.AcceptTcpClient()

Важно отметить, что мы используем блокирующий (синхронный) метод "AcceptTcpClient". Это значит, что скрипт не продолжит работу до тех пор, пока не подключится клиент. Есть так же асинхронные методы, которые не будут ожидать подключения, но они их применение немного сложнее.

Кроме этого методы .NET нельзя прервать через Powershell используя "Ctrl+C". Что бы завершить работу такого метода можно просто завершив сессию Powershell (закрыть окно). В этом случае сокет так же будет закрыт.

Вы так же можете прервать работу "AcceptTcpClient()" если перейти через браузер по "localhost" + ваш порт. Таким образом вы отправите TCP/HTTP пакет, а AcceptTcpClient примет его.

Прием TCP пакетов с сокета на Powershell и .NET

После того, как мы приняли TCP пакет мы должны его обработать т.к. данные будут в битах (в переменной $stream). Один из способов это сделать - использовать класс 'System.IO.StreamReader', который работает со строками и UTF-8 (по умолчанию):

$stream = $data.GetStream()
$reader = New-Object System.IO.StreamReader($stream)
$result = $reader.ReadToEnd()

Проверить, что мы получаем данные можно так же через браузер. При открытии страницы через браузер нужно нажать на крестик:

Получение заголовков HTTP клиента используя сокеты Powershell и .NET

Нажатие на крестик необходимо так как протокол HTTP имеет дополнительную логику и не закрывает соединение, а "ReadToEnd" ожидает еще данных из-за того, что соединение еще открыто. "ReadToEnd" самый простой способ чтения стрима, но не единственный возможный.

Если вы открыли сокет на сервере, то по завершению работы серверной части должны его закрыть. Если порт не будет закрыт, то вы не сможете его использовать в других приложениях и у вас будут ошибки. Закрыть порт можно просто завершив сессию Powershell (закрыть окно) или используя методы ниже. Кроме того нужно освобождать стримы т.к. в них хранятся данные, которые занимают место в памяти:

$stream.Dispose()
$stream.Close()
$socket.Stop()

Закрытие сокета и стрима в Powershell и .NET

Что бы избежать проблемы, которая была с HTTP протоколом и методом "ReadToEnd" можно использовать метод Peek() и Read(), которые читают данные посимвольно. В примере ниже показан пример использования этих методов и класса "StreamWriter" (так же для строк и в UTF-8 по умолчанию), через который мы отправим данные в браузер:

$Port=1330
$answer = 'hello world'
$endpoint = New-Object System.Net.IPEndPoint ([system.net.ipaddress]::any, $Port)
$socket = New-Object System.Net.Sockets.TcpListener $endpoint
$socket.start()

$data = $socket.AcceptTcpClient()
$stream = $data.GetStream()
$reader = New-Object System.IO.StreamReader($stream)
# чтение данных
$string = ''
while ($reader.Peek() -ge 0){
    $result = $reader.Read()
    $string += [char]$result
}
# полученные данные
$string
# отправка данных
$writer = New-Object System.IO.StreamWriter($stream)
foreach ($line in $answer){
    $writer.WriteLine($line)
    $writer.Flush()
}
$stream.Dispose()
$stream.Close()
$socket.Stop()

Чтение и запись с StreamWriter и StreamReader в Powershell и .NET

Хоть браузер и показывает отправленное сообщение - это не значит что мы реализовали протокол HTTP.

Классы "StreamReader" и "StreamWriter" имеют дополнительные методы, которые во многом похожи. Подробнее о них можно почитать в документации.

Если вы не хотите использовать отдельные классы или вы отправляете не строки, можете обработать данные собственноручно используя буфер ($bytes):

# получение
$bytes = New-Object System.Byte[] 1024

$stream_byte = $stream.Read($bytes,0,$bytes.Length)
foreach ($i in $stream_byte){
    $encoded_text = New-Object System.Text.UTF8Encoding
    $data = $encoded_text.GetString($bytes,0, $i)
    Write-Output $data
}
# отправка
$encoded_text = New-Object System.Text.UTF8Encoding
[byte[]]$byte_answer = $encoded_text.GetBytes($answer)
$stream.Write($byte_answer, 0, $byte_answer.Length)

Чтение и запись данных в стрим для отправки через сокет в Powershell и .NET

Вы можете столкнуться с проблемой, когда буфер будет полностью занят, т.к. ограничен в 1024 байта. Чаще всего она решается на прикладном уровне либо вы должны заранее знать объем отправляемых данных. Так же можно рассчитывать объем на стороне клиента и отправлять в первом сообщении. Насколько знаю примерно так работает HTTP.

Так же можно использовать классы "MemoryStream" и "CopyTo", которые помогут избежать этой проблемы.

Чтение данных часто реализовывают через свойство "DataAvailable", которое показывает что данные в стриме еще есть. Это реализовывается через цикл:

$response = ""
$bytes = New-Object System.Byte[] 1024
while ($stream.DataAvailable){
  $stream_bytes = $stream.Read($bytes, 0, $bytes.Length)
  $encodedtext = New-Object System.Text.UTF8Encoding
  $response += $encodedtext.GetString($bytes, 0, $stream_bytes)
}
$response

 

Создание сокета на клиенте

Создание клиентской части похоже на серверную, но в обратном порядке. Первым делом мы должны создать объект с IP адресом сервера и выполнить соединение с сервером. Если порт будет закрыт или соединение будет блокировать фаерволл, то произойдет ошибка:

$server = 'localhost'
$port = 1330
$message = 'Привет'
# получаем IP адрес через DNS запрос
$ip = [System.Net.Dns]::GetHostAddresses($server) | Where-Object {$PSItem.AddressFamily -eq 'InterNetwork'}
# выполняем соединение
$socket = New-Object System.Net.Sockets.TCPClient($ip, $port)

Если вам не нужно выполнять DNS запрос, то можете использовать метод Parse, но он так же вернет объект "IPAddress", но без запроса:

$ip = [System.Net.IPAddress]::Parse('127.0.0.1')

Получение IP адреса для создания сокета на клиенте с Powershell и .NET

Последнее, что нужно сделать, создать стрим и записать в него данные. По умолчанию идет расчет, что вы используете UTF-8:

$stream = $socket.GetStream()
$writer = New-Object System.IO.StreamWriter($stream)
foreach ($line in $message){
   $writer.WriteLine($line)
   $writer.Flush()
}

# закрытие сокета и стрима
$stream.Dispose()
$stream.Close()
$socket.Close()

Отправка данных с сокета клиента на сервер используя Powershell и .NET

Сокет на клиенте не привязывается к системе, как в случае сервера. Т.е. если его не закрыть вы будете расходовать память, но не получите ошибку, как в случае с сервером.

 

Пример на удаленном выполнении команд

У модуля PSRemoting есть ряд ограничений. Они могут быть связаны с .NET классами, WMI и самим Powershell. Например мы не можем открыть программу у удаленного пользователя:

Invoke-Command -ComputerName 'localhost' -ScriptBlock {calc.exe}

Такие ограничения часто обходят созданием задачи в планировщике, которая запускается мгновенно.

Используя сокет можно обойти эти проблемы. Такой скрипт можно использовать для сервера (который будет получать скрипт запуска программы):

function Receive-TCPCommand {
    Param ( 
        [Parameter(Mandatory=$true)] 
        [int]$Port
    ) 
        $endpoint = New-Object System.Net.IPEndPoint ([system.net.ipaddress]::any, $Port)
        $socket = New-Object System.Net.Sockets.TcpListener $endpoint
        $socket.start()

        # вечный цикл с таймаутом в 1 секунду
        while ($True){
            $data = $socket.AcceptTcpClient()
            $stream = $data.GetStream()
            $reader = New-Object System.IO.StreamReader($stream)
            $result = $reader.ReadToEnd()
            powershell.exe -Command "$result"
            $stream.Dispose()
            $stream.Close()
            sleep(1)
        }
        $socket.Stop()
}

Вечный цикл создан для того, что бы сокет не перестал работать после первого подключения. Таймаут не обязателен так как "AcceptTcpClient" и так будет блокировать выполнение дальнейшей программы до подключения клиента. Это нужно что бы сработали сочетания клавиш "Ctrl+C", так как sleep может быть отменен через Powershell.

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

И клиент:

function Send-TCPCommand { 
        param ( [ValidateNotNullOrEmpty()]
          [Parameter(Mandatory=$true)]
          [string]
          $Server, 
          [Parameter(Mandatory=$true)]
          [int] 
          $Port,
          [Parameter(Mandatory=$true)]
          [string]
          $Message
        ) 
        $ip = [System.Net.Dns]::GetHostAddresses($server) | Where-Object {$PSItem.AddressFamily -eq 'InterNetwork'}
        $socket = New-Object System.Net.Sockets.TCPClient($ip,$port)
        
        $stream = $socket.GetStream()
        $writer = New-Object System.IO.StreamWriter($stream)
        foreach ($line in $message){
           $writer.WriteLine($line)
           $writer.Flush()
        }
        $stream.Dispose()
        $stream.Close()
        $socket.Close()      
}

 

Аутентификация и шифрование

Используя .NET мы можем реализовать механизм аутентификации и шифрования. В примере показан NTLM, но если сервер и клиент поддерживают Kerberos, то будет использоваться он. Клиент и сервер используют один и тот же класс "System.Net.Security.NegotiateStream", но с разными методами.

Для сервера мы используем метод "AuthenticateAsServer": 

$negotiate_stream =  New-Object System.Net.Security.NegotiateStream($stream)
$negotiate_stream.AuthenticateAsServer(
    [System.Net.CredentialCache]::DefaultNetworkCredentials,
    [System.Net.Security.ProtectionLevel]::EncryptAndSign,
    [System.Security.Principal.TokenImpersonationLevel]::Impersonation
    )

Где:

  • [System.Net.CredentialCache]::DefaultNetworkCredentials - класс, проверяющий стандартные учетные данные;
  • [System.Net.Security.ProtectionLevel]::EncryptAndSign - класс, использующий подпись и шифрование;
  • [System.Security.Principal.TokenImpersonationLevel]::Impersonation - говорит, что контекст безопасности пользователя не может быть использован на других серверах. Проще говоря, что он не может выполнять задачи на другом сервере.

Для клиента используем метод "AuthenticateAsClient" с частью параметров, которые были описаны выше:

$negotiate_stream =  New-Object System.Net.Security.NegotiateStream($stream)
$negotiate_stream.AuthenticateAsClient(
        (Get-Credential).GetNetworkCredential(),
        'администратор@domain.local',
        [System.Net.Security.ProtectionLevel]::EncryptAndSign,
        [System.Security.Principal.TokenImpersonationLevel]::Impersonation
    )

Где:

  •  (Get-Credential).GetNetworkCredential() - запрашивает и сохраняет учетные данные;
  • 'администратор@domain.local' - подразумевает SPN и не имеет значения в случае NTLM. Если используете Kerberos, то это может быть важным. Посмотреть SPN можно через команду Get-ADUser и setspn. Синтаксис может быть таким "MYSERVICE/администратор@domain.local"
  • аутентификации. Возможно подразумевается, что вы реализовываете это на уровне приложения.

После аутентификации, в объекте "$NegotiateStream", будет множество полезных свойств. Например:

# подписаны ли отправленные данные
$negotiate_stream.IsSigned
# успешная ли аутентификация
$negotiate_stream.IsAuthenticated
# используется ли шифрование
$negotiate_stream.IsEncrypted
# Относится ли пользователь к администратору
([Security.Principal.WindowsPrincipal]$negotiate_stream.RemoteIdentity).IsInRole('администраторы')
# подробная информация о типе аутентификации
$negotiate_stream
$negotiate_stream.RemoteIdentity

Получение данных о NegotiateStream в Powershell и .NET

Запись и чтения стрима происходит так же, но с использованием класса:

# Запись
$data = (New-Object System.Text.UTF8Encoding).GetBytes('какое-то сообщение')
$negotiate_stream.Write($data,0,$data.length)
$negotiate_stream.Flush()


# Чтение
$result = ''
$byte = New-Object byte[] 1024
$stream_bytes = $negotiate_stream.Read($byte, 0, $byte.Length)            
foreach ($i in $stream_bytes){
    $encoding = New-Object System.Text.UTF8Encoding
    $result += $encoding.GetString($byte, 0, $stream_bytes)
}

Пример команды для сервера:

function Receive-TCPAuthCommand {
    Param ( 
        [Parameter(Mandatory=$true)] 
        [int]$Port
    ) 
        $endpoint = New-Object System.Net.IPEndPoint ([system.net.ipaddress]::any, $Port)
        $socket = New-Object System.Net.Sockets.TcpListener $endpoint
        $socket.start()

        # вечный цикл с таймаутом в 1 секунду
        while ($True){
            $data = $socket.AcceptTcpClient()
            $stream = $data.GetStream()
            $negotiate_stream =  New-Object System.Net.Security.NegotiateStream($stream)
            $negotiate_stream.AuthenticateAsServer(
                [System.Net.CredentialCache]::DefaultNetworkCredentials,
                [System.Net.Security.ProtectionLevel]::EncryptAndSign,
                [System.Security.Principal.TokenImpersonationLevel]::Impersonation
                )
            $result = ''
            $byte = New-Object byte[] 1024
            $stream_bytes = $negotiate_stream.Read($byte, 0, $byte.Length)            
            foreach ($i in $stream_bytes){
                $encoding = New-Object System.Text.UTF8Encoding
                $result += $encoding.GetString($byte, 0, $stream_bytes)
            }
            # закрываем цикл, если команда 'exit'  
            if ($result -like "exit*"){
                $negotiate_stream.Dispose()
                $stream.Dispose()
                $stream.Close()
                break
            }
            else {
                powershell.exe -Command $result
                $negotiate_stream.Dispose()
                $stream.Dispose()
                $stream.Close()
                sleep(1)
            }
        }
        $socket.Stop()
}

Пример команды клиента:

function Send-TCPAuthCommand { 
        param ( [ValidateNotNullOrEmpty()]
          [Parameter(Mandatory=$true)]
          [string]
          $Server, 
          [Parameter(Mandatory=$true)]
          [int] 
          $Port,
          [Parameter(Mandatory=$true)]
          [string]
          $Command,
          [Parameter(Mandatory=$true)]
          $Credential
        ) 
        $ip = [System.Net.Dns]::GetHostAddresses($server) | Where-Object {$PSItem.AddressFamily -eq 'InterNetwork'}
        $socket = New-Object System.Net.Sockets.TCPClient($ip,$port)
        
        $stream = $socket.GetStream()
        $negotiate_stream =  New-Object System.Net.Security.NegotiateStream($stream)
        $negotiate_stream.AuthenticateAsClient(
                $Credential.GetNetworkCredential(),
                'администратор@domain.local',
                [System.Net.Security.ProtectionLevel]::EncryptAndSign,
                [System.Security.Principal.TokenImpersonationLevel]::Impersonation
            )
        $data = (New-Object System.Text.UTF8Encoding).GetBytes($Command)
        $negotiate_stream.Write($data,0,$data.length)
        $negotiate_stream.Flush()
        $negotiate_stream.Dispose()
        $stream.Dispose()
        $stream.Close()
        $socket.Close()      
}

Пример работы с верными и неверными учетными данными:

...

Теги: #powershell #socket


Каналы
Telegram FixMyPc Telegram Лента FixMyPC RSS Rss
Популярные тэги
О блоге
Этот блог представляет собой конспекты выученного материала, приобретённого опыта и лучшие практики в системном администрировании и программировании.