Ты в жизни не раз сталкивался с разными протоколами — одни использовал, другие, возможно, реверсил. Одни были легко читаемы, в других без hex-редактора не разобраться. В этой статье я покажу, как создать свой собственный протокол, который будет работать поверх TCP/IP. Мы разработаем свою структуру данных и реализуем сервер на C#.

Итак, протокол передачи данных — это соглашение между приложениями о том, как должны выглядеть передаваемые данные. Например, сервер и клиент могут использовать WebSocket в связке с JSON. Вот так приложение на Android могло бы запросить погоду с сервера:

{
    "request": "getWeather",
    "city": "cityname"
}

И сервер мог бы ответить:

{
    "success": true,
    "weatherHumanReadable": "Warm",
    "degrees": 18
}

Пропарсив ответ по известной модели, приложение предоставит информацию пользователю. Выполнить парсинг такого пакета можно, только располагая информацией о его строении. Если ее нет, протокол придется реверсить.

 

Создаем базовую структуру протокола

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

Первое, что необходимо ввести, — это наш собственный заголовок, чтобы приложения могли отличать пакеты нашего протокола. У нас это будет набор байтов 0xAF, 0xAA, 0xAF. Именно они и будут стоять в начале каждого сообщения.

INFO

Почти каждый бинарный протокол имеет свое «магическое число» (также «заголовок» и «сигнатура») — набор байтов в начале пакета. Оно используется для идентификации пакетов своего протокола. Остальные пакеты будут игнорироваться.

Каждый пакет будет иметь тип и подтип и будет размером в байт. Так мы сможем создать 65 025 (255 * 255) разных типов пакетов. Пакет будет содержать в себе поля, каждое со своим уникальным номером, тоже размером в один байт. Это предоставит возможность иметь 255 полей в одном пакете. Чтобы удостовериться в том, что пакет дошел до приложения полностью (и для удобства парсинга), добавим байты, которые будут сигнализировать о конце пакета.

Завершенная структура пакета:

XPROTOCOL PACKET STRUCTURE
 
(offset: 0) HEADER (3 bytes) [ 0xAF, 0xAA, 0xAF ]
(offset: 3) PACKET ID
  (offset: 3) PACKET TYPE (1 byte)
  (offset: 4) PACKET SUBTYPE (1 byte)
(offset: 5) FIELDS (FIELD[])
(offset: END) PACKET ENDING (2 bytes) [ 0xFF, 0x00 ]
 
FIELD STRUCTURE
 
(offset: 0) FIELD ID (1 byte)
(offset: 1) FIELD SIZE (1 byte)
(offset: 2) FIELD CONTENTS

Назовем наш протокол, как ты мог заметить, XProtocol. На третьем сдвиге начинается информация о типе пакета. На пятом начинается массив из полей. Завершающим звеном будут байты 0xFF и 0x00, закрывающие пакет.

 

Пишем клиент и сервер

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

  • тип пакета;
  • подтип;
  • набор полей.
public class XPacket
{
    public byte PacketType { get; private set; }
    public byte PacketSubtype { get; private set; }
    public List<XPacketField> Fields { get; set; } = new List<XPacketField>();
}

Добавим класс для описания поля пакета, в котором будут его данные, ID и размер.

public class XPacketField
{
    public byte FieldID { get; set; }
    public byte FieldSize { get; set; }
    public byte[] Contents { get; set; }
}

Сделаем обычный конструктор приватным и создадим статический метод для получения нового экземпляра объекта.

private XPacket() {}

public static XPacket Create(byte type, byte subtype)
{
    return new XPacket
    {
        PacketType = type,
        PacketSubtype = subtype
    };
}

Теперь можно задать тип пакета и поля, которые будут внутри него. Создадим функцию для этого. Записывать будем в поток MemoryStream. Первым делом запишем байты заголовка, типа и подтипа пакета, а потом отсортируем поля по возрастанию FieldID.

public byte[] ToPacket()
{
    var packet = new MemoryStream();

    packet.Write(
    new byte[] {0xAF, 0xAA, 0xAF, PacketType, PacketSubtype}, 0, 5);

    var fields = Fields.OrderBy(field => field.FieldID);

    foreach (var field in fields)
    {
        packet.Write(new[] {field.FieldID, field.FieldSize}, 0, 2);
        packet.Write(field.Contents, 0, field.Contents.Length);
    }

    packet.Write(new byte[] {0xFF, 0x00}, 0, 2);

    return packet.ToArray();
}

Теперь запишем все поля. Сначала пойдет ID поля, его размер и данные. И только потом конец пакета — 0xFF, 0x00.

Теперь пора научиться парсить пакеты.

INFO

Минимальный размер пакета — 7 байт: HEADER(3) + TYPE(1) + SUBTYPE(1) + PACKET ENDING(2)

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

public static XPacket Parse(byte[] packet)
{
    if (packet.Length < 7)
    {
        return null;
    }

    if (packet[0] != 0xAF ||
        packet[1] != 0xAA ||
        packet[2] != 0xAF)
    {
        return null;
    }

    var mIndex = packet.Length - 1;

    if (packet[mIndex - 1] != 0xFF ||
        packet[mIndex] != 0x00)
    {
        return null;
    }

    var type = packet[3];
    var subtype = packet[4];

    var xpacket = Create(type, subtype);

    /* <---> */

Пора перейти к парсингу полей. Так как наш пакет заканчивается двумя байтами, мы можем узнать, когда закончились данные для парсинга. Получим ID поля и его размер, добавим к списку. Если пакет будет поврежден и будет существовать поле с ID, равным нулю, и SIZE, равным нулю, то необходимости его парсить нет.

    /* <---> */

    var fields = packet.Skip(5).ToArray();

    while (true)
    {
        if (fields.Length == 2)
        {
            return xpacket;
        }

        var id = fields[0];
        var size = fields[1];

        var contents = size != 0 ?
        fields.Skip(2).Take(size).ToArray() : null;

        xpacket.Fields.Add(new XPacketField
        {
            FieldID = id,
            FieldSize = size,
            Contents = contents
        });

        fields = fields.Skip(2 + size).ToArray();
    }
}

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

 

Учимся записывать и считывать данные

Из-за строения класса XPacket необходимо хранить бинарные данные для полей. Чтобы установить значение поля, нам потребуется конвертировать имеющиеся данные в массив байтов. Язык C# не предоставляет идеальных способов сделать это, поэтому внутри пакетов будут передаваться только базовые типы: int, double, float и так далее. Так как они имеют фиксированный размер, можно считать его напрямую из памяти.

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

Вариант 1. Оформи подписку на «Хакер», чтобы читать все материалы на сайте

Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке

Вариант 2. Купи один материал

Заинтересовала информация, но нет возможности оплатить подписку? Тогда этот вариант для тебя! Обрати внимание: этот способ покупки доступен только для материалов, опубликованных более двух месяцев назад.


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

Check Also

Хроники битвы при Denuvo. Как «непробиваемая» игровая защита EA Origin оказалась пробиваемой

Защита от пиратства Denuvo пришла на смену SecuROM и связана с одним действующим лицом – Р…