Создание прослушивающего приложения для просмотра трафика мобильной MMORPG

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Это вторая часть цикла статей про разбор сетевого трафика мобильной MMORPG. Примерные темы цикла:

  1. Разбор формата сообщений между сервером и клиентом.
  2. Написание прослушивающего приложения для просмотра трафика игры в удобном виде.
  3. Перехват трафика и его модификация при помощи не-HTTP прокси-сервера.
  4. Первые шаги к собственному («пиратскому») серверу.

В этой части я опишу создание прослушивающего приложения (sniffer), который позволит нам фильтровать события по их типу и источнику, выводить информацию о сообщении и выброчно сохранять их для анализа, а также немного залезу в исполняемый файл игры («бинарник»), чтобы найти вспомогательную информацию и добавить поддержку Protocol Buffers в приложение. Заинтересовавшихся прошу под кат.

Требуемые инструменты


Для возможности повторения шагов, описанных ниже, потребуются:

  • Wireshark для анализа пакетов;
  • .NET;
  • библиотека PcapDotNet для работы с WinPcap;
  • библиотека protobuf-net для работы с Protocol Buffers.

Написание прослушивающего приложения


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

  • перехватить пакеты мобильного устройства;
  • отфильтровать пакеты игры;
  • добавить данные очередного пакета в буфер для последующей обработки;
  • доставать события игры из буферов по мере его заполнения.

Эти действия реализованы в классе Sniffer, который использует библиотеку PcapDotNet для перехвата пакетов. В метод Sniff мы передаем IP-адрес адаптера (по факту — это адрес ПК, с которого раздается Wi-Fi для мобильного устройства, внутри этой же сети), IP-адрес мобильного устройства и IP-адрес сервера. По причине непостоянности последних двух (после многомесячного наблюдения за разными платформами и серверами выяснилось, что сервер выбирается из пула ~50 серверов, на каждом и которых еще по 5-7 возможных портов) я передаю лишь первые три октета. Использование данной фильтрации видно в методе IsTargetPacket.

public class Sniffer
{
    private byte[] _data = new byte[4096];
    public bool Active { get; set; } = true;

    private string _adapterIP;
    private string _target;
    private string _server;

    private List<byte> _serverBuffer;
    private List<byte> _clientBuffer;
    
    private LivePacketDevice _device = null;
    private PacketCommunicator _communicator = null;
    
    private Action<Event> _eventCallback = null;
    
    public void Sniff(string ip, string target, string server)
    {
        _adapterIP = ip;
        _target = target;
        _server = server;

        _serverBuffer = new List<byte>();
        _clientBuffer = new List<byte>();

        IList<LivePacketDevice> allDevices = LivePacketDevice.AllLocalMachine;

        for (int i = 0; i != allDevices.Count; ++i)
        {
            LivePacketDevice device = allDevices[i];
            var address = device.Addresses[1].Address + "";
            if (address == "Internet " + _adapterIP)
            {
                _device = device;
            }
        }

        _communicator = _device.Open(65536, PacketDeviceOpenAttributes.Promiscuous, 1000);
        _communicator.SetFilter(_communicator.CreateFilter("ip and tcp"));

        new Thread(() =>
        {
            Thread.CurrentThread.IsBackground = true;
            BeginReceive();
        }).Start();
    }
    
    private void BeginReceive()
    {
        _communicator.ReceivePackets(0, OnReceive);

        do
        {
            PacketCommunicatorReceiveResult result = _communicator.ReceivePacket(out Packet packet);
            switch (result)
            {
                case PacketCommunicatorReceiveResult.Timeout: continue;
                case PacketCommunicatorReceiveResult.Ok: OnReceive(packet); break;
            }
        } while (Active);
    }

    public void AddEventCallback(Action<Event> callback)
    {
        _eventCallback = callback;
    }
    
    private void OnReceive(Packet packet)
    {
        if (Active)
        {
            IpV4Datagram ip = packet.Ethernet.IpV4;

            if (IsTargetPacket(ip))
            {
                try
                {
                    ParseData(ip);
                }
                catch (ObjectDisposedException)
                {
                }
                catch (EndOfStreamException e)
                {
                    Console.WriteLine(e);
                }
                catch (Exception)
                {
                    throw;
                }
            }
        }
    }
    
    private bool IsTargetPacket(IpV4Datagram ip)
    {
        var sourceIp = ip.Source.ToString();
        var destIp = ip.Destination.ToString();

        return (sourceIp != _adapterIP && destIp != _adapterIP) && (
               (sourceIp.StartsWith(_target) && destIp.StartsWith(_server)) ||
               (sourceIp.StartsWith(_server) && destIp.StartsWith(_target))
            );
    }
    
    private void ParseData(IpV4Datagram ip)
    {
        TcpDatagram tcp = ip.Tcp;
        if (tcp.Payload != null && tcp.PayloadLength > 0)
        {
            var payload = ExtractPayload(tcp);
            AddToBuffer(ip, payload);
            ProcessBuffers();
        }
    }
    
    private byte[] ExtractPayload(TcpDatagram tcp)
    {
        int payloadLength = tcp.PayloadLength;
        MemoryStream ms = tcp.Payload.ToMemoryStream();
        byte[] payload = new byte[payloadLength];
        ms.Read(payload, 0, payloadLength);
        return payload;
    }
    
    private void AddToBuffer(IpV4Datagram ip, byte[] payload)
    {
        if (ip.Destination.ToString().StartsWith(_target))
        {
            foreach (var value in payload)
                _serverBuffer.Add(value);
        }
        else
        {
            foreach (var value in payload)
                _clientBuffer.Add(value);
        }
    }
    
    private void ProcessBuffers()
    {
        ProcessBuffer(ref _serverBuffer);
        ProcessBuffer(ref _clientBuffer);
    }
    
    private void ProcessBuffer(ref List<byte> buffer)
    {
        // TODO
    }

    public void Suspend()
    {
        Active = false;
    }

    public void Resume()
    {
        Active = true;
    }
}

Отлично, теперь у нас есть два буфера с данными пакетов от клиента и сервера. Вспоминаем формат событий между игрой и сервером:

struct Event {
    uint payload_length <bgcolor=0xFFFF00, name="Payload Length">;
    ushort event_code <bgcolor=0xFF9988, name="Event Code">;
    byte payload[payload_length] <name="Event Payload">;
};

Исходя из этого можно создать класс события Event:

public enum EventSource
{
    Client, Server
}

public enum EventTypes : ushort
{
    Movement = 11,
    Ping = 30,
    Pong = 31,
    Teleport = 63,
    EnterDungeon = 217
}

public class Event {
    public uint ID;
    public uint Length { get; protected set; }
    public ushort Type { get; protected set; }
    public uint DataLength { get; protected set; }
    public string EventType { get; protected set; }
    public EventSource Direction { get; protected set; }
    protected byte[] _data;
    
    protected BinaryReader _br = null;
    
    public Event(byte[] data, EventSource direction)
    {
        _data = data;
        _br = new BinaryReader(new MemoryStream(_data));
        Length = _br.ReadUInt32();
        Type = _br.ReadUInt16();
        DataLength = 0;
        EventType = $"Unknown ({Type})";
        if (IsKnown())
        {
            EventType = ((EventTypes)Type).ToString();
        }
        Direction = direction;
    }
    
    public virtual void ParseData()
    {

    }

    public bool IsKnown()
    {
        return Enum.IsDefined(typeof(EventTypes), Type);
    }

    public byte[] GetPayload(bool hasDatLength = true)
    {
        var payloadLength = _data.Length - (hasDatLength ? 10 : 6);

        return new List<byte>(_data).GetRange(hasDatLength ? 10 : 6, payloadLength).ToArray();
    }

    public virtual void Save()
    {
        var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Packets", EventType);
        Directory.CreateDirectory(path);
        File.WriteAllBytes(path + $"/{ID}.dump", _data);
    }

    public override string ToString()
    {
        return $"Type {Type}. Data length: {Length}.";
    }
    
    protected ulong ReadVLQ(bool readFlag = true)
    {
        if (readFlag)
        {
            var flag = _br.ReadByte();
        }
        ulong vlq = 0;
        var i = 0;
        for (i = 0; ; i += 7)
        {
            var x = _br.ReadByte();
            vlq |= (ulong)(x & 0x7F) << i;

            if ((x & 0x80) != 0x80)
            {
                break;
            }
        }

        return vlq;
    }
}

Класс Event будет использоваться как базовый класс для всех событий игры. Вот пример класса для события Ping:

public class Ping : Event
{
    private ulong _pingTime;

    public Ping(byte[] data) : base(data, EventSource.Client)
    {
        EventType = "Ping";
        DataLength = 4;
        _pingTime = _br.ReadUInt32();
    }

    public override string ToString()
    {
        return $"Pinging server at {_pingTime}ms.";
    }
}

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

private void ProcessBuffer(ref List<byte> buffer)
{
    if (buffer.Count > 0)
    {
        while (Active)
        {
            if (buffer.Count > 4) // Первые 4 байта в событии содержат размер полезной нагрузки ...
            {
                var eventLength = BitConverter.ToInt32(buffer.Take(4).ToArray(), 0) + 6; // ... поэтому размер события - это размер П.Н. + первые 4 байта + 2 байта кода события
                if (eventLength >= 6 && buffer.Count >= eventLength)
                {
                    var eventData = buffer.Take(eventLength).ToArray();
                    var ev = CreateEvent(eventData, direction);
                    buffer.RemoveRange(0, eventLength);

                    continue;
                }
            }

            break;
        }
    }
}

private Event CreateEvent(byte[] data, EventSource direction)
{
    var ev = new Event(data, direction);
    var eventType = Enum.GetName(typeof(EventTypes), ev.Type);

    if (eventType != null)
    {
        try
        {
            // Создаем экземпляр класса события (например, <code>Ping</code>).
            var className = "Events." + eventType;
            Type t = Type.GetType(className);
            ev = (Event)Activator.CreateInstance(t, data);
        }
        catch (Exception)
        {
            // Если специального класса нет - создаем экземпляр базового.
            ev = new Event(data, direction);
        }
        finally
        {

        }
    }
    
    _eventCallback?.Invoke(ev);

    return ev;
}

Создадим класс формы, который будет запускать прослушку:

public partial class MainForm : Form
{
    private Sniffer _sniffer = null;
    private List<Event> _events = new List<Event>();

    private List<ushort> _eventTypesFilter = new List<ushort>();
    private bool _showClientEvents = true;
    private bool _showServerEvents = true;
    private bool _showUnknownEvents = false;
    private bool _clearLogsOnRestart = true;
    private uint _eventId = 1;
    
    private void InitializeSniffer()
    {
        _sniffer = new Sniffer();
        _sniffer.AddEventCallback(NewEventThreaded);
        _sniffer.Sniff("192.168.137.1", "192.168.137.", "123.45.67.");
    }
    
    private void NewEventThreaded(Event ev)
    {
        events_table.Invoke(new NewEventCallback(NewEvent), ev);
    }

    public delegate void NewEventCallback(Event ev);

    private void NewEvent(Event ev)
    {
        ev.ID = _eventId++;
        _events.Add(ev);
        LogEvent(ev);
    }

    private void LogEvent(Event ev)
    {
        if (FilterEvent(ev))
        {
            var type = ev.GetType();
            events_table.Rows.Add(1);
            events_table.Rows[events_table.RowCount - 1].Cells[0].Value = ev.ID;
            events_table.Rows[events_table.RowCount - 1].Cells[1].Value = ev.EventType;
            events_table.Rows[events_table.RowCount - 1].Cells[2].Value = Enum.GetName(typeof(EventSource), ev.Direction);
            events_table.Rows[events_table.RowCount - 1].Cells[3].Value = ev.ToString();
        }
    }
    
    private void ReloadEvents()
    {
        events_table.Rows.Clear();
        events_table.Refresh();

        foreach (var ev in _events)
        {
            LogEvent(ev);
        }
    }

    private bool FilterEvent(Event ev)
    {
        return (
                (ev.Direction == EventSource.Client && _showClientEvents) ||
                (ev.Direction == EventSource.Server && _showServerEvents)
               ) && (_eventTypesFilter.Contains(ev.Type) || (!ev.IsKnown() && _showUnknownEvents));
    }
}

Готово! Теперь можно добавить пару таблиц для управления списком событий (через нее заполняется _eventTypesFilter) и просмотра в реальном времени (главная таблица events_table). Например, я фильтровал по следующим критериям (метод FilterEvent):

  • показ событий от клиента;
  • показ событий от сервера;
  • показ неизвестных событий;
  • показ выбранных известных событий.

Изучаем исполняемый файл игры


Хотя теперь можно без проблем анализировать события игры, предстоит огромная ручная работа по определению не только смысла всех кодов событий, но и структуры полезной нагрузки, что будет достаточно сложно, особенно если она изменяется в зависимости от некоторых полей. Я решил поискать какую-нибудь информацию в исполняемом файле игры. Так как игра кроссплатформенная (доступна на Windows, iOS и Android), то доступны следующие варианты для анализа:

  • .exe файл (у меня расположен по пути C:/Program Files/WindowsApps/%appname%/;
  • бинарный файл iOS, который шифруется Apple, но имея JailBreak можно получить расшифрованную версию при помощи Crackulous или подобных;
  • динамическая библиотека (shared object) Android. Находится по пути /data/data/%app-vendor-name%/lib/.

Не имея понятия какую архитектуру выбирать для Android и iOS, я начал с .exe файла. Загружаем бинарник в IDA, видим выбор архитектур.



Цель нашего поиска — какие-нибудь очень полезные строки, а значит декомпиляция ассемблера не входит в планы, но на всякий случай выбираем «executable 80386», так как варианты «Binary File» и «MS-DOS executable» явно не подходят. Жмем «OK», ждем пока файл загрузится в базу данных, и желательно дождаться окончания анализа файла. Окончание анализа можно узнать по тому, что в статус баре внизу слева будет следующее состояние:



Переходим на вкладку Strings (View/Open subviews/Strings или Shift + F12). Процесс генерации строк может занять некоторое время. В моем случае было найдено ~47к строк. Адреса расположения строк имеют префикс вида .data, .rdata и другие. В моем случае, все «интересные» строки находились в секции .rdata, размер которой был ~44.5к записей. Просматривая таблицу можно увидеть:

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

Наконец, ближе к концу таблицы попадается то, что мы искали.



Это список кодов событий между клиентом и сервером. Это может упростить нам жизнь при разборе сетевого протокола игры. Но не будем останавливаться на достигнутом! Нужно проверить можно ли как-то получить числовое значение кода события. Видим «знакомые» из предыдущей статьи коды CMSG_PING и SMSG_PONG, имеющих коды 30 (1E16) и 31 (1F16) соответственно. Двойным щелчком по строке переходим на это место в коде.



Действительно, сразу за строковыми значениями кодов идет последовательность 0x10 0x1E и 0x10 0x1F. Отлично, значит, можно распарсить всю таблицу и получить список событий и их числовое значение, что еще больше упростит разбор протокола.

К сожалению, Windows-версия игры отстает от мобильных версий на очень много версий, а потому информация из .exe не является актуальной, и хотя она может помочь, всецело полагаться на нее не стоит. Следующим я решил изучить динамическую библиотеку с Android, так как на одном форуме видел, что там, в отличие от бинарников iOS, содержится много мета-информации о классах. Но увы, поиск по файлу значений CMSG_PING не дал результатов.

Без надежды делаю тот же поиск в бинарнике iOS — невероятно, но там оказались те же данные, что и в .exe! Загружаем файл в IDA.



Выбираю первый предложенный вариант, так как не уверен какой надо. Снова ждем окончания анализа файла (бинарник в почти 4 раза больше по размеру .exe, время анализа, естественно, тоже увеличилось). Открываем окно со строками, которых на сей раз оказалось 51к. Через Ctrl + F ищем CMSG_PING и… не находим. Вводя код посимвольно, можно заметить вот такой результат:



Почему-то IDA скомпоновала весь объект Opcode.proto в одну строку. Двойным щелчком переходим на это место в коде и видим, что структура описана так же, как и в .exe файле, значит можно вырезать ее и сконвертировать в Enum.

Тут наконец стоит вспомнить, как в комментариях к прошлой статье aml подсказал, что структура сообщений игры является реализацией Protocol Buffers. Если внимательно присмотреться к коду в бинарном файле, можно увидеть, что описание Opcode тоже в этом формате.



Напишем шаблон-парсер для 010Editor, чтобы получить все значения кодов.

Обновленный код типов Packed* для 010Editor
Небольшие изменения в типах содержат проверку метки поля, чтобы пропускать отсутствующие.

uint PeekTag() {
    if (FTell() == FileSize()) {
        return 0;
    }
    
    Varint tag;
    FSkip(-tag.size);
    
    return tag._ >> 3;
}

struct Packed (uint fieldNumber) {
    if (PeekTag() != fieldNumber) {
        break;
    }
    
    Varint key <bgcolor=0xFFBB00>;
    
    local uint wiredType = key._ & 0x7;
    local uint field = key._ >> 3;
    local uint size = key.size;
    
    switch (wiredType) {
        case 1: double value; size += 8; break;
        case 5: float value; size += 4; break;
        default: Varint value; size += value.size; break;
    }
};

struct PackedString(uint fieldNumber) {
    if (PeekTag() != fieldNumber) {
        break;
    }
    
    Packed length(fieldNumber);
    char str[length.value._];
};


struct Code {
    Packed size(2) <bgcolor=0x00FF00>;
    PackedString code_name(1) <bgcolor=0x00FF00>;
    Packed code_value(2) <bgcolor=0x00FF00>;
    Printf("%s = %d,\n", code_name.str, code_value.value._); // Вывод значения в консоль для вставки в Enum
};

struct Property {
    Packed size(5) <bgcolor=0x00FF00>;
    
    PackedString prop_name(1) <bgcolor=0x00FF00>;
    while (FTell() - 0x176526B - prop_name.length.value._ < size.value._) {
        Code codes <name="Codes">;
    }
};

struct {
    FSkip(0x176526B);
    
    PackedString object(1) <bgcolor=0x00FF00>;
    PackedString format(2) <bgcolor=0x00FFFF>;
    Property prop;
} file;

В результате получаем что-то подобное:



Дальше интереснее! Заметили pb в описании объекта? Надо бы поискать другие строки, вдруг таких объектов еще много?



Результаты крайне неожиданные. Судя по всему, в исполняемом файле игры описаны многие типы данных, включая перечисления и форматы сообщений между сервером и клиентом. Вот пример описания типа, описывающего положение объекта в мире:



Быстрый поиск выявил два больших места с описаниями типов, хотя при более тщательном изучении наверняка выявятся и другие мелкие места. Вырезав их, я написал маленький скрипт на C#, чтобы разделить описания по файлам (по структуре это схоже с описанием списка кодов событий) — так проще анализировать их в 010Editor.

class Program
{
    static void Main(string[] args)
    {
        var br = new BinaryReader(new FileStream("./BinaryFile.partX", FileMode.Open));

        while (br.BaseStream.Position < br.BaseStream.Length)
        {
            var startOffset = br.BaseStream.Position;

            var length = ReadVLQ(br, out int size);
            var tag = br.ReadByte();
            var eventName = br.ReadString();

            br.BaseStream.Position = startOffset;

            File.WriteAllBytes($"./parsed/{eventName}", br.ReadBytes((int)length + size + 1));
        }
    }
    
    static ulong ReadVLQ(BinaryReader br, out int size)
    {
        var flag = br.ReadByte();
        ulong vlq = 0;
        size = 0;
        var i = 0;
        for (i = 0; ; i += 7)
        {
            var x = br.ReadByte();
            vlq |= (ulong)(x & 0x7F) << i;
            size++;

            if ((x & 0x80) != 0x80)
            {
                break;
            }
        }

        return vlq;
    }
}

Подробно разбирать формат описания структур не буду, т.к. либо он специфичен для рассматриваемой игры, либо это общепринятый в Protocol Buffers формат (если кто знает наверняка, укажите, пожалуйста, в комментариях). Из того, что смог обнаружить:

  • описание также идет в формате Protocol Buffers;
  • описание каждого поля содержит его имя, номер и тип данных, для которой использовалась своя таблица типов:
    string TypeToStr (uint type) {
        switch (type) {
            case 2: return "Float";
            case 4: return "UInt64";
            case 5: return "UInt32";
            case 8: return "Boolean";
            case 9: return "String";
            case 11: return "Struct";
            case 14: return "Enum";
            default: local string s; SPrintf(s, "%Lu", type); return s;
        }
    };
    
  • если тип данных — перечисление или структура, то далее шла ссылка на нужный объект.

Ну и последнее, что нам осталось, — это использовать полученную информацию в нашем прослушивающем приложении: парсить сообщения при помощи библиотеки protobuf-net. Подключите библиотеку через NuGet, добавьте using ProtoBuf; и можно создавать классы для описания сообщений. Возьмем один из примеров из прошлой статьи: движение персонажа. Распаршеное описание формата при подсветке сегментов выглядит примерно так:



Отладочный вывод позволяет создать из этого краткое описание:

Field 1 (Type 13): time 
Field 2 (Struct .pb.CxGS_Vec3): position 
Field 3 (UInt64): guid 
Field 4 (Struct .pb.CxGS_Vec3): direction 
Field 5 (Struct .pb.CxGS_Vec3): speed 
Field 6 (UInt32): state 
Field 10 (UInt32): flag 
Field 11 (Float): y_speed 
Field 12 (Boolean): is_flying 
Field 7 (UInt32): emote_id 
Field 9 (UInt32): emote_duration 
Field 8 (Boolean): emote_loop 

Теперь можно создать соответствующий класс с помощью библиотеки protobuf-net.

[ProtoContract]
public class MoveInfo : ProtoBufEvent<MoveInfo>
{
    [ProtoMember(3)]
    public ulong GUID;

    [ProtoMember(1)]
    public ulong Time;

    [ProtoMember(2)]
    public Vec3 Position;

    [ProtoMember(4)]
    public Vec3 Direction;

    [ProtoMember(5)]
    public Vec3 Speed;

    [ProtoMember(6)]
    public ulong State;

    [ProtoMember(7, IsRequired = false)]
    public uint EmoteID;

    [ProtoMember(8, IsRequired = false)]
    public bool EmoteLoop;

    [ProtoMember(9, IsRequired = false)]
    public uint EmoteDuration;

    [ProtoMember(10, IsRequired = false)]
    public uint Flag;

    [ProtoMember(11, IsRequired = false)]
    public float SpeedY;

    [ProtoMember(12)]
    public bool IsFlying;

    public override string ToString()
    {
        return $"{GUID}: {Position}";
    }
}

Для сравнения, вот шаблон того же события из прошлой статьи:

struct MoveEvent {
    uint data_length <bgcolor=0x00FF00, name="Data Length">;
    Packed move_time <bgcolor=0x00FFFF>;
    
    PackedVector3 position <bgcolor=0x00FF00>;
    PackedVector3 direction <bgcolor=0x00FF00>;
    PackedVector3 speed <bgcolor=0x00FF00>;
    
    Packed state <bgcolor=0x00FF00>;
};

При наследовании класса Event мы можем переопределить метод ParseData, десериализируя данные пакета:

class CMSG_MOVE_INFO : Event
{
    private MoveInfo _message;

    [...]
    
    public override void ParseData()
    {
        _message = MoveInfo.Deserialize(GetPayload());
    }

    public override string ToString()
    {
        return _message.ToString();
    }
}

Вот и все. Следующим шагом будет перенаправление трафика игры на наш прокси-сервер с целью инъекции, подмены и вырезки пакетов.
Источник: https://habr.com/ru/post/457480/


Интересные статьи

Интересные статьи

Кустикова Валентина, Васильев Евгений, Вихрев Иван, Дудченко Антон, Уткин Константин и Коробейников Алексей. Intel Distribution of OpenVINO Toolkit — набор библиотек для разработки приложений,...
На КДПВ — стикер, созданный командой браузера Samsung Internet Больше 12 лет прошло с тех пор, как Стив Джобс впервые представил идею веб-приложений, «которые выглядят и ведут себя точно так ж...
При построении процесса CI/CD с использованием Kubernetes порой возникает проблема несовместимости требований новой инфраструктуры и переносимого в неё приложения. В частности, на этапе сборк...
От скорости сайта зависит многое: количество отказов, брошенных корзин. Согласно исследованию Google, большинство посетителей не ждёт загрузки больше 3 секунд и уходит к конкурентам. Бывает, что сайт ...
С недавних пор меня просто заваливают приглашениями на собесы. Я прихожу, мы болтаем за жизнь, а потом мне делают оффер, как будто техническое интервью уже позади, и я подтвердил свои скиллы ...