Внеигровые боты
В этой главе мы познакомимся с внеигровыми ботами. Сначала рассмотрим инструменты для их разработки. После этого изучим основные принципы работы вычислительных сетей. Попробуем написать простое сетевое приложение. Когда мы освоим инструменты разработки, напишем внеигрового бота для существующей игры. В конце главы рассмотрим методы защиты от ботов этого типа.
Инструменты для разработки
Инструменты для разработки внутриигровых и внеигровых ботов различаются. В первом случае нам нужны эффективные средства для доступа к памяти процесса игры и манипуляции его данными. Внеигровые боты полностью замещают собой игровой клиент и дублируют его основные возможности.
Язык программирования
Многие из существующих внеигровых ботов написаны на C++. Этот язык хорошо интегрируется с WinAPI, а кроме того для него существует много сторонних библиотек, в том числе для работы с сетью и криптографией. C++ – отличный инструмент для разработки ботов. Но в этой главе мы воспользуемся другим языком для наших примеров.
Мы будем использовать скриптовый язык Python по нескольким причинам. Прежде всего, он лаконичнее C++. Благодаря этому наши примеры станут короче и понятнее для чтения. Также у Python есть библиотеки (известные как модули) для работы с сетью и криптографией. Эти возможности очень важны для разработки внеигровых ботов.
Для работы с Python подойдёт практически любая IDE. Я предпочитаю Notepad++, которым мы пользовались во второй главе.
Есть два варианта установки Python и криптографической библиотеки. Первый вариант – Python последней версии 3.6.5 и библиотека PyCryptodome. PyCryptodome – это ответвлённый проект библиотеки PyCrypto. В нём лучше реализована поддержка ОС Windows. К сожалению, этот проект не имеет некоторых устаревших возможностей PyCrypto. Они вряд ли понадобятся при разработке реальных ботов, но могут быть полезны для учебных целей при знакомстве с криптографией. Второй вариант установки подразумевает более старую версию Python 3.3.0 и библиотеку PyCrypto.
Все примеры этой главы корректно исполняются на обеих версиях Python 3.6.5 и 3.3.0. Но если вы выберите вариант с PyCryptodome, вы не сможете запустить несколько примеров. Они не так важны, и будет достаточно просто рассмотреть их код.
Для установки Python 3.3.0 и библиотеки PyCrypto выполните следующие действия:
- Скачайте Python 3.3.0 с официального сайта.
- Установите Python. Выберите путь установки по умолчанию:
C:\Python33. - Скачайте неофициальную сборку библиотеки PyCrypto.
- Установите библиотеку. В процессе установки Python будет найден автоматически.
Инструкция по установке Python 3.6.5 и библиотеки PyCryptodome:
- Скачайте Python 3.6.5 с официального сайта.
- Установите его по пути по умолчанию:
C:\Program Files\Python36. - Скачайте скрипт
get-pip.pyс сервера bootstrap. Этот скрипт устанавливает менеджер модулейpip. С его помощью вы сможете скачивать нужные вам модули Python. - Запустите
get-pip.pyиз командной строки:
1 get-pip.py --user
Когда скрипт закончит свою работу, вы увидите сообщение с путём установки менеджера pip. В моём случае это C:\Users\ilya.shpigor\AppData\Roaming\Python\Python36\Scripts.
5. Перейдите по пути установки pip и запустите его:
1 pip install --user pycryptodome
По этой команде будет скачана библиотека PyCryptodome.
После установки любой версии Python нужно проверить, что путь до интерпретатора python.exe попал в переменную окружения PATH. Для этого выполните следующие действия:
- Откройте диалог “Control Panel” -> “System” -> “Advanced system settings” (“Панель управления” -> “Система” -> “Дополнительные параметры системы”). Нажмите кнопку “Environment Variables” (переменные среды). Вы увидите диалог с двумя списками.
- В списке “System variables” (переменные системы) найдите переменную “PATH”. Выберите её левым щелчком мыши.
- Нажмите кнопку “Edit” (Редактирование). Вы увидите текущий список путей в переменной
PATH. - Добавьте в список ваш путь установки Python, если его там нет.
Теперь ваша система готова к запуску примеров этой главы.
Язык Python кросс-платформенный. Это значит, что написанные на нём скрипты можно запускать на Windows, Linux и macOS с незначительными изменениями.
Анализатор трафика
Wireshark – один из самых известных анализаторов трафика с открытым исходным кодом. Благодаря ему вы сможете перехватывать весь входящий и исходящий трафик с указанной сетевой платы, просматривать его в удобном интерфейсе пользователя, фильтровать пакеты, выводить статистику и сохранять результат на жёстком диске. Кроме этого, Wireshark имеет функции для интерпретации данных и расшифровки большинства сетевых протоколов.
Конфигурация Windows
В этой главе мы будем работать с сетевыми приложениями. Каждое из них состоит из двух частей (клиент и сервер), запущенных на разных компьютерах, которые соединены друг с другом через сеть. Для тестирования таких приложений нужны либо два компьютера, либо специальные средства вроде виртуальной машины. В этом случае одна часть приложения запускается на хост-системе (ваша ОС), а другая часть в виртуальной машине (гостевая система). Системы подключаются друг к другу через эмулируемую локальную сеть.
К счастью, у современных ОС есть возможность запуска и отладки сетевых приложений без вспомогательных компьютеров или виртуальных машин. Для этой цели служит специальный сетевой интерфейс, известный как loopback (петля). Обе части сетевого приложения, запущенные на одном компьютере могут обмениваться сетевыми пакетами через loopback. При этом они ведут себя практически так же, как если бы взаимодействовали через реальную сеть.
По умолчанию интерфейс loopback отключён в Windows. Чтобы запустить наши тестовые примеры, вам потребуется его включить. Для этого выполните следующие шаги:
- Запустите Device Manager (диспетчер устройств). Вы можете сделать это через Control Panel (панель управления) или набрав команду “Device Manager” в меню Start (пуск).
- Выберите корневой элемент в дереве устройств окна Device Manager.
- Выберите пункт меню “Action” -> “Add legacy hardware” (“Действие” -> “Установить старое устройство”). Откроется диалог “Add Hardware” (установить устройство).
- Нажмите кнопку “Next” (далее) на первой странице диалога.
- На второй странице диалога выберите пункт “Install the hardware that I manually select from a list (Advanced)” (установка оборудования, выбранного из списка вручную). Нажмите кнопку “Next”.
- В списке “Common hardware types” (стандартные типы оборудования) выберите пункт “Network adapters” (сетевые платы). Нажмите кнопку “Next”.
- Выберите производитель “Microsoft” и сетевую плату “Microsoft Loopback Adapter”. Нажмите кнопку “Next” на этой и следующей страницах.
- Когда процесс установки завершится, нажмите кнопку “Finish” (завершить).
После установки интерфейса loopback, его необходимо включить. Для этого выполните следующие действия:
- Откройте окно “Network and Sharing Center” (центр управления сетями и общим доступом). Это можно сделать через меню “Start”.
- Щёлкните по пункту “Change adapter settings” (изменение параметров адаптера) в левой части окна. Откроется новое окно “Network Connections” (сетевые подключения).
- Правым щелчком мыши по иконке “Microsoft Loopback Adapter” откройте всплывающее меню. В нём выберите пункт “Enable” (включить).
Теперь интерфейс loopback готов к работе.
Сетевые протоколы
В первой главе мы рассмотрели архитектуру типичной онлайн-игры. Как вы помните, в ней игровой клиент взаимодействует с сервером через сеть (в большинстве случаев это Интернет). Для передачи пакетов клиент вызывает функции WinAPI. ОС обрабатывает эти вызовы и отправляет указанные данные по сети. На аппаратном уровне для этого используется сетевая плата, функции которой доступны ОС благодаря драйверу устройства.
Возникает вопрос: как именно происходит передача данных по сети? Попробуем найти на него ответ вместе.
Задачи при передаче данных
Чтобы лучше понять существующие решения в какой-то технической области, будет разумным рассмотреть решаемые ими задачи. Представим, что мы с вами разработчики программ и нам поставили задачу передать данные игрового клиента на сервер через существующую сеть.
У нас есть два устройства, подключённых к сети как на иллюстрации 4-1. Они называются сетевыми хостами.
Самое прямолинейное и простое решение – реализовать алгоритм передачи данных целиком в игровом клиенте. Этот алгоритм может выглядеть следующим образом:
- Скопировать все состояния игровых объектов в байтовый массив. Такой массив называется сетевым пакетом.
- Скопировать подготовленный пакет в память, доступную для сетевой платы. Обычно эта память работает в режиме DMA.
- Дать плате команду на отправку пакета.
Наш алгоритм успешно справляется с передачей данных до тех пор, пока сеть состоит только из двух устройств. Но что произойдёт, если подключить третий хост как на иллюстрации 4-2?
В этому случае нам не обойтись без дополнительного устройства, известного как сетевой коммутатор (network switch). У обычной современной сетевой платы Ethernet есть только один порт. Она рассчитана на подключение точка-точка. Поэтому трёх сетевых плат просто не хватит для сети из трёх хостов. Конечно, можно установить несколько сетевых плат на каждый компьютер, но это будет слишком дорого. Сетевой коммутатор решает проблему. На данный момент будем рассматривать его, только как средство физического подключения нескольких хостов к одной сети.
После появления третьего устройства в сети возникла проблема. Каким-то образом необходимо различать хосты и направлять игровые данные от клиента на сервер, а не на телевизор. Вы можете возразить, что нет ничего плохого, если телевизор получит несколько ненужных ему пакетов. Он может их просто проигнорировать. Эта мысль верна до тех пор, пока наша сеть небольшая. Но что случится, если к ней подключатся сотни хостов? Если каждый узел будет посылать трафик для каждого, сеть окажется перегружена. Задержки в передаче пакетов станут настолько велики, что никакого эффективного взаимодействия между хостами не получится. Причина этого в том, что сетевые кабели и платы имеют ограниченную пропускную способность в силу аппаратных особенностей. С этим ресурсом нам следует работать осмотрительно.
Проблему различия хостов в сети можно решить, если каждому из них назначить уникальный идентификатор. Мы пришли к первому решению, которое приняли настоящие разработчики сетей. MAC-адрес – это уникальный идентификатор сетевой платы или другого передающего в сеть устройства. Этот адрес неизменный и назначается изготовителем на этапе производства устройства. Теперь наше игровое приложение может добавлять MAC-адрес целевого хоста к каждому передаваемому пакету. Благодаря этому сетевой коммутатор сможет перенаправлять пакет только на тот свой порт, к которому подключён целевой хост.
Откуда коммутатор знает MAC-адреса хостов подключённые к его портам? Для этого он следит за всеми входящими на каждый порт пакетами. Из них он читает MAC-адрес отправителя и добавляет его в таблицу разрешения адресов, также известную как Address Resolution Logic (ARL). В этой таблице каждая строка содержит MAC-адрес и соответствующий ему порт.
Когда сервер получит пакет клиента, он захочет подтвердить корректность принятых данных, либо в случае ошибки запросить повторной передачи. Для этого нужно знать MAC-адрес отправителя. Поэтому будет разумным при отправке пакета клиентом добавлять не только MAC-адрес целевого хоста, но и свой собственный.
Предположим, что наша сеть стала больше. Например, к ней подключены хосты, находящиеся в двух расположенных недалеко друг от друга зданиях. Каждое из них имеет собственную локальную сеть (или подсеть), состоящую для простоты из трёх компьютеров. Обе они объединены в единую сеть через маршрутизатор (router), как на иллюстрации 4-3.
На самом деле в каждой из двух локальных сетей могут быть десятки хостов. Если мы по-прежнему будем использовать MAC-адреса для указания целей пакетов, возникнут сложности. Каждый хост должен знать адреса всех получателей, с которыми он обменивается данными. Самое простое решение этой проблемы заключается в том, чтобы хранить список MAC-адресов всех хостов в сети на каждом из них. Тогда при подключении нового компьютера надо выполнить следующие действия:
- Добавить MAC-адрес нового хоста во все существующие списки.
- Скопировать исправленный список на новый хост.
Не забывайте также об исправлении списков адресов, когда один из хостов отключается. Очевидно, что вручную поддерживать эти списки в актуальном состоянии очень трудоёмко.
Вместо ручной правки и копирования списков можно написать алгоритм автоматического обнаружения хостов. Например, только что подключившийся к сети компьютер отправляет широковещательный запрос всем остальным. Любой, кто получает этот запрос, должен выслать свой MAC-адрес отправителю. Подобный механизм существует и известен как протокол определения адреса (Address Resolution Protocol или ARP). На самом деле ARP работает несколько сложнее. Когда какой-то хост хочет начать обмен данными, но не знает MAC-адрес получателя, он отправляет широковещательный запрос. В этом запросе указано (по IP-адресу о котором далее), кто именно должен на него ответить. Таким образом отвечает только тот хост, которого ищут.
Что означает термин “протокол” применительно к сетям? Это набор соглашений о формате данных. Например, наше приложение посылает игровые данные на сервер. Должны ли мы добавлять MAC-адреса отправителя и получателя в начале сетевого пакета или в конце? Если в начале – получатель должен знать об этом решении и интерпретировать первые байты пакета как адреса. Кроме того протокол определяет, как будут обрабатывать ошибки передачи данных. Например, сервер получает только половину отправленного клиентом пакета. Логично будет запросить его повторную передачу. Чтобы это сработало, клиент должен правильно понять сообщение от сервера о потере пакета. Спецификация протокола включает в себя все подобные нюансы взаимодействия сетевых хостов.
Вернёмся к нашей разросшейся сети. Очевидно, мы имеем некоторое дублирование данных, поскольку все хосты знают друг друга и должны хранить таблицу MAC-адресов в своей памяти. Протокол ARP помогает частично решить эту проблему. Благодаря ему актуальность таблиц будет поддерживаться динамически. Но их размер станет значительным, если сеть насчитывает десятки тысяч хостов. Было бы намного эффективнее, если бы только хосты одной подсети знали друг друга. При обмене данными между компьютерами из разных подсетей, маршрутизатор мог бы перенаправлять их пакеты. Таким образом хостам нужно будет знать только свою подсеть, частью которой является маршрутизатор.
Чтобы решить проблему с дублированием данных в таблицах, нам нужно что-то более гибкое чем MAC-адреса. Для передачи пакетов между подсетями был бы удобен механизм назначения хостам произвольных идентификаторов. Тогда мы могли бы назначить определённый диапазон “адресов” компьютерам одной подсети. Зная правило выбора диапазона, маршрутизатор мог бы быстро вычислять подсеть получателя по идентификатору и перенаправлять пакет. Мы говорим об уже существующем решении, известном как IP-адреса.
Теперь наше игровое приложение и сервер могут эффективно взаимодействовать, даже находясь в разных подсетях. Но что случится если мы запустим чат-программу на том же компьютере, где уже работает игровой клиент? Оба приложения должны посылать и принимать сетевые пакеты. Когда ОС получает пакет, указанные в нём IP- и MAC-адреса соответствуют текущему хосту. Однако, этой информации недостаточно, чтобы найти программу-получатель среди работающих в данный момент. Для решения этой проблемы нужно добавить некий идентификатор приложения. Он называется портом. В каждом сетевом пакете должны быть указаны порты приложения отправителя и получателя. Тогда ОС сможет гарантировать правильность передачи пакета ожидающему его процессу. Порт отправителя нужен, чтобы получатель смог ответить.
Возможно, вы уже заметили, что реализация нашего игрового приложения становится слишком сложной. Оно должно подготовить пакет, содержащий состояния игровых объектов, MAC-адреса, IP-адреса и порты. Также было бы полезно подсчитать контрольную сумму передаваемых данных и поместить её в тот же пакет. Приложение на стороне сервера должно иметь те же самые алгоритмы для кодирования и декодирования адресов, портов, игровых данных, а также подсчёта контрольной суммы. Эти алгоритмы выглядят достаточно универсальными. Любое приложение (например чат-программа или браузер) могло бы использовать их для передачи своих данных. В то же время каждый хост сети должен иметь эти алгоритмы. Лучшим решением будет поместить их в библиотеки ОС.
Мы пришли к решению, известному как стек протоколов. Этот термин означает реализацию набора сетевых протоколов. Слово “стек” используется, чтобы подчеркнуть иерархическую зависимость одних протоколов от других. Каждый из них относится к одному из уровней иерархии. При этом низкоуровневые протоколы предоставляют свои возможности для высокоуровневых. Например, стандарт IEEE 802.3 описывает правила передачи данных на физическом уровне по витой паре, а стандарт IEEE 802.11 - для беспроводной связи Wi-Fi. Протоколы уровней выше должны уметь передавать данные по обоим типам соединений. Это означает, что на каждом уровне может быть реализовано несколько взаимозаменяемых протоколов. В зависимости от требований пользователь может выбрать протокол подходящий для его задачи. Когда возникает разнообразие реализаций, крайне важно чётко определить обязанности каждого уровня. Именно для этого была создана сетевая модель OSI (Open Systems Interconnection).
Мы кратко рассмотрели основные решения современных сетевых коммуникаций. Теперь у нас достаточно знаний, чтобы изучить реальный стек протоколов, используемый сегодня в сети Интернет.
Стек протоколов TCP/IP
Почему мы собираемся рассмотреть стек TCP/IP, когда речь зашла об Интернете? Возможно, вы ожидали, что в самой большой сети на планете должен использоваться стек, строго построенный по OSI модели. Ведь на её создание у двух интернациональных комитетов (ISO и CCITT) ушло несколько лет. В результате они разработали хорошо продуманный стандарт, покрывающий все возможные требования по взаимодействию в сети.
Было несколько попыток применить модель OSI на практике и реализовать протоколы для каждого её уровня. Все эти проекты не увенчались успехом. Главная проблема заключается в том, что модель OSI избыточна. Многие её функции оказались не нужны при практическом применении. В результате сетевые пакеты содержали никем не используемые данные, а это лишние накладные расходы.
Ещё одна проблема модели заключается в частичном перекрытии обязанностей некоторых уровней. Как результат в сетевом пакете оказываются дублирующиеся данные, используемые разными протоколами. Алгоритмы для их обработки копируются, что приводит к увеличению объёма исполняемого кода. Это также негативно отражается на быстродействии. Разработчикам требуется больше усилий на написание и сопровождение стека протоколов. Всё это приводит к его удорожанию.
Пока велась работа над моделью OSI, два исследователя Роберт Кан и Винтон Серф создали стек протоколов TCP/IP. Это произошло на несколько лет раньше публикации стандарта OSI. Роберт и Винтон занимались конкретной практической задачей – передачей данных в сети ARPANET. Возможно, благодаря этому их решение оказалось эффективным и простым в реализации. Впоследствии этот стек был опубликован комитетом IEEE в качестве открытого стандарта, получившего название модель TCP/IP, в 1974 году. Модель OSI увидела свет только в 1984.
Сразу после публикации модели TCP/IP разработчики энтузиасты и компании начали реализовывать собственные версии стека для существовавших в то время ОС. Он оказался настолько прост, что программист в одиночку мог написать его за разумное время. Таким образом на большинстве работающих компьютеров появилась та или иная реализация стека и он стал стандартом де-факто сети Интернет.
В чём различие моделей OSI и TCP/IP? Обе они следуют принципу разделения задач, связанных с передачей данных, по нескольким уровням иерархии протоколов. Но в TCP/IP число этих уровней меньше: четыре против семи в модели OSI. Таблица 4-1 демонстрирует соответствие этих уровней.
| Уровень | OSI | TCP/IP |
|---|---|---|
| 7 | Прикладной (Application) | Прикладной (Application) |
| 6 | Представления (Presentation) | |
| 5 | Сеансовый (Session) | |
| 4 | Транспортный (Transport) | Транспортный (Transport) |
| 3 | Сетевой (Network) | Межсетевой (Internet) |
| 2 | Канальный (Data Link) | Канальный (Link) |
| 1 | Физический (Physical) |
Рассмотрим все уровни TCP/IP на примере реального сетевого пакета. Для этого воспользуемся анализатором трафика Wireshark. Скачайте и установите его на свой компьютер. После этого загрузите с Wiki ресурса Wireshark лог-файл с примером перехваченного Интернет-трафика. Откройте лог-файл http.cap в Wireshark. Диалог открытия файла можно вызвать по комбинации клавиш Ctrl+O. После этого окно анализатора должно выглядеть как на иллюстрации 4-4.
Окно анализатора разделено на три части. Верхняя из них представляет собой таблицу. Её горизонтальные ряды – это список перехваченных пакетов. Для каждого пакета в вертикальных столбцах приведена общая информация: адреса отправителя и получателя, время перехвата и т.д. Вы можете пролистать таблицу вниз и выбрать нужный пакет для вывода более подробной информации. Она отображается в средней части окна приложения. Здесь представлены заголовки всех протоколов, которые смог распознать Wireshark в этом пакете. Если вы выделите левым щелчком мыши один из заголовков, Wireshark подсветит соответствующие ему байты в нижней части окна. Более подробно интерфейс анализатора описан в официальной документации.
Мы рассмотрим пакет под номером четыре в лог-файле http.cap. Это типичный запрос браузера на загрузку веб-страницы из Интернета. Согласно таблице 4-1, в самом низу стека TCP/IP находятся протоколы канального уровня. Они отвечают за передачу пакетов по локальной сети. Как вы помните, в этом случае для обмена пакетами отправитель и получатель должны знать MAC-адреса друг друга. Этой информации будет достаточно для сетевого коммутатора, чтобы перенаправить пакет по назначению.
Согласно информации от Wireshark, отправитель четвёртого пакета в логе использует Ethernet II в качестве протокола канального уровня. Его заголовок идёт сразу после строчки “Frame” (кадр) в средней части окна анализатора. Если развернуть этот заголовок левым щелчком мыши по треугольнику рядом с ним, Wireshark отобразит содержащуюся в нём информацию: MAC-адреса получателя и отправителя. Кроме них, есть поле “Type” (тип) размером два байта. Оно содержит идентификатор протокола следующего уровня, который использовал отправитель. Заголовки протоколов следуют друг за другом в пакете. При этом можно рассматривать каждый протокол как контейнер содержащий заголовок и данные протокола следующего уровня. В нашем случае поле “Type” равно 0x0800, что соответствует протоколу IP версии 4 (Internet Protocol Version 4 или IPv4).
IPv4 соответствует межсетевому уровню модели TCP/IP. Он отвечает за маршрутизацию пакетов между сетями. Самая важная информация его заголовка – это IP-адреса отправителя и получателя. Основываясь на них, маршрутизатор выбирает целевую сеть для передачи пакета. Чтобы прочитать эти адреса, разверните заголовок “Internet Protocol Version 4”. Кроме них есть несколько полей, информация которых также нужна для корректной маршрутизации. Например, поле “Time to live” (время жизни) определяет максимальное время, в течение которого пакет может передаваться по сети. Если оно оказалось превышено, первый маршрутизатор, получивший такой пакет, заблокирует его. Поля “Identification” (идентификатор) и “Fragment offset” (смещение фрагмента) хранят информацию, необходимую для механизма фрагментации. Он позволяет делить пакет на части (называемые фрагментами) и передавать их по отдельности через сеть. Такое разделение позволяет балансировать нагрузку в сети. Передача слишком больших пакетов увеличивает цену ошибки. Если один бит данных окажется искажённым в процессе передачи, весь пакет придётся отправлять повторно. Маленькие пакеты приводят к увеличению накладных расходов, т.е. уменьшится отношение полезной нагрузки (передаваемые данные) к служебной информации (заголовки протоколов). Последнее поле IPv4-заголовка называется “Protocol” (протокол). В нём хранится идентификатор протокола следующего уровня. В нашем случае – это протокол управления передачей (Transmission Control Protocol или TCP).
Протоколы транспортного уровня обеспечивают соединение между взаимодействующими по сети процессами, запущенными на разных хостах. Самая важная информация для этого соединения – номера портов, которые позволяют идентифицировать процессы отправителя и получателя. Разверните заголовок “Transmission Control Protocol” в окне Wireshark, чтобы прочитать значения “Source Port” (порт отправителя в нашем случае равен 3372) и “Destination Port” (порт получателя – 80). Кроме них, в заголовке есть поля “Sequence number” (порядковый номер) и “Acknowledgment number” (номер подтверждения). Эти номера нужны для установки соединения и обнаружения потерянных пакетов.
Сегодня в сети Интернет чаще других встречаются два протокола транспортного уровня: TCP и протокол пользовательских датаграмм (User Datagram Protocol или UDP). Основное различие между ними заключается в надёжности передачи данных. Протокол TCP имеет механизм проверки того, что все отправленные пакеты дошли до получателя. Если какой-то пакет был потерян, получатель просит его передать повторно. В протоколе UDP такого механизма нет. Получатель не проверяет последовательность входящих пакетов, а просто игнорирует потери.
Зачем может понадобиться такой ненадёжный протокол, как UDP? Наряду со всеми достоинствами у протокола TCP есть один существенный недостаток. Механизм обнаружения потерянных пакетов может привести к задержкам в передаче пакетов. В некоторых случаях такие задержки неприемлемы.
Для примера рассмотрим отправку и получение по сети видеопотока. В этом случае потеря одного кадра несущественна, поскольку воспроизведение видео можно продолжить со следующего. Однако, если мы используем протокол TCP, ОС запросит повторную отправку потерянного сетевого пакета. Тогда отправитель вместо следующего кадра будет пересылать потерянный. Это приведёт к остановкам при воспроизведении видео, поскольку у приложения видеопроигрывателя не будет нужного в данный момент кадра. Если же для передачи видеопотока применить протокол UDP, зависаний удастся избежать. При этом очень вероятно, что пользователь вообще не заметит потерянные кадры.
На самом верхнем уровне модели TCP/IP находятся прикладные протоколы. Их формат произволен, и разработчики программ могут выбирать его по своему усмотрению. Таким образом, порядок и значение байтов этой части сетевых пакетов целиком зависит от взаимодействующих приложений.
В нашем примере в качестве прикладного протокола используется протокол передачи гипертекста (Hypertext Transfer Protocol или HTTP). Данные этого протокола передаются в виде текста, который можно прочитать в нижней части окна Wireshark. Хост-отправитель пакета запрашивает у веб-сервера с единым указателем ресурса (Uniform Resource Locator или URL) “www.ethereal.com” страницу под названием “download.html”. URL, также известный как веб-адрес, представляет собой псевдоним для IP-адреса. Он был введён в употребление, чтобы упростить использование всемирной паутиной (World Wide Web или WWW). Благодаря URL пользователям нужно запоминать не IP-адреса, а названия сайтов.
Перехват трафика
Мы рассмотрели протоколы, используемые в сети Интернет. Теперь познакомимся с методом перехвата сетевого трафика двух взаимодействующих процессов, работающих на разных хостах. Анализ трафика игрового приложения – первый шаг при разработке внеигрового бота.
Тестовое приложение
Для начала напишем простое приложение, которое передаёт по сети несколько байтов. Оно состоит из двух частей: клиент и сервер. Благодаря интерфейсу loopback мы можем запустить их на одном компьютере и сымитировать передачу данных по сети. С помощью Wireshark перехватим этот трафик.
Перед тем как начать писать код, рассмотрим ресурс операционной системы, известный как сетевой сокет (network socket). Именно он предоставляет приложению функции ОС для передачи сетевых пакетов.
Понятие сокета тесно связано с портом и IP-адресом. Как вы помните, порты отправителя и получателя указаны в заголовках протоколов TCP и UDP. Благодаря им ОС доставляет пакет тому процессу, который его ожидает.
Предположим, вы запускаете игровой клиент и чат-программу на своём компьютере. Что произойдёт если оба приложения решат использовать один и тот же сетевой порт для связи со своими серверами? В теории, каждая программа может выбрать порт по своему усмотрению. Чтобы предотвратить конфликты такого выбора, будет разумно зарезервировать некоторые порты для широко распространённых приложений. Это решение уже существует. Есть три диапазона портов:
- Общеизвестные или системные от 0 до 1023. Эти порты используются процессами ОС, которые предоставляют широко распространённые сетевые сервисы.
- Зарегистрированные или пользовательские от 1024 до 49151. Они частично зарезервированы за конкретными приложениями и сервисами администрацией адресного пространства Интернет (IANA).
- Динамические или частные от 49152 до 65535. Представляют собой незарезервированные порты, которые могут быть использованы для любых целей.
Очевидно, что кто-то должен контролировать использование портов запущенными приложениями. Эту функцию выполняет ОС. Когда процесс хочет воспользоваться конкретным портом, он запрашивает у ОС сетевой сокет. Сокет – это абстрактный объект, представляющий собой конечную точку сетевого соединения. Этот объект содержит следующую информацию: IP-адрес, номер порта, состояние соединения. Как правило, приложение владеет сокетом и использует его монопольно. Когда он становится не нужен, его освобождают (release).
Вид сокета зависит от комбинации используемых протоколов. В наших примерах мы будем применять только пары: IPv4 и TCP, IPv4 и UDP.
Наше первое приложение отправляет один пакет данных по протоколу TCP. Оно состоит из двух Python-скриптов: TestTcpReceiver.py (см. листинг 4-1) и TestTcpSender.py (см. листинг 4-2). Алгоритм их работы следующий:
- Скрипт
TestTcpReceiver.pyзапускается первый. Он создаёт TCP-сокет, привязанный (bind) к порту 24000 и IP-адресу 127.0.0.1, известному как localhost (локальный хост). Такая конфигурация называется TCP-сокет сервера. - Скрипт
TestTcpReceiver.pyзапускает цикл ожидания запроса на установку соединения через открытый им сокет. Говорят, что скрипт слушает (listen) порт 24000. - Запускается скрипт
TestTcpSender.py. Он открывает TCP-сокет, но не привязывает его к какому-либо порту или IP-адресу. Эта конфигурация называется TCP-сокет клиента. - Скрипт
TestTcpSender.pyустанавливает соединение с сокетом получателя по IP-адресу 127.0.0.1 и порту 24000. После этого он отправляет пакет данных. ОС самостоятельно выбирает IP-адрес и порт отправителя, т.е. скриптTestTcpSender.pyне может выбрать их по своему усмотрению. После отправки пакета, скрипт освобождает свой сокет. - Скрипт
TestTcpReceiver.pyпринимает запрос от отправителя на установку соединения, получает пакет данных, выводит их в консоль и освобождает свой сокет.
Рассмотренный нами алгоритм выглядит простым и прямолинейным. Однако, некоторые шаги по установке и разрыву TCP-соединения скрыты от пользователя и выполняются ОС автоматически. Мы увидим их, если перехватим и просмотрим трафик приложения в Wireshark.
TestTcpReceiver.py 1 import socket
2
3 def main():
4 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
5 s.bind(("127.0.0.1", 24000))
6 s.listen(1)
7 conn, addr = s.accept()
8 data = conn.recv(1024, socket.MSG_WAITALL)
9 print(data)
10 s.close()
11
12 if __name__ == '__main__':
13 main()
Скрипт TestTcpReceiver.py использует модуль socket, который предоставляет доступ к сокетам ОС. Алгоритм скрипта реализован в функции main. Рассмотрим её подробнее. Сначала вызывается функция socket модуля socket. Она создаёт новый объект для сокета. У неё есть три входных параметра:
1. Набор протоколов, который будет использован при установке соединения. Наиболее часто выбираемые варианты:
* AF_INET (IPv4)
* AF_INET6 (IPv6)
* AF_UNIX (локальное соединение)
2. Тип сокета. Может быть одним из следующих вариантов:
* SOCK_STREAM (TCP)
* SOCK_DGRAM (UDP)
* SOCK_RAW (без указания протокола транспортного уровня)
3. Номер протокола. Он используется, когда для указанного набора протоколов и типа сокета возможны несколько вариантов. В большинстве случаев этот параметр равен 0.
Мы создали сокет, использующий протоколы IPv4 и TCP, а затем поместили его в переменную с именем s. Следующий шаг нашего скрипта – привязать сокет к конкретному IP-адресу и порту с помощью метода bind объекта s. Затем с помощью метода listen запускаем цикл ожидания входящего соединения. Единственный входной параметр listen определяет максимальное число попыток установить соединение. В этой точке скрипт TestTcpReceiver.py останавливает своё выполнение, потому что вызов listen не возвращает управление, пока соединение не установлено.
Когда скрипт TestTcpSender.py пытается установить соединение, TestTcpReceiver.py принимает его через вызов метода accept. Этот метод возвращает два значения: объект соединения, пару IP-адрес и порт отправителя. Мы сохраняем их в переменные conn и addr соответственно. Для чтения данных из принятого пакета мы вызываем метод recv объекта conn. Затем печатаем их на консоль с помощью функции print.
Последним действием функции main освобождаем сокет через вызов его метода close. После этого ОС помечает ресурс как свободный. Теперь другое приложение может слушать TCP порт 24000.
Листинг 4-2 демонстрирует реализацию скрипта TestTcpSender.py.
TestTcpSender.py 1 import socket
2
3 def main():
4 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
5 s.settimeout(2)
6 s.connect(("127.0.0.1", 24000))
7 s.send(bytes([44, 55, 66]))
8 s.close()
9
10 if __name__ == '__main__':
11 main()
Здесь мы создаём такой же объект s для сокета, использующего протоколы IPv4 и TCP. Затем через метод settimeout устанавливаем двухсекундный тайм-аут на все операции с сокетом. Если сервер не ответит в течение этого времени на любой запрос клиента, будет сгенерировано исключение. Оно не обрабатывается в нашем скрипте, поэтому просто приведёт к его завершению.
Следующий шаг – установка соединения через вызов метода connect. В качестве входного параметра он получает пару: IP-адрес и порт сервера. В Python для объединения двух значений в пару используются круглые скобки. Метод connect возвращает управление сразу после успешной установки соединения. Теперь мы готовы к отправке пакета с данными. Для этого вызываем метод send. В примере отправляются три байта со значениями: 44, 55, 66. В конце функции main освобождаем сокет.
Перед запуском примера, необходимо проверить IP-адрес вашего интерфейса loopback. Для этого выполните следующие шаги:
- Откройте окно “Network Connections” (сетевые подключения).
- Правым щелчком мыши по иконке “Microsoft Loopback Adapter” откройте всплывающее меню и выберите пункт “Status” (состояние).
- Нажмите кнопку “Details…” (сведения). Откроется окно “Network Connection Details” (сведения о сетевом подключении), в котором указан IPv4-адрес.
Если этот адрес отличается от 127.0.0.1, добавьте его в оба скрипта. В TestTcpReceiver.py нужно поправить вызов метода bind, а в TestTcpSender.py – вызов connect.
Лучше запускать оба скрипта в командной строке. Тогда вы сможете прочитать их выводы. Получатель должен напечатать три байта, переданных через интерфейс loopback.
Перехват пакета
Перехватим и проанализируем трафик нашего тестового приложения с помощью Wireshark. Для этого выполните следующие действия:
1. Запустите Wireshark. В главном окне анализатора отобразится список сетевых интерфейсов, как на иллюстрации 4-5.
- Двойным щелчком левой кнопки мыши выберите интерфейс loopback в списке. Его имя вы можете уточнить в окне “Network Connections” (сетевые подключения). После выбора интерфейса, Wireshark сразу начнёт перехватывать проходящие через него пакеты.
- Запустите скрипт
TestTcpReceiver.py. - Запустите скрипт
TestTcpSender.py.
В окне Wireshark вы увидите список перехваченных пакетов, как на иллюстрации 4-6.
Как правило, перехват трафика на сетевом интерфейсе нужен, чтобы отследить работу одного конкретного приложения. К сожалению, этим интерфейсом в то же самое время могут пользоваться сервисы ОС (например, менеджер обновлений) и другие приложения (например, браузер). Их пакеты также попадут в список перехваченных Wireshark. Чтобы исключить их из списка, анализатор предоставляет возможность фильтрации.
Под панелью иконок находится строка для ввода текста. Когда она пустая, в ней выводится серый текст: “Apply a display filter …” (применить фильтр отображения). В эту строку вы можете ввести правила фильтрации пакетов. Чтобы применить правила, нажмите иконку в виде стрелки слева от кнопки “Expression…” (выражение). После этого в списке перехваченных пакетов отобразятся только те, которые удовлетворяют условиям фильтрации.
Чтобы отобразить только пакеты нашего тестового приложения, применим следующий фильтр:
1 tcp and ip.addr==127.0.0.1 and tcp.port==24000
Он состоит из трёх условий. Первое из них представляет собой единственное слово “tcp”. Оно означает, что следует отобразить только пакеты, использующие протокол TCP. Второе условие “ip.addr==127.0.0.1” проверяет IP-адрес отправителя и получателя. Если любой из них равен 127.0.0.1, пакет попадёт в список для отображения. Последнее условие “tcp.port==24000” ограничивает TCP-порты отправителя и получателя. Пакет будет отображён, если любой из них равен 24000.
Для комбинации правил в единый фильтр используется служебное слово “and” (И). Оно означает, что отобразятся пакеты, для которых выполняются все три условия одновременно. Другие часто используемые служебные слова: “or” (ИЛИ) и “not” (НЕ). Первое означает, что пакет будет отображён если хотя бы одно из указанных условий выполнено. Второе слово инвертирует условие. Служебные слова подробно описаны в официальной документации.
Указывать правила для фильтрации пакетов можно двумя способами: набирать текст условий в поле ввода (как мы сделали ранее) либо использовать диалог “Display Filter Expression” (фильтр отображения), приведённый на иллюстрации 4-7. Чтобы его открыть, нажмите кнопку “Expression…”.
В левой части диалога находится список “Field Name” (название поля) всех поддерживаемых протоколов и полей их заголовков. В списке “Relation” (отношение) приведены операторы отношения, с помощью которых вы можете накладывать ограничения на значения полей. Под ним находится поле ввода “Value” (значение), в котором указывается значение для сравнения. В нижней части диалога есть поле с получившимися правилами фильтрации в текстовой форме. На иллюстрации 4-7 это поле подсвечено зелёным цветом. Если в фильтре ошибка, цвет поменяется на красный.
Механизм фильтрации – это мощный инструмент, помогающий анализировать лог-файлы с перехваченным трафиком. Используйте его как можно чаще, чтобы ускорить свою работу с Wireshark.
Вернёмся к перехваченным пакетам нашего тестового приложения на иллюстрации 4-6. Почему в списке оказалось восемь пакетов, хотя наше приложение посылает один? Передача данных происходит только в пакете номер 13. Остальные, переданные до него (с номерами 10, 11, 12), нужны, чтобы установить TCP-соединение. Этот процесс известен как тройное рукопожатие (three-way handshake). Он состоит из следующих шагов:
- Клиент (скрипт
TestTcpSender.py) отправляет первый пакет (номер 10) на сервер. В TCP-заголовке этого пакета установлен флаг SYN, а sequence number или seq (порядковый номер) равен 0. Это означает, что клиент хочет установить соединение. Следующий фильтр отобразит в окне Wireshark только SYN пакеты:
1 tcp.flags.syn==1 and tcp.seq==0 and tcp.ack==0
- Сервер (скрипт
TestTcpReceiver.py) отвечает пакетом номер 11, в котором установлены флаги SYN и ACK. Кроме них в пакете передаётся acknowledgment number или ack (номер подтверждения), равный seq, полученный от клиента, плюс один. Таким образом подтверждается seq клиента. Также сервер передаёт клиенту собственный seq, равный 0. Чтобы отобразить ответы сервера на установку соединения, используйте следующий фильтр:
1 tcp.flags.syn==1 and tcp.flags.ack==1 and tcp.seq==0 and tcp.ack==1
3. Клиент отвечает пакетом номер 12 с установленным флагом ACK. Его ack-номер, равный единице, подтверждает seq сервера. После этого шага обе стороны подтвердили свои seq номера и готовы к взаимодействию. Следующий фильтр отображает ответ клиента:
1 tcp.flags.syn==0 and tcp.flags.ack==1 and tcp.flags.push==0 and tcp.seq==1 and tcp.a\
2 ck==1
Подробнее состояния клиента и сервера в процессе установки соединения рассмотрены в следующей статье.
Возможно, вы заметили, что в последнем фильтре для ответа клиента мы проверяем значение флага PUSH. Если этот флаг установлен в единицу, пакет содержит данные, отправленные приложением. Вы можете инвертировать условие, чтобы отобразить только эти пакеты:
1 not tcp.flags.push==0
Если вы хотите прочитать данные, отправленные нашим тестовым приложением, выделите пакет под номером 13 с установленным в единицу флагом PUSH. Затем щёлкните левой кнопкой мыши по пункту “Data” (данные) в списке заголовков. В результате в нижней части окна Wireshark синим цветом будут выделены соответствующие байты пакета, как на иллюстрации 4-8.
Тестовое приложение передаёт три байта, которые в шестнадцатеричной системе равны 2C, 37, 42. Если перевести эти числа в десятичную систему, получим: 44, 55, 66. Вы можете удостовериться в листинге 4-2, что именно эти три байта передаёт скрипт TestTcpSender.py.
Вы могли заметить на иллюстрации 4-6, что пакет с номером 14, следующий за передачей данных, имеет ack-номер равный четырём. Что означает это число? После установки соединения номера seq и ack используются для подтверждения числа байтов данных, полученных сервером от клиента. Следовательно, когда сервер получает данные, он отвечает пакетом с ack-номером, рассчитанным по формуле:
1 ack ответа = seq клиента + размер данных
В случае 14-ого пакета из нашего лог-файла, расчёт номера ack выглядит следующим образом:
1 ack = 1 + 3 = 4
Номер seq для этой формулы можно уточнить в последнем отправленном клиентом пакете с установленным флагом PUSH. В нашем случае это пакет с номером 13.
Иллюстрация 4-9 демонстрирует пример, когда клиент передаёт не один пакет данных, а несколько. В столбце Info вы можете проследить увеличение номеров ack и seq. Каждый пакет с подтверждением от сервера имеет ack, рассчитанный по рассмотренной выше формуле.
Обратите внимание, что клиент всегда посылает свои пакеты на целевой порт 24000. Порт отправителя равен 35936 на иллюстрации 4-9 и 32978 на иллюстрации 4-6. Как вы помните, ОС назначает его клиенту каждый раз, когда тот пытается установить новое соединение. Номер порта выбирается случайным образом, и его невозможно предсказать. Поэтому в условиях фильтрации пакетов лучше всегда проверять порт TCP-сервера, а не клиента.
Вернёмся к иллюстрации 4-6, на которой приведён TCP-трафик для передачи одного пакета данных. После его получения сервер отправляет пакет номер 14 с подтверждением. Затем следуют три пакета с номерами 15, 16 и 17 для закрытия TCP-соединения:
1. Клиент отправляет пакет номер 15, в котором установлен флаг FIN. Таким образом он запрашивает разрыв соединения. В нашем случае в этом пакете также установлен флаг ACK. С его помощью клиент подтверждает получение от сервера пакета номер 14, seq которого равен 1. Чтобы отобразить только этот пакет в Wireshark, примените следующий фильтр:
1 tcp.flags.fin==1 and tcp.dstport==24000
2. Сервер отвечает пакетом номер 16, в котором установлены флаги FIN и ACK. Его номер ack равен пяти, т.е. номеру seq клиента плюс один. Теперь флаг ACK означает, что сервер подтверждает получение FIN пакета. С помощью флага FIN сервер просит клиента закрыть соединение на своей стороне. Фильтр для отображения этого пакета следующий:
1 tcp.flags.fin==1 and tcp.srcport==24000
- Клиент отвечает пакетом номер 17 с установленным флагом ACK. Он подтверждает получение запроса сервера на закрытие соединения. Номер seq этого пакета равен номеру ack последнего пакета (номер 16) от сервера. Фильтр для отображения:
1 tcp.flags.ack==1 and tcp.seq==5 and tcp.dstport==24000
Обратите внимание, что в этом фильтре мы проверяем номер seq пакета для того, чтобы найти последний пакет от клиента с установленным флагом ACK.
Подробнее закрытие TCP-соединения рассмотрено в статье.
UDP-соединение
Мы рассмотрели тестовое приложение, которое передаёт данные по протоколу TCP. Познакомились с основными принципами его работы и знаем как перехватить и проанализировать такой вид трафика. Однако, многие онлайн-игры используют протокол UDP вместо TCP.
Перепишем наше тестовое приложение так, чтобы оно использовало протокол UDP. В этом случае его алгоритм будет выглядеть следующим образом:
- Скрипт
TestUdpReceiver.py(из листинга 4-3) запускается первым. Он открывает UDP-сокет и привязывает (bind) его к порту 24000 и IP-адресу 127.0.0.1. UDP-сокеты, в отличие от TCP, равноправны. Это значит, что любой из них может отправлять данные в произвольный момент времени. Процедур установки и разрыва соединения нет. - Скрипт
TestUdpReceiver.pyожидает входящего пакета от отправителя. - Скрипт
TestUdpSender.py(из листинга 4-4) запускается вторым. Он открывает UDP-сокет и привязывает его к порту 24001 и адресу localhost. Последний шаг необязателен. Тогда ОС назначит произвольный порт отправителю UDP-пакетов. Однако, явная привязка к порту может быть полезной, если понадобится передавать данные в обоих направлениях. - Скрипт
TestUdpSender.pyотправляет пакет данных, после чего освобождает свой сокет. - Скрипт
TestUdpReceiver.pyполучает пакет, выводит на консоль его содержимое и освобождает свой сокет.
Как видите, алгоритм тестового приложения стал проще, по сравнению с использованием протокола TCP. Нет необходимости устанавливать и разрывать соединение. Приложение только отправляет единственный пакет с данными.
TestUdpReceiver.py 1 import socket
2
3 def main():
4 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
5 s.bind(("127.0.0.1", 24000))
6 data, addr = s.recvfrom(1024, socket.MSG_WAITALL)
7 print(data)
8 s.close()
9
10 if __name__ == '__main__':
11 main()
Алгоритм этого скрипта похож на TestTcpReceiver.py. В отличие от него, здесь нет цикла ожидания и установки соединения. В качестве типа сокета s указан SOCK_DGRAM, который соответствует протоколу UDP. Для получения пакета используется метод recvfrom объекта s. В отличие от метода recv TCP-сокета, он возвращает пару значений: принятые данные и IP-адрес отправителя. Поскольку для UDP не устанавливается соединения, мы не вызываем метод accept. Поэтому IP-адрес отправителя можно получить только через вызов recvfrom. Если этот адрес не важен, можно использовать метод recv, как и в случае TCP.
TestUdpSender.py 1 import socket
2
3 def main():
4 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
5 s.bind(("127.0.0.1", 24001))
6 s.sendto(bytes([44, 55, 66]), ("127.0.0.1", 24000))
7 s.close()
8
9 if __name__
10 main()
В скрипте TestUdpSender.py мы так же указываем тип сокета SOCK_DGRAM при создании его объекта s. Нам не нужен тайм-аут на операции с сокетом, поскольку протокол UDP не предполагает подтверждений для передаваемых пакетов. Вместо этого мы просто отправляем данные и освобождаем сокет.
Запустите Wireshark и начните перехват трафика на интерфейс loopback. После этого запустите скрипты TestUdpReceiver.py и TestUdpSender.py. Вы должны получить результат, приведённый на иллюстрации 4-10.
Если вы видите несколько пакетов в списке перехваченных Wireshark, примените следующий фильтр:
1 udp.port==24000
Вы увидите единственный пакет, содержащий три байта данных: 2C, 37, 42.
Пример бота для NetChess
Мы узнали достаточно, чтобы написать простого внеигрового бота. Он будет делать ходы в шахматной программе NetChess. Эта программа состоит из клиентской и серверной частей. Она позволяет играть двум пользователям по локальной сети. Вы можете бесплатно скачать её на сайте SourceForge. Чтобы установить игру, просто распакуйте архив с ней в любой каталог.
Рассмотрим интерфейс игры. Её главное окно изображено на иллюстрации 4-11. Большую его часть занимает шахматная доска с фигурами. Главное меню находится в верхней области окна. Ряд иконок под меню дублирует некоторые из его функций.
Чтобы начать игру, необходимо запустить приложение NetChess и назначить ему роль сервера. После этого второй игрок запускает приложение на другом компьютере и настраивает его на роль клиента. Он подключается к серверу, и игра начинается. Благодаря интерфейсу loopback мы можем запустить клиент и сервер на одном хосте.
Чтобы запустить NetChess и начать игру, выполните следующие действия:
- Дважды запустите исполняемый файл
NetChess2.1.exeиз каталогаDebugигры. В результате откроется два окна NetChess, соответствующие двум процессам. Выберите, кто из них будет выполнять роль сервера. - Переключитесь на окно сервера и выберите пункт меню “Network” -> “Server” (“Сеть” -> “Сервер”). Откроется диалог конфигурации приложения в роли сервера, как на иллюстрации 4-12.
- Введите имя пользователя, который играет на стороне сервера, и нажмите кнопку “OK”.
- Переключитесь на окно приложения NetChess, выполняющее роль клиента. Выберите пункт меню “Network” -> “Client” (“Сеть” -> “Клиент”). Откроется диалог конфигурации клиента, как на иллюстрации 4-13.
- Введите имя пользователя на стороне клиента и IP-адрес сервера (в моём случае это 169.254.144.77). Затем нажмите кнопку “OK”.
- Переключитесь на окно сервера. Когда клиент попытается подключиться, должен открыться диалог “Accept” (принять), как на иллюстрации 4-14. В нём выберите цвет фигур (чёрный, белый, случайный). После этого нажмите кнопку “Accept” (принять).
- Переключитесь на окно клиента. Вы увидите сообщение об успешном подключении к серверу. В нём выводится имя оппонента и цвет его фигур (см иллюстрацию 4-15).
- Переключитесь на окно сервера и выберите пункт меню “Edit” -> “Manual Edit” -> “Start Editing” (“Редактирование” -> “Ручное редактирование” -> “Начать редактирование”). Откроется диалог с подтверждением, в котором вы должны нажать кнопку “Yes” (да). После этого приложение позволит вам запустить игровые часы.
- Переключитесь на окно клиента и подтвердите включение режима “Manual Edit” в открывшемся диалоге. Для этого нажмите кнопку “Yes”.
- Переключитесь на окно сервера. Вы увидите сообщение, что клиент подтвердил включение режима “Manual Edit”. Закройте его нажатием кнопки “OK”. Затем уберите галочку с пункта меню “Edit” -> “Manual Edit” -> “Pause clock” (“Редактирование” -> “Ручное редактирование” -> “Остановить часы”).
Игровые часы запустятся, и белая сторона может сделать первый ход. Для этого достаточно перетащить мышкой нужную фигуру на другую клетку доски.
Обзор бота
Наш внеигровой бот будет подключаться к серверу и полностью замещать собой приложение NetChess, выполняющее роль клиента.
У бота есть много способов выбрать свой ход. Предлагаю остановиться на самом простом решении. Ведь мы рассматриваем взаимодействие с игровым сервером, а не алгоритмы шахматных программ. Наш бот будет зеркально повторять ходы игрока до тех пор, пока это позволяют правила игры. Задача выглядит достаточно простой, но потребует изучения протокола NetChess.
Приложение NetChess распространяется с открытым исходным кодом. Вы можете изучить код и быстро разобраться в протоколе приложения. Мы выберем другой путь. Давайте предположим, что NetChess – проприетарная игра и её исходный код недоступен. Для исследования у нас есть только перехваченный сетевой трафик между клиентом и сервером.
Изучение трафика NetChess
Мы рассмотрели шаги, необходимые для установки соединения между клиентом и сервером NetChess, а также чтобы начать игру. Теперь мы можем перехватить трафик и найти сетевые пакеты, соответствующие каждому из этих шагов. Но сначала рассмотрим два важных вопроса.
Как мы будем отличать трафик NetChess от остальных приложений в Wireshark логе? Если бы мы использовали сетевую плату вместо интерфейса loopback, в лог попали бы пакеты всех работающих в данный момент сетевых приложений. Но пакеты NetChess мы можем отличить по номеру порта. Мы указали его при настройке серверной части приложения. По умолчанию он равен 55555. Применим следующее условие проверки порта в качестве Wireshark фильтра:
1 tcp.port==55555
Теперь в логе будет выводиться только трафик NetChess.
Следующий вопрос: как именно следует перехватывать трафик? Можно просто запустить Wireshark, начать прослушивать интерфейс loopback и сыграть несколько игр подряд. Поступив так, мы потеряем важную информацию, которая очень пригодилась бы для изучения трафика. В Wireshark логе, собранном по нескольким играм, будет сложно различить отдельные ходы каждой стороны. Например, какой именно пакет соответствует первому ходу белых? В логе накопилось более ста пакетов, а мы не можем даже сказать, когда начиналась каждая игра. Чтобы избежать этого затруднения, будем проверять Wireshark лог сразу после каждого совершённого действия. В этом случае мы легко отличим соответствующие ему пакеты.
Теперь запустите Wireshark, NetChess клиент и сервер. Начните прослушивание интерфейса loopback в анализаторе. После этого выполните следующие действия:
- Запустите NetChess в режиме сервера (настройка “Network” -> “Server”). После этого действия приложение только открывает сокет. Поэтому в логе Wireshark новых пакетов не появится.
- Подключитесь клиентом NetChess к серверу (настройка “Network” -> “Client”). В Wireshark окне появятся три пакета, как на иллюстрации 4-16. Это установка TCP-соединения через тройное рукопожатие.
- Сервер принимает соединение клиента. После этого анализатор перехватит два пакета, отправленные сервером. На иллюстрации 4-17 их номера 22 и 24. Клиент подтверждает их получение и сам посылает два пакета с данными (их номера 26 и 28).
Остановимся на этом шаге и рассмотрим только что перехваченные пакеты. Первый пакет от сервера под номером 22 содержит следующие данные:
1 0f 00 00 00
Попробуйте перезапустить клиент и сервер NetChess. После этого снова установите соединение между ними. Данные, передаваемые первым пакетом не изменятся. Вероятнее всего, на прикладном уровне модели TCP/IP они означают, что сервер принял соединение клиента. Чтобы проверить это предположение, попробуйте на стороне сервера отклонить подключение клиента. В этом случае данные пакета изменятся на следующие:
1 01 00 00 00
Из этого следует, что наша гипотеза верна. Приняв соединение, сервер отвечает первым байтом 0f. Иначе в ответе будет 01.
Второй пакет от сервера с номером 24 содержит следующие байты данных:
1 0b 02 46 6d e7 5a 73 72 76 5f 75 73 65 72 00
В моём случае игрок на стороне сервера выбрал белые фигуры и ввёл имя “srv_user”. Wireshark способен частично декодировать эти данные. Согласно иллюстрации 4-18, байты с 7-ого по 15-ый соответствуют имени пользователя.
Что означают первые шесть байтов в ответе сервера? Перезапустите приложение и заставьте его отправить этот пакет снова. Не забудьте выбрать то же имя пользователя “srv_user” и белые фигуры на стороне сервера. Благодаря этому уже известные нам байты данных не изменятся.
После перезапуска NetChess, у меня получились следующие данные в пакете:
1 0b 02 99 b3 ee 5a 73 72 76 5f 75 73 65 72 00
Обратите внимание, что первые два байта (0b и 02) не изменились. Скорее всего, в них закодирован цвет фигур, который выбрал игрок на стороне сервера. Попробуйте перезапустить NetChess и выбрать сторону чёрных. Данные этого пакета поменяются:
1 0b 01 ba 45 e8 5a 73 72 76 5f 75 73 65 72 00
Если повторить тест с выбором чёрных фигур несколько раз, второй байт всегда будет равен 01. Это подтверждает наше предположение. Цвет фигур игрока на стороне сервера кодируется согласно таблице 4-2. Эта информация может оказаться полезной для бота.
| Байт | Цвет |
|---|---|
| 01 | Чёрный |
| 02 | Белый |
Следующие два пакета с данными отправляются клиентом. Первый из них под номером 26 содержит байты:
1 09 00 00 00
Они не изменятся, если мы перезапустим приложение и попробуем поменять имя игрока на стороне сервера или цвет его фигур. Поэтому предположительно это неизменный ответ клиента.
Следующий пакет под номером 28 содержит данные:
1 0c 63 6c 5f 75 73 65 72 00
Wireshark декодирует эти байты, начиная со второго, как имя игрока на стороне клиента (см. иллюстрацию 4-19). Значение первого байта неясно. Оно не меняется после перезапуска приложения. Бот может обращаться с ним как с константой и всегда включать в свой ответ серверу.
Продолжим действия в приложении NetChess, необходимые для начала игры. Включим режим “Manual Edit” на стороне сервера (“Edit” -> “Manual Edit” -> “Start Editing”). После этого сервер отправляет два пакета клиенту.
Первый пакет под номером 41 на иллюстрации 4-20 содержит следующие данные:
1 0a 00 00 00
Вероятнее всего, первый байт 0a соответствует коду запроса сервера. Данные второго пакета под номером 43 выглядят так:
1 13 73 72 76 5f 75 73 65 72 00
Мы уже встречали набор байтов со 2-ого по 9-ый и знаем, что он соответствует строке “srv_user”. Первый же байт со значением 13 не меняется и наш бот может его игнорировать.
Когда клиент подтверждает включение режима “Manual Edit”, он отправляет два пакета с номерами 45 и 47 на иллюстрации 4-20. Их данные следующие:
1 01 00 00 00
2 17
При получении запроса сервера, начинающегося с 0a, бот должен повторить этот ответ без изменений.
Чтобы начать игру, нам осталось только включить часы. После этого действия сервер отправляет два пакета с номерами 54 и 56 на иллюстрации 4-21. Данные этих пакетов следующие:
1 02 00 00 00
2 22 00
Клиент не отвечает на эти пакеты, поэтому наш бот может их просто проигнорировать.
Все последующие пакеты (начиная с номера 58) передают данные о перемещении фигур игроками. Первой ходит белая сторона. В нашем случае это игрок на стороне сервера. Каждому ходу соответствует два пакета с данными в Wireshark логе.
Если белые сделают первый ход e2-e4, сервер передаст пакеты со следующими данными:
1 07 00 00 00
2 00 00 06 04 04 04 00
Попробуйте сделать ещё несколько ходов за обе стороны. Вы заметите, что данные первого из двух пакетов (07 00 00 00) не меняются. По ним бот может определить, что передаётся ход игрока.
Мы подошли к самому важному вопросу: как декодировать данные о ходе игрока? Представим себе шахматную доску. В ней всего 64 поля: 8 по вертикали и 8 по горизонтали. По вертикали поля нумеруются цифрами от 1 до 8, а по горизонтали – латинскими буквами от a до h. Очевидно, что ход каждого игрока должен содержать информацию о поле, где находится фигура сейчас, и поле, куда её следует переместить.
Вернёмся к перехваченному пакету с информацией о перемещении фигуры. Его данные содержат четыре ненулевых байта. Попробуйте сделать ещё несколько ходов. Первые два и последний байт всегда равны нулю, а остальные – нет. Следовательно, начальная и конечная позиция фигуры должна быть закодирована в этих четырёх байтах. То есть каждое поле задаётся двумя байтами.
Предположим, что первым указывается текущее поле фигуры. В нашем случае клетке e2 соответствуют два байта 06 04, а e4 соответствуют 04 04. Обратите внимание, что буква у обоих полей одинакова. Исходя из этого, предположим, что байт 04 соответствует букве “e”.
Теперь сделайте ход пешкой на поле с другой буквой, чтобы подтвердить наше предположение. В случае “d2-d4” данные соответствующего пакета выглядят следующим образом:
1 00 00 06 03 04 03 00
Получается, что букве “d” соответствует байт 03. Логично предположить, что коды букв идут последовательно один за другим. Учитывая это, составим таблицу 4-3 соответствия букв и их кодов.
| Байт | Буква |
|---|---|
| 00 | a |
| 01 | b |
| 02 | c |
| 03 | d |
| 04 | e |
| 05 | f |
| 06 | g |
| 07 | h |
Как мы получили эту таблицу? Начнём заполнять её левый столбец с уже известных нам байтов 03 и 04, которые соответствуют буквам “d” и “e”. Затем продолжим вверх значения в левом столбце: 02, 01, 00. Точно так же продолжим вверх значения в правом столбце: “c”, “b”, “a”. Аналогично заполним строки таблицы после байта 04.
Теперь составим похожую таблицу для номеров клеток. Мы уже знаем, что байт 06 соответствует номеру 2, а 04 – номеру 4. Поместим эти значения в таблицу и заполним остальные её строки. Вы должны получить таблицу 4-4.
| Байт | Номер |
|---|---|
| 07 | 1 |
| 06 | 2 |
| 05 | 3 |
| 04 | 4 |
| 03 | 5 |
| 02 | 6 |
| 01 | 7 |
| 00 | 8 |
Проверьте наши выводы, делая различные игровые ходы. По номерам и буквам клеток вы легко сможете предсказать данные пакетов, которые отправляют друг другу клиент и сервер.
Теперь мы знаем об игровом протоколе всё необходимое, чтобы написать бота.
Реализация бота
Начало игры
Первая задача бота – подключиться к серверу и начать игру в качестве клиента. Мы подробно рассмотрели все пакеты, которыми обмениваются обе стороны на этом этапе. Теперь реализуем скрипт, отвечающий на запросы сервера точно так же, как клиент NetChess. Результат приведён в листинге 4-5.
StartGameBot.py 1 import socket
2
3 def main():
4 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
5 s.settimeout(60)
6 s.connect(("127.0.0.1", 55555))
7
8 # получить от сервера подтверждение соединения
9 s.recv(1024, socket.MSG_WAITALL)
10 s.recv(1024, socket.MSG_WAITALL)
11
12 # отправить имя пользователя на стороне клиента
13 s.send(bytes([0x09, 0, 0, 0]))
14 s.send(bytes([0x0C, 0x63, 0x6C, 0x5F, 0x75, 0x73, 0x65, 0x72, 0x00]))
15
16 # получить от сервера уведомление о включении режима "Manual Edit"
17 s.recv(1024, socket.MSG_WAITALL)
18 s.recv(1024, socket.MSG_WAITALL)
19
20 # отправить подтверждение клиентом режима "Manual Edit"
21 s.send(bytes([0x01, 0, 0, 0]))
22 s.send(bytes([0x17]))
23
24 # получить от сервера уведомление о включении игровых часов
25 s.recv(1024, socket.MSG_WAITALL)
26 s.recv(1024, socket.MSG_WAITALL)
27
28 s.close()
29
30 if __name__ == '__main__':
31 main()
Первые три строки функции main нам уже знакомы. Они устанавливают TCP-соединение. Обратите внимание, что мы указали 60 секундный тайм-аут для сокета. в течение этого времени вызовы recv ожидают пакеты от сервера. За это время игрок должен успеть сделать свой ход.
Затем идут два вызова recv, чтобы получить подтверждение от сервера об успешном соединении. В этих пакетах указано имя игрока и цвет его фигур. Эти данные не важны для бота, поэтому он их игнорирует.
Почему цвет фигур оппонента игнорируется ботом? На самом деле вопрос стоит сформулировать иначе: сможет ли бот сыграть любым цветом? Ответ – нет. Наш бот отвечает на ходы игрока зеркально, то есть повторяет их. Следовательно, он может сделать свой ход только после человека. То есть бот всегда играет за чёрных.
Получив подтверждение от сервера, бот отправляет имя пользователя на стороне клиента. Оно не важно. Для примера будем отправлять строку “cl_user”, которая в виде байтового массива представляется следующим образом:
1 63 6C 5F 75 73 65 72
Перед именем пользователя добавим обязательную константу 0c.
На следующем шаге сервер включает режим “Manual Edit”. Получив от него уведомление, бот отправляет пакет с подтверждением. После этого сервер запускает игровые часы. На это действие уведомление от клиента не требуется.
Можно ли удалить лишние recv вызовы из скрипта StartGameBot.py? В нём мы не используем данные пакетов, полученных от сервера. Другими словами бот игнорирует информацию о выбранном пользователем имени и цвете фигур, а также код режима “Manual Edit”. Всё, что на самом деле нужно боту, – это данные с ходами игрока. Да, мы могли бы удалить лишние вызовы recv, но тогда возникает проблема. Как бот выберет правильные моменты времени для отправки подтверждений на действия сервера? Можно останавливать выполнение скрипта с помощью функции sleep на время достаточное пользователю, чтобы напечатать своё имя или включить режим “Manual Edit”. Но такое решение ненадёжно. Если бот ответит раньше, чем сервер отправит ему запрос на подтверждение, порядок процедуры запуска игры нарушится. Получается, что единственный надёжный способ для бота вовремя реагировать на действия игрока – это получать все пакеты от сервера с помощью вызова recv. Далее зная заранее последовательность шагов для начала игры, бот может точно установить момент получения пакета с первым ходом пользователя.
Повторение ходов игрока
В листинге 4-5 мы рассмотрели часть скрипта бота, которая отвечает за процесс начала игры. После него пользователь делает свой первый ход, на который бот должен ответить. Реализуем алгоритм для зеркального повторения ходов игрока.
Как правильно выбрать фигуру для перемещения и её новое поле? Рассмотрим несколько примеров зеркальных ходов в таблице 4-5.
| Ход | Байты данных | Зеркальный ход | Байты данных |
|---|---|---|---|
| e2-e4 | 00 00 06 04 04 04 00 | e7-e5 | 00 00 01 04 03 04 00 |
| d2-d4 | 00 00 06 03 04 03 00 | d7-d5 | 00 00 01 03 03 03 00 |
| b1-c3 | 00 00 07 01 05 02 00 | b8-c6 | 00 00 00 01 02 02 00 |
Первый ход в таблице e2-e4 делает белая пешка. Ему соответствует зеркальный ход чёрной пешкой e7-e5. Следующие ходы делают пешки на линии “d”. Затем идёт ход белого коня b1-c3. Прочитав соответствующий ему зеркальный ход чёрных, вы, возможно, заметите некоторые закономерности в байтах данных.
Первая закономерность связана с буквенными обозначениями. Предположим, что фигура, которая делает ход, находится на поле с буквой b. Тогда выполняющая зеркальный ход фигура тоже будет находиться на поле b. Буквы полей, в которые фигуры переместятся, также совпадут. Это правило выполняется для всех фигур.
Вторая закономерность поможет нам рассчитать номера клеток. Внимательно посмотрите на следующие пары чисел:
- 6 и 1
- 4 и 3
- 7 и 0
- 5 и 2
Как из правого числа получить левое? Для этого надо вычесть его из семи. Это правило выполняется для каждой из рассмотренных пар.
Теперь реализуем алгоритм расчёта зеркальных ходов. Результат приведён в листинге 4-6.
1 while(1):
2 # получить от сервера ход игрока
3 s.recv(1024, socket.MSG_WAITALL)
4 data = s.recv(1024, socket.MSG_WAITALL)
5 print(data)
6
7 start_num = 7 - data[2]
8 end_num = 7 - data[4]
9
10 # отправить ход бота
11 s.send(bytes([0x07, 0, 0, 0]))
12 s.send(bytes([0, 0, start_num, data[3], end_num, data[5], 0x00]))
Алгоритм работает в бесконечном цикле while. Сначала мы получаем пакет от сервера с ходом игрока и сохраняем его данные в переменной data. С помощью функции print выводим эти данные на консоль. Далее вычисляем номер клетки чёрной фигуры, которая должна сделать ход. Для расчёта используем третий байт массива data (с индексом 2). Он соответствует номеру начального поля белой фигуры. Результат сохраняем в переменной start_num. Аналогично вычисляем номер клетки, куда фигура должна походить. Результат сохраняем в переменной end_num. После этого отправляем два пакета с ходом бота. Первый из них содержит константные данные (07 00 00 00). Второй – рассчитанные номера клеток и те же буквы, что и в ходе игрока. Они хранятся в байтах с индексами 3 и 5 массива data.
Полная реализация бота доступна в файле MirrorBot.py из архива примеров к этой книге. В нём объединён код из листингов 4-5 и 4-6.
Чтобы протестировать бота, выполните следующие действия:
- Запустите приложение NetChess.
- Настройте его на работу в режиме сервера.
- Запустите скрипт
MirrorBot.py. - В приложении включите режим “Manual Edit”.
- Запустите игровые часы.
- Сделайте первый ход за белых.
Бот будет повторять каждый ваш ход до тех пор, пока это позволяют правила игры. Если такой ход невозможен, бот не будет ничего делать.
Выводы
Рассмотрим эффективность нашего внеигрового бота, сопоставив его достоинства и недостатки.
Достоинства бота:
- Он получает полную и точную информацию о состоянии игровых объектов.
- Он может симулировать действия игрока без каких-либо ограничений.
Недостатки бота:
- Анализ протокола взаимодействия клиента и сервера требует времени. Чем сложнее игра, тем более трудоёмким становится этот процесс.
- Чтобы защититься от этого типа ботов, достаточно зашифровать трафик между клиентом и сервером.
- Незначительные изменения в протоколе игры приводят к обнаружению бота. Также они могут помешать его работе, поскольку сервер, скорее всего, заблокирует пакеты устаревшего формата.
Мы можем обобщить наши выводы на большинство внутриигровых ботов. Они хорошо справляются с автоматизацией игрового процесса, но только до тех пор, пока на стороне сервера не поменяется протокол взаимодействия. После этого ваша игровая учётная запись будет заблокирована с большой вероятностью. Разработка ботов этого типа требует значительных усилий и времени.
Методы защиты от внеигровых ботов
Мы разработали бота для NetChess. Это простое приложение для игры в шахматы по локальной сети. Современные онлайн-игры насчитывают тысячи пользователей, которые подключаются к серверу через Интернет. Несмотря на эти различия, разработка внеигровых ботов в обоих случаях пойдёт по одному и тому же плану. Прежде всего необходимо изучить протокол взаимодействия игрового клиента и сервера.
У приложения NetChess нет никакой защиты от реверс-инжиниринга и внеигровых ботов. Именно по этой причине нам так быстро удалось понять его протокол. Если вы попробуете проделать то же самое с современной онлайн-игрой, возникнут сложности. Скорее всего, вы не сможете так просто установить соответствие между действиями игрока и данными в перехваченных пакетах. Одни и те же действия могут менять байты по разным смещениям без какой-либо закономерности. Если вы столкнулись с подобным поведением, значит игра имеет систему защиты. Самый надёжный и распространённый подход для защиты трафика приложения – это шифрование.
В главе 3 мы применяли алгоритмы шифрования для защиты памяти приложения. Теперь рассмотрим, как с их помощью обезопасить сетевой трафик.
Криптосистема
Перед изучением практических примеров, рассмотрим понятие криптосистемы. Криптосистема – это набор криптографических алгоритмов для обеспечения конфиденциальности информации. Как правило, она предоставляет алгоритмы для следующих целей:
- Генерация ключа.
- Шифрование.
- Дешифрование.
Первая категория алгоритмов в списке используется для создания секретного ключа, который удовлетворяет требованиям шифра.
Как работает шифрование? Предположим, что у нас есть некоторая информация (например сообщение), которое мы хотим защитить от несанкционированного чтения. Эта информация называется открытый текст (plaintext). Она вместе с секретным ключом передаётся алгоритму шифрования. После отработки алгоритм выдаст информацию в зашифрованном виде, который называется шифротекст. Чтобы снова получить открытый текст, необходимо передать шифротекст и ключ в алгоритм дешифрования. Это значит, что исходное сообщение смогут прочитать только те получатели, которые знают ключ.
Мы рассмотрели работу типичной криптосистемы в общих чертах. В реальных системах могут быть дополнительные шаги шифрования и дешифрования, а также возможности управления ключами.
Тестовое приложение
Для демонстрации алгоритмов шифрования воспользуемся простым приложением, которое передаёт текстовое сообщение по протоколу UDP. Мы использовали это приложение в разделе “Перехват трафика” (см. листинги 4-3 и 4-4). Немного изменим скрипт отправителя, чтобы вместо трёх байт отправлялась строка “Hello world!”.
TestStringUdpSender.py 1 import socket
2
3 def main():
4 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
5 s.bind(("127.0.0.1", 24001))
6 data = bytes("Hello world!", "utf-8")
7 s.sendto(data, ("127.0.0.1", 24000))
8 s.close()
9
10 if __name__
11 main()
Скрипт отправляет строку, хранящуюся в переменной data. Это байтовый массив, в котором каждой букве соответствует один байт (кодировка ASCII). Чтобы получить этот массив из исходной строки в кодировке UTF-8, используется функция bytes.
Запустите скрипт TestUdpReceiver.py из листинга 4-3 и TestStringUdpSender.py. Когда получатели примет сообщение, он выведет на консоль текст:
1 b'Hello world!'
Символ “b” в начале строки означает, что строка хранится в памяти в виде байтового массива.
Иллюстрация 4-22 демонстрирует перехваченный пакет тестового приложения.
Wireshark корректно декодировал строку “Hello world!”. Мы можем её прочитать в нижней части окна анализатора в области байтового представления пакета.
Шифр XOR
Шифр XOR представляет собой одну из простейших криптосистем. Мы использовали его в главе 3 для сокрытия данных процесса от сканеров памяти. Теперь применим его для шифрования сетевого пакета.
Библиотека PyCrypto предоставляет реализацию шифра XOR. Мы воспользуемся ею вместо того, чтобы писать алгоритм самостоятельно.
Листинг 4-8 демонстрирует использование шифра XOR, предоставляемого библиотекой PyCrypto.
XorTest.py 1 from Crypto.Cipher import XOR
2
3 def main():
4 key = b"The secret key"
5
6 # Encryption
7 encryption_suite = XOR.new(key)
8 cipher_text = encryption_suite.encrypt(b"Hello world!")
9 print(cipher_text)
10
11 # Decryption
12 decryption_suite = XOR.new(key)
13 plain_text = decryption_suite.decrypt(cipher_text)
14 print(plain_text)
15
16 if __name__ == '__main__':
17 main()
Первая строка скрипта импортирует Python модуль XOR, в котором реализованы алгоритмы шифра. Чтобы ими воспользоваться, нам надо подготовить секретный ключ. Им служит строка “The secret key”, хранящаяся в переменной key.
Чтобы зашифровать строку, мы создаём объект encryption_suite класса XORCipher с помощью функции new (вызов XOR.new). В качестве параметра передаём в неё секретный ключ. У созданного объекта есть метод encrypt, который применяет шифр к переданному ему открытому тексту в формате байтового массива. Получившийся шифротекст сохраняется в переменной cipher_text и выводится на консоль. Он выглядит следующим образом:
1 b'\x1c\r\tL\x1cE\x14\x1d\x17\x18DJ'
Оставшаяся часть функции main дешифрует шифротекст в исходный вид. Для этого мы создаём объект dencryption_suite точно так же, как и encryption_suite ранее. С помощью метода decrypt этого объекта мы дешифруем строку, хранящуюся в переменной cipher_text, и выводим результат на консоль. Он должен совпасть с исходной строкой “Hello world!”.
После внимательного изучения кода листинга 4-8 возникает вопрос. Можно ли использовать один и тот же объект класса XORCipher для шифрования и дешифрования? Ответ – нет. Классы библиотеки PyCrypto имеют внутреннее состояние, которое зависит от последней операции, выполненной с их помощью. Это означает, что любое действие над ними окажет влияние на последующее. Если вы зашифруете две строки друг за другом с помощью одного объекта, расшифровать их возможно только в той же последовательности. Иначе результат будет ошибочным. Надёжный и правильный способ использовать объекты XORCipher – использовать их для однократных операций шифрования и дешифрования.
Теперь применим шифр XOR для скриптов отправки и получения UDP-пакета нашего тестового приложения. Листинг 4-9 демонстрирует дополненный скрипт отправителя.
XorUdpSender.py 1 import socket
2 from Crypto.Cipher import XOR
3
4 def main():
5 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
6 s.bind(("127.0.0.1", 24001))
7
8 key = b"The secret key"
9 encryption_suite = XOR.new(key)
10 cipher_text = encryption_suite.encrypt(b"Hello world!")
11
12 s.sendto(cipher_text, ("127.0.0.1", 24000))
13 s.close()
14
15 if __name__ == '__main__':
16 main()
В скрипте XorUdpSender.py мы шифруем строку “Hello world!” и отправляем её по протоколу UDP.
Скрипт получателя приведён в листинге 4-10.
XorUdpReceiver.py 1 import socket
2 from Crypto.Cipher import XOR
3
4 def main():
5 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
6 s.bind(("127.0.0.1", 24000))
7 data, addr = s.recvfrom(1024, socket.MSG_WAITALL)
8
9 key = b"The secret key"
10 decryption_suite = XOR.new(key)
11 plain_text = decryption_suite.decrypt(data)
12 print(plain_text)
13
14 s.close()
15
16 if __name__ == '__main__':
17 main()
Если вы запустите скрипты отправителя и получателя, результат будет тем же что и раньше. Скрипт XorUdpReceiver.py выведет на консоль полученную строку:
1 b'Hello world!'
Однако, если вы перехватите передаваемый пакет с помощью Wireshark, вы сразу заметите разницу. Этот пакет приведён на иллюстрации 4-23.
Обратите внимание, что теперь Wireshark не может декодировать строку. Вы можете сделать это вручную, но только если вам известен секретный ключ.
Возможно, некоторые читатели решат, что шифр XOR – это отличный вариант для защиты приложения. Он прост в использовании и быстро работает. На самом деле его очень легко взломать. Рассмотрим подробнее, как это сделать.
В шифре применяется логическая операция исключающее “или”. Предположим, что мы шифруем открытый текст A с помощью секретного ключа K. Тогда получим шифротекст B:
1 A ⊕ K = B
Если мы применим исключающее “или” к A и B, то получим ключ K:
1 A ⊕ B = K
Это означает, что можно восстановить секретный ключ, если известны открытый текст и шифротекст. Скрипт XorCrack.py из листинга 4-11 восстанавливает ключ по рассмотренному алгоритму.
XorCrack.py 1 from Crypto.Cipher import XOR
2
3 def main():
4 key = b"The secret key"
5
6 # Encryption
7 encryption_suite = XOR.new(key)
8 cipher_text = encryption_suite.encrypt(b"Hello world!")
9 print(cipher_text)
10
11 # Decryption
12 decryption_suite = XOR.new(key)
13 plain_text = decryption_suite.decrypt(cipher_text)
14 print(plain_text)
15
16 # Crack
17 crack_suite = XOR.new(plain_text)
18 key = crack_suite.encrypt(cipher_text)
19 print(key)
20
21 if __name__ == '__main__':
22 main()
При запуске этот скрипт выведет на консоль следующее:
1 b'\x1c\r\tL\x1cE\x14\x1d\x17\x18DJ'
2 b'Hello world!'
3 b'The secret k'
Первая строка соответствует шифротексту. Далее идёт открытый текст и восстановленный секретный ключ.
Почему скрипт XorCrack.py восстановил только часть секретного ключа? В XOR шифре оператор исключающего “или” последовательно применяется к каждой букве открытого текста и соответствующему ей байту ключа. Если ключ оказался короче текста, оставшаяся его часть не используется. В противном случае он будет применяться циклично.
Как рассмотренное свойство оператора исключающего “или” поможет нам расшифровать пакет с реальными игровыми данными? В этом случае у нас есть только шифротекст. Наша задача – получить из него открытый текст. Прежде всего, необходимо восстановить секретный ключ. Для этого возьмём известный нам открытый текст и зашифруем его в точности той же криптосистемой, которой пользуется игровое приложение. Получив шифротекст, мы применим операцию исключающего “или” к нему и открытому тексту. Так мы узнаем ключ.
Например, мы заполняем форму регистрации для онлайн-игры. В ней надо указать информацию о новом игроке (имя, пароль, адрес электронной почты). Все эти данные нам известны. После заполнения формы, обычно требуется нажать кнопку “отправить”. Перехватим пакеты, которые игровое приложение посылает по этому нажатию. В них передаются данные пользователя из формы регистрации. Применим оператор исключающего “или” к введённой нами информации об игроке и шифротексту из пакета. Чтобы перепробовать все комбинации, понадобится время, но рано или поздно мы восстановим секретный ключ.
Можно заключить, что у шифра XOR есть положительные стороны, но он не способен обеспечить надёжную защиту для трафика приложения.
Шифр Triple DES
Следующий шифр, который мы рассмотрим, называется Triple DES (3DES). Для шифрования в нём троекратно применяется алгоритм DES (Data Encryption Standard), который был разработан в 1975 году компанией IBM. Сегодня DES считается ненадёжным из-за использования коротких секретных ключей длиной 56 бит. Современные компьютеры позволяют перебрать все возможные ключи такой длины (количеством 256) в течение нескольких дней. Алгоритм 3DES решает эту проблему путём увеличения длины ключа в три раза до 168 бит.
Почему необходимо применять алгоритм DES именно три раза? Разве не хватит двух? В этом случае мы получили бы ключ длиной 112 бит, которого достаточно для современных требований надёжности. Ожидается, что для взлома шифра потребуется перебрать 2112 всех возможных комбинаций. К сожалению, это предположение неверно. Атака под названием встреча посередине (meet-in-the-middle) позволяет сократить число вариантов ключей для перебора до 257. Этого недостаточно для надёжного шифрования открытого текста. Если же применить алгоритм 3DES, атакующему (лицу взламывающему шифр) придётся перебрать 2112 комбинаций ключей, даже если он применит атаку встреча посередине.
При разработке шифра 3DES учитывалось, насколько удобно будет его применение на специальных чипах. Сегодня по-прежнему эксплуатируется много устройств, выпущенных десять и более лет назад. Они поддерживают алгоритм DES на аппаратном уровне. Эти устройства достаточно просто настроить на работу с шифром 3DES. Обратная совместимость с устаревшими решениями – это основная причина использования 3DES в наши дни. Более современные шифры быстрее и надёжнее.
Обе библиотеки PyCrypto и PyCryptodome предоставляют реализации шифров DES и 3DES. Мы рассмотрим только 3DES алгоритм.
Листинг 4-12 демонстрирует скрипт для шифрования и дешифрования строки с помощью 3DES.
3DesTest.py 1 from Crypto.Cipher import DES3
2 from Crypto import Random
3
4 def main():
5 key = b"The secret key a"
6 iv = Random.new().read(DES3.block_size)
7
8 # Encryption
9 encryption_suite = DES3.new(key, DES3.MODE_CBC, iv)
10 cipher_text = encryption_suite.encrypt(b"Hello world! ")
11 print(cipher_text)
12
13 # Decryption
14 decryption_suite = DES3.new(key, DES3.MODE_CBC, iv)
15 plain_text = decryption_suite.decrypt(cipher_text)
16 print(plain_text)
17
18 if __name__ == '__main__':
19 main()
В этом скрипте мы импортируем Python модули DES3 и Random библиотеки PyCrypto. Первый из них предоставляет класс DES3Cipher, в котором реализованы алгоритмы шифрования и дешифрования. Модуль Random предоставляет генератор случайных последовательностей байтов. Его следует использовать вместо стандартного модуля random, распространяемого с интерпретатором Python. Потому что random считается небезопасным для целей шифрования.
Зачем алгоритму 3DES понадобился массив случайных байтов? 3DES – это блочный шифр. В нём открытый текст разделяется на блоки, которые последовательно шифруются с помощью секретного ключа. Если мы применим алгоритм как есть, шифр будет недостаточно надёжным. Причина в том, что атакующий может найти закономерность между отдельными блоками открытого текста и шифротекста. Тогда он сможет определить или по крайней мере предположить содержимое зашифрованных блоков. Чтобы предотвратить эту уязвимость, надо смешать каждый блок открытого текста с предыдущим блоком шифротекста. Этот подход известен как сцепление блоков шифротекста (Cipher Block Chaining или CBC). Единственная проблема возникает с первым блоком открытого текста. С какими данными следует смешивать его? Решение заключается в использовании случайно сгенерированный данных. Они называются вектором инициализации (Initialization Vector или IV).
В скрипте 3DesTest.py мы создаём файлоподобный объект (file-like) с помощью функции new модуля Random. После этого вызываем метод read, который возвращает массив случайных байтов указанной длины. Она должна быть равна длине одного блока, на которые разбивается открытый текст в алгоритме 3DES. В нашем случае это константа реализации DES3.block_size, равная восьми байтам. Мы сохраняем массив случайных байтов в переменной iv. Он будет смешиваться с первым блоком открытого текста при шифровании.
Возможно, вы заметили, что мы расширили секретный ключ двумя дополнительными символами до 16 байт. При использовании алгоритма 3DES длина ключа может быть либо 16, либо 24 байта.
После подготовки вектора инициализации и ключа, мы создаём объект encryption_suite класса DES3Cipher с помощью функции new модуля DES3. Она принимает три входных параметра:
- Секретный ключ.
- Режим сцепления блоков шифротекста.
- Вектор инициализации (если он нужен для выбранного режима).
В скрипте 3DesTest.py используется режим DES3.MODE_CBC. Библиотека PyCrypto предоставляет несколько альтернативных вариантов. Вы можете выбрать один из них.
Интерфейс методов encrypt и decrypt класса DES3Cipher такой же, как и у XORCipher. Первый принимает на вход открытый текст, а второй – шифротекст.
После запуска скрипта 3DesTest.py, вывод на консоли должен выглядеть следующим образом:
1 b'\xdc\xce\xf1^_\x95[\x16K\x93\x9a\xb8\x01\xf3\x1b\xcb'
2 b'Hello world! '
Обратите внимание, что мы добавили четыре пробела в конце строки открытого текста “Hello world!”. Они необходимы, поскольку его длина должна быть кратна восьми байтам, т.е. длине блока шифрования. Это требование выбранного нами режима сцепления блоков шифротекста.
Теперь дополним скрипты отправки и приёма UDP сообщения так, чтобы они использовали 3DES шифр. Листинг 4-13 демонстрирует код отправителя.
3DesUdpSender.py 1 import socket
2 from Crypto.Cipher import DES3
3 from Crypto import Random
4
5 def main():
6 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
7 s.bind(("127.0.0.1", 24001))
8
9 key = b"The secret key a"
10 iv = Random.new().read(DES3.block_size)
11 encryption_suite = DES3.new(key, DES3.MODE_CBC, iv)
12 cipher_text = iv + encryption_suite.encrypt(b"Hello world! ")
13
14 s.sendto(cipher_text, ("127.0.0.1", 24000))
15 s.close()
16
17 if __name__ == '__main__':
18 main()
Скрипт 3DesUdpSender.py шифрует открытый текст так же, как и 3DesTest.py. Единственное отличие в том, что мы добавляем вектор инициализации в начало шифротекста. Затем отправляем его получателю в UDP-пакете. Для чего это нужно? Как вы помните, для дешифровки сообщения нужен секретный ключ и вектор инициализации. Ключ мы можем сгенерировать заранее и сохранить на стороне отправителя и получателя. К сожалению, проделать то же самое с вектором инициализации не получится. Он должен быть уникальным для каждой операции шифрования, иначе алгоритм будет скомпрометирован и атакующему будет проще взломать шифр. Следовательно, получатель сообщения должен каким-то образом узнать IV. Самое простое решение – отправлять его вместе с шифротекстом в одном пакете.
Возникает вопрос: безопасно ли передавать вектор инициализации в открытом виде? Да, это вполне безопасно. Главная задача IV – добавлять случайность в шифротекст. Благодаря ему мы получаем разный результат при шифровании одного и того же открытого текста. При применении криптосистем IV часто рассматривается как обязательная часть шифротекста.
Листинг 4-14 демонстрирует реализацию скрипта получателя.
3DesUdpReceiver.py 1 import socket
2 from Crypto.Cipher import DES3
3
4 def main():
5 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
6 s.bind(("127.0.0.1", 24000))
7 data, addr = s.recvfrom(1024, socket.MSG_WAITALL)
8
9 key = b"The secret key a"
10 decryption_suite = DES3.new(key, DES3.MODE_CBC, data[0:DES3.block_size])
11 plain_text = decryption_suite.decrypt(data[DES3.block_size:])
12 print(plain_text)
13
14 s.close()
15
16 if __name__ == '__main__':
17 main()
В скрипте 3DesUdpReceiver.py мы передаём первый блок данных (байты с нулевого по DES3.block_size) из принятого UDP-пакета в функцию new в качестве вектора инициализации. Она конструирует объект decryption_suite, с помощью которого мы расшифровываем оставшиеся байты сообщения.
Если вы запустите сначала скрипт 3DesUdpReceiver.py, а потом 3DesUdpSender.py, получатель корректно расшифрует переданное сообщение и выведет его на консоль.
Вы можете использовать шифр 3DES в своих приложениях только тогда, когда на это есть серьёзные причины (например аппаратная поддержка со стороны используемого оборудования). Сегодня он не считается достаточно надёжным. Теоретические варианты атаки на шифр рассмотрены в этой статье. Кроме того, современные шифры работают быстрее 3DES.
Шифр AES
В 1998 году два бельгийских криптографа Винсент Рэймен и Йоан Даймен создали шифр AES (Advanced Encryption Standard). Он заменил DES и его вариации в качестве криптографического стандарта США.
В AES были решены проблемы шифра DES. Прежде всего он позволяет использовать длинные секретные ключи: 128, 192 и 256 бит. Любой из вариантов не вызовет накладных расходов алгоритма шифрования, как в случае 3DES. Их отсутствие – одна из причин высокой скорости работы AES. Возможность выбора появилась потому, что в AES длины блоков и ключа могут различаться.
Обе библиотеки PyCrypto и PyCryptodome предоставляют шифр AES. Интерфейс для его использования похож на 3DES.
Листинг 4-15 демонстрирует применение AES для шифрования и дешифрования строки.
AesTest.py 1 from Crypto.Cipher import AES
2 from Crypto import Random
3
4 def main():
5 key = b"The secret key a"
6 iv = Random.new().read(AES.block_size)
7
8 # Encryption
9 encryption_suite = AES.new(key, AES.MODE_CBC, iv)
10 cipher_text = encryption_suite.encrypt(b"Hello world! ")
11 print(cipher_text)
12
13 # Decryption
14 decryption_suite = AES.new(key, AES.MODE_CBC, iv)
15 plain_text = decryption_suite.decrypt(cipher_text)
16 print(plain_text)
17
18 if __name__ == '__main__':
19 main()
Сравните скрипты AesTest.py и 3DesTest.py. Они очень похожи. Функция new модуля AES создаёт объект encryption_suite класса AESCipher. У неё те же три входных параметра, что и в случае 3DES: секретный ключ, режим сцепления блоков, IV. Кроме того, AES поддерживает те же режимы сцепления, что и 3DES.
После запуска скрипта AesTest.py, в консоли напечатаются следующие строки:
1 b'\xed\xd5\x19]\x04\xba\xc5\x05^s\x18t\xa3\xb59x'
2 b'Hello world! '
Нам опять пришлось дополнить открытый текст пробелами, до длины кратной восьми байтов. Это требование режима сцепления блоков AES.MODE_CBC.
Листинг 4-16 демонстрирует скрипт AesUdpSender.py, который шифрует сообщение алгоритмом AES и отправляет его.
AesUdpSender.py 1 import socket
2 from Crypto.Cipher import AES
3 from Crypto import Random
4
5 def main():
6 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
7 s.bind(("127.0.0.1", 24001))
8
9 key = b"The secret key a"
10 iv = Random.new().read(AES.block_size)
11 encryption_suite = AES.new(key, AES.MODE_CBC, iv)
12 cipher_text = iv + encryption_suite.encrypt(b"Hello world! ")
13
14 s.sendto(cipher_text, ("127.0.0.1", 24000))
15 s.close()
16
17 if __name__ == '__main__':
18 main()
Здесь мы отправляем IV в начале данных пакета точно так же, как и в скрипте 3DesUdpSender.py (листинг 4-13). Алгоритм шифрования и отправки пакета такой же, как при использовании 3DES.
Скрипт AesUdpReceiver.py из листинга 4-17 получает и дешифрует сообщение.
AesUdpReceiver.py 1 import socket
2 from Crypto.Cipher import AES
3
4 def main():
5 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
6 s.bind(("127.0.0.1", 24000))
7 data, addr = s.recvfrom(1024, socket.MSG_WAITALL)
8
9 key = b"The secret key a"
10 decryption_suite = AES.new(key, AES.MODE_CBC, data[0:AES.block_size])
11 plain_text = decryption_suite.decrypt(data[AES.block_size:])
12 print(plain_text)
13
14 s.close()
15
16 if __name__ == '__main__':
17 main()
Скрипт AesUdpReceiver.py работает по тому же алгоритму, что и 3DesUdpReceiver.py из листинга 4-14.
Попробуйте запустить скрипты отправителя и получателя, чтобы проверить корректность их работы.
Если вы собираетесь использовать симметричный шифр в своём приложении, всегда выбирайте AES вместо 3DES.
Предположим, что игровое приложение, для которого мы собираемся написать бота, применяет симметричный шифр для защиты своего сетевого трафика. Можем ли мы его взломать, чтобы изучить протокол игры? Если используются надёжные алгоритмы вроде 3DES или AES, скорее всего, придётся перебрать и проверить все возможные комбинации секретных ключей. Этот подход известен как метод “грубой силы”. Однако, существуют атаки на шифр, позволяющие уменьшить число ключей для перебора и проверки. Они специфичны для алгоритма шифрования, режима его работы, деталей реализации и качества выбранного секретного ключа.
Возникает другой вопрос. Если мы применили какую-то технику и получили набор ключей для перебора и проверки, как понять, что один из них подошёл? Ведь в большинстве случаев мы не знаем точно, как выглядит открытый текст.
Первое решение этого затруднения заключается в том, чтобы собрать информацию об открытом тексте. Мы можем прочитать состояния игровых объектов в окне приложения или проанализировать память его процесса. Высока вероятность, что эти состояния окажутся в одном из пакетов, которыми обменивается игровой клиент и сервер. Следовательно, если секретный ключ подойдёт, мы должны прочитать эти данные.
Альтернативное решение заключается в применении статистического теста к расшифрованным данным. Если проверяемый секретный ключ корректен, они должны быть более упорядоченными. Иначе мы получим набор случайных байтов без какой-либо закономерности.
Шифр RSA
Все рассмотренные нами ранее шифры (XOR, 3DES, AES) являются симметричными. Это означает, что для шифрования и дешифрования используется один и тот же секретный ключ. Следовательно, он должен быть у отправителя и получателя сообщения. Этот факт может навести на мысль: зачем вообще нужно взламывать надёжный шифр? Ведь по сути секретный ключ находится на стороне пользователя в памяти игрового клиента. Достаточно найти его и импортировать в код внеигрового бота. После этого он сможет взаимодействовать с сервером точно так же, как и оригинальный клиент.
Возникает встречный вопрос: возможно ли защитить секретный ключ на стороне игрового клиента? Лучшим решением было бы вообще не хранить его локально у пользователя. С другой стороны, сервер не может просто отправлять ключ перед началом каждого сеанса обмена пакетами. Если атакующий перехватит его, он легко расшифрует весь дальнейший трафик. Асимметричное шифрование решает именно эту проблему. Оно предоставляет алгоритмы для безопасной передачи ключа.
Рассмотрим асимметричный шифр RSA. Его идея заключается в том, чтобы применять одностороннюю математическую функцию для шифрования открытого текста. Ключ является входным параметром этой функции. Чтобы взломать шифр, необходимо решить математическое уравнение, то есть найти открытый текст по известному шифротексту и ключу. Однако, главная особенность односторонних функций заключается в сложности нахождения входного параметра по её известному значению. Поэтому взломать шифр за разумное время невозможно.
Если вычислить функцию обратную односторонней нельзя, как же тогда происходит дешифрование сообщения? Предположим, что мы зашифровали сообщение с помощью ключа и односторонней функции. Шифротекст передали получателю. Даже зная ключ, используемый при шифровании, он не сможет дешифровать сообщение. Нюанс заключается в том, что для асимметричного шифрования выбираются особенные односторонние функции: те у которых есть лазейка. Лазейка – эта некоторая подсказка, помогающая вычислить обратную функцию, т.е. получить открытый текст по известному шифротексту и ключу. Мы пришли к концепции двух ключей: первый для выполнения шифрования (известен как открытый ключ) и второй – лазейка для вычисления обратной функции (закрытый ключ).
Рассмотрим, как работает асимметричное шифрование с точки зрения пользователя. Наша задача – получить от другого лица зашифрованное сообщение. Сначала надо вычислить пару ключей: открытый и закрытый. Первый из них передаём отправителю информации. Он шифрует своё сообщение этим ключом и отправляет нам шифротекст. Благодаря закрытому ключу, который служит лазейкой к односторонней функции, мы расшифровываем сообщение. Как видно из рассмотренной схемы, атакующий может перехватить открытый ключ и шифротекст, но это не поможет ему расшифровать сообщение. Для этого нужен закрытый ключ, но его получатель хранит у себя и никому не передаёт.
Обе библиотеки PyCrypto и PyCryptodome предоставляют реализацию шифра RSA. Но в PyCryptodome отсутствуют некоторые недостаточно надёжные функции RSA.
Листинг 4-18 демонстрирует использование RSA для шифрования и дешифрования строки.
RsaTest.py 1 from Crypto.PublicKey import RSA
2 from Crypto import Random
3
4 def main():
5 key = RSA.generate(1024, Random.new().read)
6
7 # Encryption
8 cipher_text = key.encrypt(b"Hello world!", 32)
9 print(cipher_text)
10
11 # Decryption
12 plain_text = key.decrypt(cipher_text)
13 print(plain_text)
14
15 if __name__ == '__main__':
16 main()
В скрипте мы импортируем два модуля Python Random и RSA. Первый из них нам уже известен. Второй предоставляет функции для генерации и применения открытого и закрытого ключа.
Сначала мы создаём объект key класса _RSAobj с помощью функции generate модуля RSA. Он содержит пару ключей (открытый и закрытый). Первый параметр функции обязательный. Он задаёт длину ключей (в нашем случае 1024 бита). Второй параметр опциональный. В нём передаётся функция генерации случайных чисел.
После создания объекта key мы вызываем его методы encrypt и decrypt для шифрования и дешифрования текста.
Может возникнуть вопрос: где применяются открытый и закрытый ключи в нашем примере? Ведь явно они нигде в коде не упоминаются. На самом деле шифрование и дешифрование происходит в одном и том же процессе, поэтому нет необходимости в передаче открытого ключа. Если же передача нужна, объект key предоставляет методы для экспорта и импорта ключей.
В листинге 4-18 мы рассмотрели использование шифра RSA самого по себе. В таком виде он уязвим для атаки на основе подобранного открытого текста (chosen-plaintext attack или CPA). Поэтому RSA всегда используют в комбинации со схемой дополнения OAEP (Optimal Asymmetric Encryption Padding), которая предотвращает эту уязвимость. Такая комбинация шифра и схемы дополнения известна как RSA-OAEP.
Листинг 4-19 демонстрирует использование RSA-OAEP алгоритма для шифрования строки.
RsaOaepTest.py 1 from Crypto.PublicKey import RSA
2 from Crypto.Cipher import PKCS1_OAEP
3 from Crypto import Random
4
5 def main():
6 key = RSA.generate(1024, Random.new().read)
7
8 # Encryption
9 encryption_suite = PKCS1_OAEP.new(key)
10 cipher_text = encryption_suite.encrypt(b"Hello world!")
11 print(cipher_text)
12
13 # Decryption
14 decryption_suite = PKCS1_OAEP.new(key)
15 plain_text = decryption_suite.decrypt(cipher_text)
16 print(plain_text)
17
18 if __name__ == '__main__':
19 main()
Теперь для шифрования и дешифрования мы используем не key, а объекты класса PKCS1OAEP_Cipher из модуля PKCS1_OAEP. Он конструируется функцией new, которая принимает входным параметром объект класса _RSAobj (то есть ключи RSA). Для шифрования и дешифрования используются два разных OAEP-объекта: encryption_suite и decryption_suite.
Применим RSA-OAEP шифр для нашего тестового приложения, отправляющего UDP-пакет по сети. Прежде всего необходимо изменить его алгоритм. В случае симметричного шифрования он тривиален: зашифровать открытый текст, передать его в пакете, расшифровать на стороне получателя. При применении асимметричного шифра появляется дополнительный шаг: передача открытого ключа отправителю сообщения. Ведь с его помощью и будет происходить шифрование.
Рассмотрим пошагово новый алгоритм тестового приложения:
- Скрипт отправителя сообщения запускается первым. Он создаёт UDP-сокет и ожидает получения открытого ключа.
- Скрипт получателя запускается. Он создаёт UDP-сокет. Затем генерирует пару ключей.
- Получатель сообщения посылает свой открытый ключ.
- Отправитель читает ключ из пришедшего UDP-пакета и использует его для шифрования открытого текста по алгоритму RSA-OAEP.
- Отправитель посылает шифротекст с сообщением.
- Получатель принимает шифротекст и дешифрует его, используя свой закрытый ключ.
Листинг 4-20 демонстрирует скрипт, отправляющий сообщение.
RsaUdpSender.py 1 import socket
2 from Crypto.PublicKey import RSA
3 from Crypto.Cipher import PKCS1_OAEP
4
5 def main():
6 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
7 s.bind(("127.0.0.1", 24001))
8
9 public_key, addr = s.recvfrom(1024, socket.MSG_WAITALL)
10
11 key = RSA.importKey(public_key)
12 cipher = PKCS1_OAEP.new(key)
13
14 cipher_text = cipher.encrypt(b"Hello world!")
15
16 s.sendto(cipher_text, ("127.0.0.1", 24000))
17 s.close()
18
19 if __name__ == '__main__':
20 main()
В этом скрипте мы используем функцию importKey модуля RSA. Она конструирует объект класса _RSAobj, содержащий только открытый ключ. Этого объекта будет достаточно для шифрования, но не для дешифрования. На входе importKey принимает ключ в формате байтового массива, который мы получаем из UDP-пакета. Переменная key используется для конструирования объекта cipher класса PKCS1OAEP_Cipher. С его помощью мы шифруем сообщение и отправляем его получателю.
Скрипт, получающий сообщение, приведён в листинге 4-21.
RsaUdpReceiver.py 1 import socket
2 from Crypto.PublicKey import RSA
3 from Crypto.Cipher import PKCS1_OAEP
4 from Crypto import Random
5
6 def main():
7 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
8 s.bind(("127.0.0.1", 24000))
9
10 key = RSA.generate(1024, Random.new().read)
11 public_key = key.publickey().exportKey()
12
13 s.sendto(public_key, ("127.0.0.1", 24001))
14
15 data, addr = s.recvfrom(1024, socket.MSG_WAITALL)
16
17 cipher = PKCS1_OAEP.new(key)
18 plain_text = cipher.decrypt(data)
19 print(plain_text)
20
21 s.close()
22
23 if __name__ == '__main__':
24 main()
Как мы рассмотрели ранее, в скрипте получателя сообщения появились дополнительные шаги для передачи открытого ключа. Мы генерируем пару ключей и сохраняем её в объекте key. Затем с помощью его метода publickey создаём временный объект класса _RSAobj, содержащий только открытый ключ. Его нужно представить в формате байтового массива, чтобы передать в UDP-пакете. Для этого вызываем метод exportKey временного объекта. Результат сохраняем в переменную public_key.
Метод exportKey есть у любого объекта класса _RSAobj. Что он экспортирует, если мы вызовем его для объекта key, содержащего и открытый ключ, и закрытый? В этом случае метод вернёт закрытый ключ. Это может быть полезно для сохранения его на жёстком диске и дальнейшего использования.
Открытый ключ мы посылаем в UDP-пакете без шифрования. Его перехват не поможет в атаке на шифр. После этого мы ждём пока отправитель получит ключ, зашифрует им сообщение и отправит его. Для дешифровки используется объект cipher класса PKCS1OAEP_Cipher, который применяет закрытый ключ в алгоритме RSA-OAEP.
Чтобы протестировать наше приложение, запустите сначала скрипт RsaUdpSender.py, а потом RsaUdpReceiver.py. Получатель должен вывести на консоль переданное сообщение.
По сравнению с симметричными шифрами RSA имеет один существенный недостаток – он работает значительно медленнее. Причина в том, что симметричные шифры используют в своих алгоритмах операции битового сдвига и логическое “или”. Современные процессоры обрабатывают их очень быстро за счёт специальных логических блоков, которые способны выполнять по одной операции за такт. Обычная тактовая частота сегодня составляет порядка 2.5 гигагерц (Гц). Это значит, что в секунду процессор способен совершать 2500000000 операций. Наличие нескольких ядер увеличивает это число.
Алгоритмы RSA используют математические функции: возведение в степень по модулю во время шифрования и вычисление функции Эйлера для дешифровки. Их расчёт не может быть ускорен с помощью специальных логических блоков, а потому требует большого числа тактов.
Проблема интенсивных вычислений в асимметричных шифрах решается с помощью сеансового ключа. Идея заключается в том, чтобы сгенерировать временный ключ для симметричного шифрования. В этом случае алгоритм RSA используется только для его безопасной передачи. После этого обе стороны переходят на симметричный шифр и с его помощью защищают свои сообщения. Временный ключ действует до окончания соединения. Для нового соединения он будет сгенерирован повторно.
Вы можете легко изменить скрипты RsaUdpSender.py и RsaUdpReceiver.py так, чтобы вместо строки “Hello world!” передавался сеансовый ключ (например, шифра AES). После этого скрипты смогут перейти на симметричное шифрование для дальнейшего обмена сообщениями.
Асимметричное шифрование позволяет устранить уязвимость, связанную с постоянным хранением секретного ключа на стороне игрового клиента.
Обнаружение внеигровых ботов
Мы рассмотрели криптографические алгоритмы для защиты трафика игрового приложения. Разработчик бота должен потратить достаточно времени на перехват сетевых пакетов и их дешифровку. Предположим, ему это удалось и он написал внеигрового бота для нашей игры. Что мы можем предпринять в этом случае?
На самом деле обнаружить внеигрового бота намного проще чем внутриигрового или кликера. Всё что нужно сделать – это реагировать на получение некорректных пакетов на стороне сервера.
Для примера рассмотрим простейший случай. Мы используем симметричное шифрование и постоянно храним секретный ключ на стороне игрового клиента. Бот импортирует этот ключ и использует его для взаимодействия с сервером. В этом случае обнаружить бота очень трудно. Но у любой онлайн-игры должен быть предусмотрен механизм обновления игрового клиента. Он необходим для исправления ошибок и добавления новых возможностей. Одно из обновлений может менять секретный ключ без уведомления об этом пользователя. Очевидно, что на стороне сервера ключ также будет обновлён. Если после этого бот отправит пакет, зашифрованный старым ключом, сервер не сможет его корректно дешифровать. Таким образом бот себя обнаружит.
Разработчик бота может своевременно реагировать на обновления и импортировать новые ключи. Однако, мы обнаружим и заблокируем всех пользователей, которые используют старую версию бота. Обычно игроки покупают и запускают бота, не понимая основных принципов его работы. Поэтому очень часто они попадаются на использовании его старых версий.
В случае асимметричного шифрования, мы можем применить тот же подход для обнаружения бота. Есть несколько вариантов распределения ключей. Предположим, что сервер постоянно хранит у себя открытый ключ игрового клиента. В начале сеанса клиент посылает свой открытый ключ. Сервер сравнивает его со своей копией. Если обнаруживается различие, велика вероятность, что пользователь запустил бота. Если ключи совпали, сервер отправляет свой открытый ключ клиенту. После этого они могут шифровать сообщения друг для друга. При обновлении мы генерируем заново все ключи: пару открытый-закрытый на стороне клиента, только открытый ключ клиента на сервере. Если бот попробует воспользоваться старыми ключами, мы его обнаружим.
Если вы не хотите генерировать новые ключи шифрования, есть альтернативное решение. Вы можете регулярно менять протокол игрового приложения. Изменение может быть незначительным. Например, будет достаточно поменять порядок параметров игровых объектов в сетевом пакете или увеличить номер версии протокола. После этого проверив принятый от клиента пакет на соответствие новому формату, будет просто обнаружить бота.