В этой статье мы разберемся, как при помощи Python передавать сообщения между двумя компьютерами, подключенными к сети. Эта задача часто встречается не только при разработке приложений, но и при пентесте или участии в CTF. Проникнув на чужую машину, мы как-то должны передавать ей команды. Именно для этого нужен reverse shell, или «обратный шелл», который мы и напишем.

Существует два низкоуровневых протокола, по которым передаются данные в компьютерных сетях, — это UDP (User Datagram Protocol) и TCP (Transmission Control Protocol). Работа с ними слегка различается, поэтому рассмотрим оба.

Протокол UDP предназначен для передачи пакетов от одного узла к другому без гарантии доставки. Пакет данных обычно состоит из двух частей. В одной — управляющая информация, в том числе данные отправителя и получателя, а также коды ошибок. В другой — пользовательская информация, то есть сообщение, которое передается.

Важно здесь то, что UDP не гарантирует доставку пакета (правильнее говоря, датаграммы) указанному узлу, так как между отправителем и получателем не установлен канал связи. Ошибки и потери пакетов в сетях случаются сплошь и рядом, и это стоит учитывать (или в определенных случаях, наоборот, не стоит).

Протокол TCP тоже доставляет сообщения, но при этом гарантирует, что пакет долетит до получателя целым и невредимым.

 

Переходим к практике

Писать код мы будем на современном Python 3. Вместе с Python поставляется набор стандартных библиотек, из которого нам потребуется модуль socket. Подключаем его.

import socket

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

На каждой из сторон первым делом создаем экземпляр класса socket и устанавливаем для него две константы (параметры).

 

Используем UDP

Сначала создадим место для обмена данными.

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

Мы создали объект s, который является экземпляром класса socket. Для этого мы вызвали метод из модуля socket с именем socket и передали ему два параметра — AF_INET и SOCK_DGRAMM. AF_INET означает, что используется IP-протокол четвертой версии. При желании можно использовать IPv6. Во втором параметре для наших целей мы можем указать одну из двух констант: SOCK_DGRAMM или SOCK_STREAM. Первая означает, что будет использоваться протокол UDP. Вторая — TCP.

 

Сторона сервера

Далее код различается для стороны сервера и клиента. Рассмотрим сначала сторону сервера.

s.bind(('127.0.0.1', 8888))
result = s.recv(1024)
print('Message:', result.decode('utf-8'))
s.close()

Здесь s.bind(('127.0.0.1', 8888)) означает, что мы резервируем на сервере (то есть на нашей же машине) адрес 127.0.0.1 и порт 8888. На нем мы будем слушать и принимать пакеты информации. Здесь стоят двойные скобки, так как методу bind() передается кортеж данных — в нашем случае состоящий из строки с адресом и номера порта.

INFO

Резервировать можно только свободные порты. Например, если на порте 80 уже работает веб-сервер, то он будет нам мешать.

Далее метод recv() объекта s прослушивает указанный нами порт (8888) и получает данные по одному килобайту (поэтому мы задаем размер буфера 1024 байта). Если на него присылают датаграмму, то метод считывает указанное количество байтов и они попадают в переменную result.

Далее идет всем знакомая функция print(), в которой мы выводим сообщение Message: и декодированный текст. Поскольку данные в result — это текст в кодировке UTF-8, мы должны интерпретировать его, вызвав метод decode('utf-8').

Ну и наконец, вызов метода close() необходим, чтобы остановить прослушивание 8888-го порта и освободить его.

Таким образом, сторона сервера имеет следующий вид:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('127.0.0.1', 8888))
result = s.recv(1024)
print('Message:', result.decode('utf-8'))
s.close()
 

Сторона клиента

Здесь все гораздо проще. Для отправки датаграммы мы используем метод класса socket (точнее, нашего экземпляра s) под названием .sendto():

s.sendto(b'<Your message>', ('127.0.0.1', 8888))

У метода есть два параметра. Первый — сообщение, которое ты отправляешь. Буква b перед текстом нужна, чтобы преобразовать символы текста в последовательность байтов. Второй параметр — кортеж, где указаны IP машины-получателя и порт, который принимает датаграмму.

Таким образом, сторона клиента будет выглядеть примерно так:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(b'<Your message>', ('127.0.0.1', 8888))
 

Тестируем

Для тестирования открываем две консоли, одна у нас будет работать сервером, другая — клиентом. В каждой запускаем соответствующую программу.

Вывод на стороне сервера
Вывод на стороне сервера

На стороне клиента мы ничего увидеть не должны, и это логично, потому что мы ничего и не просили выводить.

Для теста мы передавали сообщение от одного порта другому порту на нашей же машине, но если запустить эти скрипты на разных компьютерах и на стороне клиента указать правильный IP, то все будет работать точно так же.

 

Используем TCP

Пришло время познакомиться с TCP. Точно так же создаем класс s, но в качестве второго параметра будем использовать константу SOCK_STREAM.

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 

Сторона сервера

Снова резервируем порт, на котором будем принимать пакеты:

s.bind(('127.0.0.1', 8888))

Дальше появляется незнакомый нам ранее метод listen(). С его помощью мы устанавливаем некую очередь для подключенных клиентов. Например, с параметром .listen(5) мы создаем ограничение на пять подключенных и ожидающих ответа клиентов.

Делаем бесконечный цикл, в котором будем обрабатывать запросы от каждого нового клиента, находящегося в очереди.

while 1:
  try:
    client, addr = s.accept()
  except KeyboardInterrupt:
    s.close()
    break
  else:
    result = client.recv(1024)
    print('Message:', result.decode('utf-8'))

Страшновато? Начнем по порядку. Сначала мы создаем обработчик исключения KeyboardInterrupt (остановка работы программы с клавиатуры), чтобы сервер работал бесконечно, пока мы что-нибудь не нажмем.

Метод accept() возвращает пару значений, которую мы помещаем в две переменные: в addr будут содержаться данные о том, кто был отправителем, а client станет экземпляром класса socket. То есть мы создали новое подключение.

Теперь посмотрим вот на эти три строчки:

except KeyboardInterrupt:
  s.close()
  break

В них мы останавливаем прослушивание и освобождаем порт, только если сами остановим работу программы. Если прерывания не произошло, то выполняется блок else:

else:
  result = client.recv(1024)
  print('Message:', result.decode('utf-8'))

Здесь мы сохраняем пользовательские данные в переменную result, а функцией print() выводим на экран сообщение, которое нам отправлял клиент (предварительно превратив байты в строку Unicode). В результате сторона сервера будет выглядеть примерно так:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('127.0.0.1', 8888))
s.listen(5)
while 1:
  try:
    client, addr = s.accept()
  except KeyboardInterrupt:
    s.close()
    break
  else:
    result = client.recv(1024)
    print('Message:', result.decode('utf-8'))
 

Сторона клиента

Со стороной клиента опять же все обстоит проще. После подключения библиотеки и создания экземпляра класса s мы, используя метод connect(), подключаемся к серверу и порту, на котором принимаются сообщения:

s.connect(('127.0.0.1', 8888))

Далее мы отправляем пакет данных получателю методом send():

s.send(b'<YOUR MESSAGE>')

В конце останавливаем прослушивание и освобождаем порт:

s.close()

Код клиента будет выглядеть примерно так:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 8888))
s.send(b'<YOUR MESSAGE>')
s.close()
 

Тестируем

Запустим в двух разных консолях код сервера и код клиента. На выходе мы должны получить примерно то же самое, что и с протоколом UDP.

Вывод на стороне сервера
Вывод на стороне сервера

Успех! Поздравляю: теперь тебе открыты большие возможности. Как видишь, ничего страшного в работе с сетью нет. И конечно, не забываем, что раз мы эксперты в ИБ, то можем добавить шифрование в наш протокол.

В качестве следующего упражнения можешь попробовать, например, написать чат на несколько персон, как на скриншоте.

Самодельный чат, вид со стороны сервера
Самодельный чат, вид со стороны сервера
 

Применяем знания на практике

Я дважды участвовал в InnoCTF, и работа с сокетами в Python очень пригождается при решении задач на взлом. По сути, все сводится к тому, чтобы очень много раз парсить поступающие данные с сервера InnoCTF и правильно их обрабатывать. Данные могут абсолютно любыми. Обычно это математические примеры, разные уравнения и прочее.

Для работы с сервером я использую следующий код.

import socket
try:
  s = socket.socket(socket.AF_INET, spcket.SOCK_STREAM)
  s.connect(('<HOST>', <PORT>))
  while True:
    data = s.recv(4096)
    if not data:
      continue
    st = data.decode("ascii")
    # Здесь идет алгоритм обработки задачи, результаты работы которого должны оказаться в переменной result
    s.send(str(result)+'\n'.encode('utf-8'))
finally:
  s.close()

Здесь мы сохраняем байтовые данные в переменную data, а потом преобразуем их из кодировки ASCII в строчке st = data.decode("ascii"). Теперь в переменной st у нас хранится то, что нам прислал сервер. Отправлять ответ мы можем, только подав на вход строковую переменную, поэтому обязательно используем функцию str(). В конце у нее символ переноса строки — \n. Далее мы все кодируем в UTF-8 и методом send() отправляем серверу. В конце нам обязательно нужно закрыть соединение.

 

Делаем полноценный reverse shell

От обучающих примеров переходим к реальной задаче — разработке обратного шелла, который позволит выполнять команды на захваченной удаленной машине.

При этом добавить нам нужно только вызов функции subprocess. Что это такое? В Python есть модуль subprocess, который позволяет запускать в операционной системе процессы, управлять ими и взаимодействовать с ними через стандартный ввод и вывод. В качестве простейшего примера используем subprocess, чтобы запустить блокнот:

import subprocess
subprocess.call('notepad.exe')

Здесь метод call() вызывает (запускает) указанную программу.

Переходим к разработке шелла. В данном случае сторона сервера будет атакующей (то есть наш компьютер), а сторона клиента — атакованной машиной. Именно поэтому шелл называется обратным.

Продолжение доступно только участникам

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее

2 комментария

  1. Аватар

    e6a3dec

    14.04.2020 at 19:57

    ну раскрыл бы уже тему pty

  2. Аватар

    S1DAL3X

    15.04.2020 at 23:20

Оставить мнение