Как стать автором
Обновить

Реверс алгоритма поиска устройств в сети

Уровень сложностиСредний
Время на прочтение32 мин
Количество просмотров957

При создании оконного клиента под MS-Windows для удалённого взаимодействия с LED-матрицами стояла задача сделать автоматический поиск всех табло в сети. Моей первой идеей было перебирать все существующие IP-адреса конкретной подсети, по очереди посылая на них запросы и ожидая что одно или несколько устройств отправят соответствующий ответ. Я быстро отказался от этой задумки, ведь подобный брутфорс будет сильно нагружать сеть, да и сам алгоритм не самый быстрый. Других идей по реализации на тот момент у меня не было. Мне предоставили копию другого клиента, где поиск осуществляется моментально по нажатию одноимённой кнопки, а приложение в табличном виде выводит IP и MAC-адреса с рядом другой информации об обнаруженных матрицах, если таковые нашлись. Эти данные затем могут быть использованы для подключения, конфигурации и отправки команд на найденные устройства. Не имея исходного кода, я подготовил дизассемблеры, отладчики и hex-редакторы, готовясь к глубокому анализу и разбору проприетарного алгоритма поиска, чтобы реализовать что-то подобное уже в своей программе.

ВАЖНО: При написании данной статьи я предполагаю, что читатель уже знаком с основами реверс-инжиниринга, языка ассемблера для x86, Си, строения исполняемых файлов и адресного пространства, сокетов и сетевого программирования. Это ни в коем случае НЕ туториал и НЕ справочная информация. Здесь я описываю собственный ход действий, который может отличаться от того, как поступали бы вы. В основе сделанных предположений лежит собственный опыт.

Предоставленные файлы

В руки мне попал .zip архив с таким содержимым:

  • UNICONFIG.EXE — Клиент для настройки контроллера. Именно это приложение создаёт окно с кнопкой "Поиск".

  • TESTCONN.EXEПрограмма тестирования контроллера. Проверяет возможность отправки команд с хоста на удалённое устройство.

  • UNICONFIG.INI, TESTCONN.INIФайлы конфигурации одноимённых программ.

Окно UNICONFIG.EXE при запуске
Окно UNICONFIG.EXE при запуске

Как видно, интерфейс программы комплексный: присутствует множество объединённых в группы элементов интерфейса, полей ввода, кнопок и ряд вкладок. Мне сообщили о том, что программа предположительно написана на C/C++ и WinApi, ссылаясь на малый размер исполняемого файла (всего 441 Кб). Мне же показалось это довольно странным, ведь было бы довольно трудно и долго вручную размечать позиции такого количества кнопок и полей в вызовах CreateWindowXXX(). Правда не стоит отрицать, что программист мог использовать какой-нибудь графический конструктор воизбежание ручного подсчёта пикселей, например этот. Но всё же я склонялся к тому, что программа написана либо на Delphi, либо на C# и WinForms. А маленький размер исполняемого файла в таком случае объясним использованием пакера.

Первый взгляд

objdump определил тип файла как pei-i386. Это говорит о том, что программа работает в 32-х битном режиме и может быть исполнена на i386-совместимых процессорах. Порой только по беглому просмотру импортов уже можно ответить на множество вопросов. Как минимум понять упакован ли файл или нет: пакеры упаковывают также таблицу импортов, создавая ILT вместо загрузчика. Именно из-за этого IAT включает в себя лишь пару базовых процедур для загрузки библиотек и нахождения символов внутри них.

$ objdump -x uniconfig.exe

        ...

        DLL Name: kernel32.dll
        vma:  Hint/Ord Member-Name Bound-To
        16ff79      0  GetProcAddress
        16ff8a      0  GetModuleHandleA
        16ff9d      0  LoadLibraryA

        ...

        DLL Name: user32.dll
        vma:  Hint/Ord Member-Name Bound-To
        170277      0  LoadStringW

        DLL Name: user32.dll
        vma:  Hint/Ord Member-Name Bound-To
        170285      0  CreateWindowExW

        ...

        DLL Name: wsock32.dll
        vma:  Hint/Ord Member-Name Bound-To
        17035b      0  WSACleanup

        ...

Библиотек в таблице чуть больше, но я оставил только те импорты, на которые стоит обратить внимание. Из kernel32.dll импортируются GetProcAddress(), GetModuleHandleA() и LoadLibraryA(). Конечно неупакованные программы тоже могут подгружать дополнительные библиотеки с помощью этого набора процедур во время выполнения, но в данном случае это единственные функции, импортируемые из kernel32.dll. Я ожидал увидеть здесь как минимум CloseHandle(), или GetCommandLineX() с GetStartupInfoX(), что использовались бы где-нибудь в точке входа компилятора. Из user32.dll приложение импортирует только LoadStringW() и CreateWindowExW(). Здесь нет даже DestroyWindow() для закрытия этого самого окна, не говоря уже о RegisterClassX(), UnregisterClassX(), DefWindowProcX() и других процедурах, которые очень часто можно встретить в оконных приложениях. Из большинства библиотек импортируются по одной-две функции, что ещё сильнее напоминает использование пакера. А если посмотреть первые инструкции в точке входа, то можно заметить, что исполнение начинается с мнемоники pushad:

0056F001 | 60                       | pushad                                   |
0056F002 | E8 03000000              | call uniconfig.56F00A                    |
0056F007 | E9 EB045D45              | jmp 45B3F4F7                             |
0056F00C | 55                       | push ebp                                 |
0056F00D | C3                       | ret                                      |
0056F00E | E8 01000000              | call uniconfig.56F014                    |
0056F013 | EB 5D                    | jmp uniconfig.56F072                     |
0056F015 | BB EDFFFFFF              | mov ebx,FFFFFFED                         |
0056F01A | 03DD                     | add ebx,ebp                              |

На этом этапе уже можно утверждать наверняка — файл упакован. Именно пакеры генерируют пару инструкций pushad/popad в начале и в конце распаковки соответственно, чтобы запомнить до исполнения и восстановить перед передачей управления распакованному коду значения регистров.

Распаковка

Пробежавшись глазами вниз по дизассемблированному коду я нашёл закрывающую инструкцию popad. Сразу за ней следует условное ветвление, в результате которого процесс либо завершится с кодом 1, либо управление перейдёт к распакованной части программы.

0056F3AF | 61                       | popad                                    |
0056F3B0 | 75 08                    | jne uniconfig.56F3BA                     |
0056F3B2 | B8 01000000              | mov eax,1                                |
0056F3B7 | C2 0C00                  | ret C                                    |
0056F3BA | 68 A4BD5100              | push uniconfig.51BDA4                    |
0056F3BF | C3                       | ret                                      |

Сразу после того, как выполнился переход к оригинальной точке входа, плагином Scylla я нашёл таблицу импортов и сдампил распакованное содержимое из памяти в отдельный исполняемый файл для возможности дальнейшего статического анализа.

Определение языка и компилятора

Десктопные программы с графическим интерфейсом по понятным причинам практически никогда не пишут с нуля на ассемблере. Этот бинарный файл с вероятностью 99.9% был скомпилирован из исходного кода, написанного в соответствии с правилами одного из популярных языков программирования высокого уровня. Моими основными предположениями, как я уже говорил ранее, были Delphi и C#. Компиляторы зачастую оставляют следы в результатах своей работы — будь-то идентификатор и собственная версия, метаданные, версии используемых библиотек или даже отладочная информация.

Исходя из импортов, я понял, что изучаемая программа точно не является C# приложением, ведь в таком случае в таблице бы встретилась запись mscoree.dll с процедурой _CorExeMain(), что инициализирует использование .NET и начинает исполнение байт-кода. CLR заголовка в файле тоже нет, что значит это нативное приложение.

Ghidra определила компилятор, как "borlanddelphi" и у меня не было оснований не верить ей. В распакованном файле я вручную поискал метаинформацию. Искал вхождения таких строк, как "delphi", "borland", "pascal", "(c)" в разных регистрах и комбинациях. Через несколько секунд появилось первое вхождение.

$ strings uniconfig_dump_SCY.exe | grep "Borland"
FastMM Borland Edition (c) 2004 - 2008 Pierre le Riche / Professional Software Development

FastMM — это быстрый менеджер памяти, разработанный для Delphi. Это упоминание подтверждает использования компилятора Delphi.

Поиск процедур кнопок

Кнопки в Delphi — это объекты класса TButton. У TButton есть метод OnClick(), который вызывается при нажатии на соответствующую кнопку. Зачастую процедуры-колбэки называют говорящими именами, дабы явно указать на то, что конкретная функция вызывается извне. Сделав банальный поиск строк и отфильтровав его по словам "click", "clicked" и "press" в разных регистрах, я увидел что-то очень похожее на названия функций.

$ strings -tx uniconfig_dump_SCY.exe | grep "Click"

  ...

  f5b10 Button3Click
  f5b23 Button5Click
  f5b36 Button4Click
  f5c51 Button3Click
  f5c8d Button5Click
  f5cc9 Button4Click
  f7777 Button3Click

  ...

Я предположил, что это названия процедур внутри таблицы адресов и имён как минимум из-за того, что в них используется PascalCase. Если внимательно присмотреться к одной из этих строк (к примеру — "Button5Click"), то можно заметить данные вокруг неё, чередующиеся с подобными ей строками.

Данные вокруг "Button5Click"
Данные вокруг "Button5Click"

Сразу за строкой идёт байт 0x0C. Зная, что Delphi хранит количество символов в строковых литералах в числе перед первым символом, можно утверждать что это длина строки (как раз ровно 12 символов). За полем длины идут другие поля, формирующие следующую последовательность байт:

13 00 8C 6D 4F 00

Количество и размер полей неизвестны, а потому я просто начал разбивать её на фрагменты разной длины и смотреть на что это похоже. Сначала я предположил, что последние 4 байта это обычный знаковый int. При загрузке значения из памяти в регистр процессора, байты переворачиваются, и перевернув их, я получил число 0x004F6D8C. Это похоже на адрес внутри исполняемой секции .text. Перейдя по этому адресу я увидел инструкции, очень напоминающие пролог процедуры:

004F6D8C | 55                       | push ebp                                 |
004F6D8D | 8BEC                     | mov ebp,esp                              |
004F6D8F | 33C9                     | xor ecx,ecx                              |
004F6D91 | 51                       | push ecx                                 |

Провернув такую же операцию с соседними строками я убедился, что все эти указатели ссылаются на разные процедуры. Выходит, что адреса и имена процедур кнопок хранятся в следующем виде:

+-------+-------------+--------~
| Адрес | Длина имени | Имя
+-------+-------------+--------~
        ^             ^        ^
        |             |        |
        4 байта       5 байт   N байт

Затем я составил простенькую программу для автоматизации поиска процедур кнопок, которая выводит команды установки точек останова в синтаксисе gdb по найденным адресам.

    /* UNICONFIG.EXE как массив байт */
    static const uint8_t raw[] = { ... };

    /* Составные части имени процедуры (ButtonXXClick) */
    const char part1[] = "Button";
    const char part2[] = "Click";

    for (const uint8_t *rawp = raw; rawp < raw + sizeof(raw); rawp++) {
        /* Проверяем совпадение по первой части */
        if (memcmp(rawp, part1, sizeof part1 - 1))
            continue;

        /* Проверяем совпадение по второй части */
        if (memcmp(rawp + sizeof(part1) + 1, part2, sizeof part2 - 1))
            if (memcmp(rawp + sizeof(part1), part2, sizeof part2 - 1))
                continue;
        
        /* Извлекаем адрес, если в .text - печатаем */
        const uint32_t addr = *(uint32_t *)(rawp - 5);
        if ((addr >= 0x401000) && (addr <= 0x51b000))
            printf("b *0x%"PRIx32"\n", addr);
    }

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

(gdb) c
Continuing.
[Thread 22696.0x8764 exited with code 0]
[Switching to Thread 22696.0x6004]

Thread 1 hit Breakpoint 59, 0x0050b61c in ?? ()
(gdb)

Поток исполнения остановился на адресе 0x0050b61c, а значит это и есть процедура, привязанная к кнопке "Поиск".

Колбэк

int __fastcall search_button_callback(int a1, int a2)
{
  int result; // eax
  int v5; // edx
  int v6; // eax

  result = sub_50E760(a1, a2 != 0, 1, a1, a2);
  if ( (_BYTE)result )
  {
    if ( *off_51EB74 )
    {
      v6 = sub_50B538(a1, a2 != 0);
      return sub_4F34C0(v6);
    }
    else
    {
      LOBYTE(v5) = a2 != 0;
      return sub_50B1B4(a1, v5);
    }
  }
  return result;
}

Так выглядел колбэк сразу после свежего анализа в IDA Pro. Сначала вызывается процедура sub_50E760(), от её результата зависит будет ли выполнена оставшаяся часть тела. Далее, в случае если она вернула ненулевое значение, проверяется глобальная переменная по указателю off_51EB74.

off_51EB74 указывает на нулевой байт в секции .data. Этот нулевой байт не изменяется нигде в программе на протяжении всего времени исполнения, а значит блок if, принадлежащий вложенному условному оператору, никогда не будет достигнут. Вероятно это какой-то переключатель (флаг), который добавляется во время компиляции в зависимости от настроек и режима. Впрочем значение этой переменной в данном контексте не играет большой роли, важно лишь то, что это константа. Итого остаётся два варианта — либо поиск реализован в самой первой вызываемой процедуре sub_50E760(), либо в sub_50B1B4(), вызываемой внутри блока else вложенного условного оператора.

Я поставил точку останова на одной из инструкций внутри блока else. В конечном счёте поток исполнения дошёл до предполагаемого адреса.

Последовательность действий

Я переименовал sub_50B1B4() в более приемлемое net_search(), после чего вывод декомпилятора стал выглядеть так:

int __fastcall net_search(int a1, int a2)
{
  if ( (unsigned __int8)sub_50B180(a1, a2) )
  {
    (*(void (__fastcall **)(_DWORD))(**(_DWORD **)(*(_DWORD *)(a1 + 1071044) + 4) + 8))(*(_DWORD *)(*(_DWORD *)(a1 + 1071044)
                                                                                                  + 4));
    sub_4D35D4(*(_DWORD *)(a1 + 1071044));
    sub_50B1F8(a1);
  }
  return sub_4D3538(*(_DWORD *)(a1 + 1071044));
}

Как и в прошлом случае видно, что большая часть тела функции (состоящая из трёх вызовов) не выполнится, если sub_50B180() вернёт 0. Посмотрев внутрь этой процедуры, и других вызываемых ею подпрограмм, я нашёл такие библиотечные вызовы, как WSAStartup(), WSACleanup(), socket(), setsockopt(), shutdown(), closesocket(). Становится очевидно, что в этом месте производится инициализация библиотеки для использования сокетов, создаётся новый сокет и изменяются некоторые его опции.

Внутри следующей процедуры sub_4D35D4() встретились вызовы htons(), sendto(), select(), recvfrom(), inet_ntoa(). Вероятно здесь и сконцентрирована основная логика поиска, ведь ведётся работа с отправкой и приёмом данных по сети с использованием UDP сокетов.

sub_50B1F8() делает вызовы функций библиотеки времени исполнения языка Delphi — TStringGrid::SetCells(), IntToStr(), которые предположительно парсят результаты поиска и добавляют на их основе элементы в таблицу найденных устройств.

sub_4D3538(), что исполняется после условного оператора, последовательно вызывает shutdown(), closesocket() и WSACleanup(), завершая работу алгоритма.

Правда непонятным остался вызов виртуального метода объекта a1 перед оставшимися двумя вызовами внутри блока if. Вероятно это вызов конструктора одного из объектов, или даже таблицы устройств. Можно было бы через отладчик проверить что именно за метод вызывается, найти таблицу виртуальных методов, но так как не сильно похоже на то, что он влияет на исследуемый алгоритм, я решил опустить это. Итого на самом высоком уровне программа выполняет следующие действия:

  1. Инициализирует работу с сетью и создаёт сокет

  2. Производит поиск устройств

  3. Парсит результаты поиска и вносит данные в таблицу

  4. Закрывает сокет и завершает работу с сетью

После того как я дал имена процедурам, net_search() стала выглядеть лучше и понятнее.

int __fastcall net_search(int a1, int a2)
{
  if ( (unsigned __int8)search_init(a1, a2) )
  {
    (*(void (__fastcall **)(_DWORD))(**(_DWORD **)(*(_DWORD *)(a1 + 1071044) + 4) + 8))(*(_DWORD *)(*(_DWORD *)(a1 + 1071044)
                                                                                                  + 4));
    search_perform(*(_DWORD *)(a1 + 1071044));
    update_table(a1);
  }
  return network_deinit(*(_DWORD *)(a1 + 1071044));
}

Инициализация работы с сетью

Внутри себя search_init() вызывает ещё одну процедуру, в которой реализована большая часть её логики. Подписал её search_init_internal().

int __fastcall search_init_internal(int a1)
{
  int v2; // ebx
  struct WSAData v4; // [esp+0h] [ebp-198h] BYREF

  v2 = *(unsigned __int8 *)(a1 + 8);
  if ( !(_BYTE)v2 )
  {
    sub_404CAC((int)&v4, 400, 0);
    if ( j_WSAStartup(0x101u, &v4) )
    {
      sub_407894((volatile __int32 *)(a1 + 16), aErrorStartWsa_0);
    }
    else if ( (unsigned __int8)sub_4D3934(a1) )
    {
      *(_BYTE *)(a1 + 8) = 1;
      LOBYTE(v2) = 1;
    }
  }
  return v2;
}

С ходу можно заметить вызов WSAStartup(), инициализирующий использование сокетов в приложении. Перед ним осуществляется вызов процедуры sub_404CAC(), что принимает адрес структуры вместе с константами 400 и 0. Этот вызов очень напоминает memset(), где последние два параметра были поменяны местами. Но я сразу же усомнился в своей теории, ведь MSDN говорит о том, что параметр lpWSAData является строго выходным, что значит нет необходимости обнулять структуру. Код внутри sub_404CAC() довольно большой и использует FPU мнемоники, выглядит как что-то очень оптимизированное. Я увидел закономерность: на протяжении всей функции третий параметр используется исключительно для чтения, а первый (адрес) исключительно для записи, что является ещё одним плюсом к теории про memset(). Я решил посмотреть что происходит с памятью внутри структуры до и после вызова этой процедуры.

(gdb) x/10wx 0x19f190
0x19f190:       0x00000001      0x766010a8      0x380107a7      0x00000047
0x19f1a0:       0x00000009      0x00000004      0x0000000d      0x00000001
0x19f1b0:       0x00000001      0x00000004
(gdb) c
Continuing.

Thread 1 hit Breakpoint 2, 0x004d3a40 in ?? ()
(gdb) x/10wx 0x19f190
0x19f190:       0x00000000      0x00000000      0x00000000      0x00000000
0x19f1a0:       0x00000000      0x00000000      0x00000000      0x00000000
0x19f1b0:       0x00000000      0x00000000
(gdb)

И да, как видно, мусор из стека заполнился нулями. Можно переименовывать процедуру.

В первый параметр WSAStartup() программа помещает 0x101. Основываясь на MSDN, это машинное слово является высшей версией сокетов MS-Windows, которую может использовать вызывающая программа. Если разбить слово, получим два единичных байта. Скорее всего оно было составлено через макрос MAKEWORD(), или его аналог для Delphi.

В случае если инициализировать использование сокетов не удалось, программа вызывает обработчик ошибки, что можно понять по передачи строки "Error start WSA!" в Юникоде. Не знаю почему, но даже согласовав типы строки и аргумента, IDA Pro всё ещё отказывается выводить её в синтаксисе L"Строка", используя вместо этого метку.

После успешной инициализации вызывается sub_4D3934(), которой передаётся переменная, полученная из параметра. Тип переменной был определён как int, что не совсем правильно. Если приглядеться к тому, как она используется, то можно заметить что параметр a1 это ни что иное, как адрес структуры.

  v2 = *(unsigned __int8 *)(a1 + 8);

  ...

  *(_BYTE *)(a1 + 8) = 1;

В фрагментах выше происходит чтение и запись её поля размером в 1 байт и расположенному по смещению 8. Убедиться в размере поля можно либо через тип (внутри каста), либо посмотрев на соответствующие операциям мнемоники.

  movzx ebx, byte ptr [esi+8]

  ...

  mov byte ptr [esi+8], 1

IDA Pro умеет создавать структуры автоматически, основываясь на размере и сдвигах полей. Достаточно лишь навести на интересующую переменную, что является указателем на структуру, затем ПКМ и "Create new struct type...". Однако этот инструмент дополняет структуру массивами, чтобы достичь нужных сдвигов полей, что я нахожу не совсем удобным при дальнейшем редактировании структуры. Поэтому я создал новый тип вручную. Похоже на то, что эта структура является общим хранилищем данных для всего алгоритма, поэтому логично бы было дать ей имя Net_Storage.

Итого процедура выполняет следующие действия:

  1. Если поле в структуре a1 по смещению 8 не равно нулю, тело процедуры не выполняется

  2. В ином случае инициализирует работу с сетью посредствам вызова WSAStartup() (зачем-то предварительно заполняя структуру WSADATA нулями). Если это сделать не удалось, вызывается обработчик ошибки

  3. Вызывает sub_4D3934(). В случае если та вернула не ноль, поле в структуре a1 по смещению 8 вместе с возвращаемой переменной принимают значение 1

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

BOOL __fastcall search_init_internal(struct Net_Storage *storage)
{
  BOOL _is_initialized; // ebx
  struct WSAData wsadata; // [esp+0h] [ebp-198h] BYREF

  _is_initialized = storage->is_initialized;
  if ( !_is_initialized )
  {
    xmemset(&wsadata, 400, 0);
    if ( j_WSAStartup(MAKEWORD(1, 1), &wsadata) )
    {
      handle_error(&storage->error_data, aErrorStartWsa_0);
    }
    else if ( (unsigned __int8)sub_4D3934(storage) )
    {
      storage->is_initialized = TRUE;
      LOBYTE(_is_initialized) = TRUE;
    }
  }
  return _is_initialized;
}

Создание сокета

Следующим шагом было разобраться с процедурой sub_4D3934().

int __fastcall sub_4D3934(struct Net_Storage *storage)
{
  int v2; // ebx
  int v3; // eax
  char optval[4]; // [esp+0h] [ebp-10h] BYREF

  v2 = 0;
  *(_DWORD *)optval = 1;
  v3 = j_socket(2, 2, 17);
  storage->field_C = v3;
  if ( v3 == -1 )
  {
    j_WSACleanup();
    handle_error(&storage->error_data, aErrorInCreatin_0);
  }
  else if ( j_setsockopt(storage->field_C, 0xFFFF, 32, optval, 1) == -1 )
  {
    sub_4D3AAC((int)storage);
    handle_error(&storage->error_data, aErrorSetSocket_0);
  }
  else
  {
    LOBYTE(v2) = 1;
  }
  return v2;
}

С ходу виден вызов socket(), которая создаст непосредственно сам сокет. Магические константы, которые она принимает это ни что иное, как значения каких-то макросов. Обратившись к MSDN можно проверить какие константы соответствуют данным значениям. После замены, вызов стал выглядеть более читаемо.

v3 = j_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

Программа создаёт сокет семейства AF_INET (IPv4), с поддержкой датаграмм и использующий протокол UDP.

Если создать сокет не получилось, программа деинициализирует библиотеку, и передавая строку L"Error in creating socket", вызывает обработчик ошибок.

Затем производится вызов setsockopt(), которая установит некую опцию для сокета. Приведя вызов в человеко-читаемый вид, получилось следующее:

else if ( j_setsockopt(storage->field_C, SOL_SOCKET, SO_BROADCAST, optval, 1) == -1 )

SOL_SOCKET указывает на то, что изменение опции производится на уровне сокетов. В данном случае изменяется опция SO_BROADCAST, которая включает/отключает возможность посылать данные на адреса, что используют броадкаст. Подробнее об этих константах можно почитать либо на MSDN, либо на странице мануала по socket(). Широковещательная отправка позволяет отправить данные сразу на несколько IP-адресов. В самом начале тела процедуры, массив optval из char[4] преобразовывается к DWORD при записи единицы. Декомпилятор неправильно определил тип переменной, на самом деле это опять 4-х байтовый BOOL.

В случае неудачи сначала вызывается sub_4D3AAC(), которая по очереди делает вызовы shutdown(), closesocket() и WSACleanup(), закрывая сокет и деинициализируя работу с сетью соответственно, после чего обработчик ошибок вызывается со строкой L"Error set socket option".

BOOL __fastcall net_create_socket(struct Net_Storage *storage)
{
  BOOL ret; // ebx
  SOCKET socket; // eax
  BOOL enable_brodcast; // [esp+0h] [ebp-10h] OVERLAPPED BYREF

  ret = FALSE;
  enable_brodcast = TRUE;
  socket = j_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
  storage->socket = socket;
  if ( socket == INVALID_SOCKET )
  {
    j_WSACleanup();
    handle_error(&storage->error_data, aErrorInCreatin_0);
  }
  else if ( j_setsockopt(storage->socket, SOL_SOCKET, SO_BROADCAST, &enable_brodcast, 1) == SOCKET_ERROR )
  {
    network_deinit(storage);
    handle_error(&storage->error_data, aErrorSetSocket_0);
  }
  else
  {
    LOBYTE(ret) = TRUE;
  }
  return ret;
}

Итого полная последовательность действий, которую совершает search_init() такова:

  1. Если инициализация работы с сетью уже была произведена ранее, ничего не выполняется

  2. Инициализирует работу с сетью посредствам вызова WSAStartup(). Если этого сделать не удалось, вызывается обработчик ошибок

  3. Создаёт сокет через вызов socket(), использующий протокол UDP. Если создать не получилось, деинициализирует работу с сетью и вызывает обработчик ошибок

  4. Включает опцию, контролирующую возможность использования броадкаст адресов на уроне сокета, используя setsockopt(). В случае неудачи закрывает сокет, деинициализирует работу с сетью и вызывает обработчик ошибок

  5. Если все шаги выше успешно выполнились, ставит поле is_initialized в хранилище в значение TRUE и возвращает TRUE

Составление пакета и широковещательная отправка

С запуском алгоритма я разобрался, теперь на очереди сам алгоритм. Сразу после создания стековой канарейки, search_perform() проверяет была ли произведена успешная инициализация ранее:

...

if ( storage->is_initialized )
  {
    sub_4D3BD8((int)_storage, 0xFFFF, 0, 0xFFFFu, -1);
    if ( sub_4D3B88(_storage, (char *)&_storage[410].socket) )
    {
      j_Sleep_0(0xC8u);

      ...

Если была, последовательно вызываются процедуры sub_4D3BD8() и sub_4D3B88(). От возвращаемого значения последней зависит выполнится ли блок if вложенного условного оператора. Первым же вызовом внутри этого блока идёт задержка на 0xC8 или же 200 миллисекунд.

int __fastcall sub_4D3BD8(int a1, __int16 a2, __int16 a3, unsigned __int16 a4, int a5)
{
  int result; // eax

  *(_DWORD *)(a1 + 10284) = 0;
  xmemset((void *)(a1 + 8212), 2070, 0);
  *(_DWORD *)(a1 + 8212) = 779192668;
  *(_WORD *)(a1 + 8216) = 22;
  *(_WORD *)(a1 + 8218) = a3;
  *(_WORD *)(a1 + 8220) = -1;
  result = a5;
  *(_DWORD *)(a1 + 8222) = a5;
  *(_DWORD *)(a1 + 8226) = a4;
  *(_WORD *)(a1 + 8228) = a2;
  *(_DWORD *)(a1 + 8230) = -1;
  return result;
}

Я ещё не поставил тип структуры a1, но уже увидел что внутри производится инициализация её полей. Только вот сама структура имела гораздо более маленький размер, чем сдвиги на 8000+ байт, которые тут используются. Сразу заметен вызов xmemset(), который заполняет нулями 2070 байт по смещению 8212 в хранилище. Это мог бы быть массив двойных машинных слов, но я заметил, что записи производятся не только в формате DWORD, но и WORD. Гораздо более вероятно, что это ещё одна большая структура, являющаяся одним из полей Net_Storage. Чтобы сдвиг у дочерней структуры был ровно 8212 байт от начала, я добавил в Net_Storage массив выравнивания char padding[8212 - 0x14], сразу после которого разместил само поле типа новой структурыNet_Buffer (размеры и сдвиги полей которой рассчитал на основе присваиваний внутри sub_4D3BD8()):

Структуры, составленные на основе обращения к их полям
Структуры, составленные на основе обращения к их полям

field_E в структуре буфера, в отличии от других полей, собирается из двух машинных слов (верхнего и нижнего), поступающих в качестве аргументов следующим образом:

  storage->buffer.field_E = a4;
  HIWORD(storage->buffer.field_E) = a2;

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

  mov edx, [ebp+arg_4]
  and edx, 0FFFFh

  ...

  mov [ecx+4], edx

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

void __fastcall fill_net_buffer(
        struct Net_Storage *storage,
        __int16 hi_half,
        __int16 a3,
        unsigned __int16 lo_half,
        int a5)
{
  *(_DWORD *)&storage[1].padding[2030] = 0;
  xmemset(&storage->buffer, 2070, 0);
  storage->buffer.field_0 = 0x2E71895C;
  storage->buffer.field_4 = 22;
  storage->buffer.field_6 = a3;
  storage->buffer.field_8 = -1;
  storage->buffer.field_A = a5;
  storage->buffer.field_E = lo_half;
  HIWORD(storage->buffer.field_E) = hi_half;
  storage->buffer.field_12 = -1;
}

Одно из приятных свойств декомпиляции, которое присутствует также и в программировании, и которое мне очень нравится — это сильная взаимосвязь вещей. Чем больше процедур, переменных и типов данных уже было найдено и подписано, тем прозрачнее и логичнее становится вывод дизассемблера и декомпилятора в ранее неизученных или малоизученных местах. Вернувшись к началу search_perform(), теперь видно что в sub_4D3B88() передаётся само хранилище и буфер внутри него.

bool __fastcall sub_4D3B88(struct Net_Storage *storage, struct Net_Buffer *buf)
{
  struct sockaddr to; // [esp+0h] [ebp-1Ch] BYREF

  to.sa_family = 2;
  *(_DWORD *)&to.sa_data[2] = -1;
  *(_WORD *)to.sa_data = j_htons_0(0xDAB8u);
  return j_sendto(storage->socket, (const char *)buf, (unsigned __int16)buf->field_4, 0, &to, 16) != -1;
}

Первое на что я обратил внимание — довольно странно, что несмотря на присутствие структуры, при обращении к её полям происходят какие-то странные касты, которых по идее быть не должно. Структуры как раз и нужны для того, чтобы удобно читать и записывать информацию конкретного фрагмента памяти, не прибегая к преобразованиям типов. Всё дело в том, что struct sockaddr — это не та структура, которая изначально была использована программистом. Декомпилятор увидел, что адрес структуры передаётся в параметр sendto(), который имеет тип struct sockaddr *. struct sockadrr это независимая от протокола структура адреса размером в 16 байт. Для заполнения IPv4 адреса следует использовать совместимую с ней структуру struct sockaddr_in, которая имеет такой же размер. После замены типа структуры и магических констант на макросы получается аккуратный вывод:

bool __fastcall broadcast_net_buffer(struct Net_Storage *storage, struct Net_Buffer *buf)
{
  struct sockaddr_in sin; // [esp+0h] [ebp-1Ch] BYREF

  sin.sin_family = AF_INET;
  sin.sin_addr.S_un.S_addr = 0xFFFFFFFF;
  sin.sin_port = j_htons_0(0xDAB8u);
  return j_sendto(
           storage->socket,
           (const char *)buf,
           (unsigned __int16)buf->field_4,
           0,
           (const struct sockaddr *)&sin,
           16) != -1;
}

Структура сохраняет в себе IP-адрес, порт и семейство сокета. В данном случае программа использует семействоAF_INET. Сам адрес 0xFFFFFFFF имеет все единичные биты, в точечной нотации выглядел бы как 255.255.255.255. Из-за включённой ранее опции SO_BROADCAST для сокета внутри хранилища, приложение попытается отправить пакет разом на все возможные IP-адреса.

Константа 0xDAB8, перед тем как присвоится соответствующему полю, проходит через функцию htons() (Host TO Network Short). Это одна из семейства функций конвертирования данных между порядком байт хоста и сетевым порядком байт. По соглашению данные в сети передаются в порядке big endian. Если у хоста порядок байт little endian, функция поменяет байты местами, в ином случае вернёт значение как есть.

Затем вызов sendto() отправит ранее собранный буфер, основываясь на информации в структуре sin. Я обратил внимание, что отправляемый размер буфера контролируется его полем field_4. Переименовал его, и вернувшись к fill_net_buffer() увидел что размер отправляемого пакета всегда константный, ровно 22 байта.

Получение ответов

Следом после отправки и небольшой задержки в 200 миллисекунд, поток исполнения доходит до цикла, в начале тела которого сразу же происходит вызов sub_4D3ACC():

      ...
  
      do
      {
        v1 = sub_4D3ACC((int)_storage, (int)&a2, &v22, _storage->padding, 0x1FFF, &v21, 500);
        if ( v1 )
        {

          ...

        }
      }
      while ( v1 );

      ...

Я предположил, что вызываемая процедура каким-то образом извлекает информацию о следующем найденном устройстве. И если очередное устройство было найдено, та вернёт ненулевое значение, в следствии чего информация о нём обработается внутри тела условного оператора и цикл совершит ещё одну итерацию.

int __fastcall sub_4D3ACC(struct Net_Storage *storage, int a2, _DWORD *a3, char *buf, int len, int *a6, int a7)
{
  int v9; // ebx
  char *v10; // eax
  struct sockaddr from; // [esp+Ch] [ebp-128h] BYREF
  fd_set readfds; // [esp+1Ch] [ebp-118h] BYREF
  int fromlen; // [esp+120h] [ebp-14h] BYREF
  struct timeval timeout; // [esp+124h] [ebp-10h] BYREF
  _DWORD *v16; // [esp+12Ch] [ebp-8h]

  v16 = a3;
  v9 = 0;
  timeout.tv_sec = 0;
  timeout.tv_usec = 1000 * a7;
  sub_4CF240(&readfds);
  unknown_libname_604(storage->socket, (int *)&readfds);
  if ( j_select(2080, &readfds, 0, 0, &timeout) > 0 )
  {
    fromlen = 16;
    *a6 = j_recvfrom(storage->socket, buf, len, 0, &from, &fromlen);
    v10 = j_inet_ntoa(*(struct in_addr *)&from.sa_data[2]);
    unknown_libname_30(a2, v10);
    *v16 = j_htons(*(u_short *)from.sa_data);
    LOBYTE(v9) = 1;
  }
  return v9;
}

Вместе с остальными локальными переменными, подпрограмма инициализирует структуру struct timeval, которая описывает некий временной промежуток.

Если вы знакомы с сетевым программированием на MS-Windows/*NIX системах, то знаете что select() требует больше подготовки для вызова, в сравнении с другими функциями. Системный вызов select() был разработан для того, чтобы сообщить программе о том, что с одним или несколькими из множества сокетов произошло какое-либо событие — появилась возможность прочитать/записать данные, или же какой-то из них вызвал исключение. Другими словами он находит себе применение в одновременном мониторинге сразу нескольких сокетов ядром операционной системы, снимая ответственность за выполнение этой задачи с программы в юзерлэнде. Но в данном случае приложение использует select() для ожидания события одного сокета, что является не менее распространённой практикой, однако устаревшей. poll() был разработан как более оптимальная замена select().

Максимальное время ожидания события описывается структурой, состоящей из двух полей. tv_sec соответствует секундам, а tv_usec — микросекундам. Умножая параметр a7 на 1000, программа конвертирует микросекунды в миллисекунды, что значит a7 является таймаутом в миллисекундах.

Первая вызываемая функция sub_4CF240() принимает адрес структуры множества сокетов и просто обнуляет первое её поле fd_count одной инструкцией mov. Это поведение очень напоминает макрос FD_ZERO(), что обнуляет структуру множества сокетов. Переименовал её соответствующе.

void __fastcall FD_ZERO(fd_set *set)
{
  set->fd_count = 0;
}

Следующая вызываемая процедура unknown_libname_604() была даже распознана декомпилятором как часть библиотеки, однако тот не смог сопоставить код функции с её именем. Логично, что после обнуления множества, программа должна добавить новый сокет в него перед вызовом select(). Обычно используется макрос FD_SET(), а код процедуры очень напоминает его реализацию:

// Delphi2006/BDS2006 Visual Component Library
void __fastcall FD_SET(SOCKET fd, fd_set *set)
{
  if ( (int)set->fd_count < 64 )
    set->fd_array[set->fd_count++] = fd;
}

select() принимает в себя три множества сокетов — множество для проверки возможности чтения, множество для проверки возможности записи и множество для проверки событий исключений, именно в таком порядке.

  if ( j_select(2080, &readfds, NULL, NULL, &timeout) > 0 )

Как видно из вызова, программа использует только первое множество, для чтения, передавая в параметры-адреса остальных множеств нулевые указатели. Моё внимание привлёк первый параметр, куда передаётся константа 2080. nfds аргумент select() в *NIX системах должен принимать значение самого большого сокета из всех множеств. В MS-Windows же этот параметр не используется вовсе, а MSDN говорит что он оставлен лишь для совместимости АБИ с BSD сокетами и игнорируется системой. Если бы разработчики программы опирались на документацию из мануалов Linux систем, то вероятно передали бы сюда storage->socket + 1, в ином случае просто ноль. У меня нет ни малейшего предположения и догадок насчёт того, для чего программисты решили передать именно 2080.

Вызов select() в данном случае блокирующий, он останавливает выполнение программы либо до максимального времени ожидания, либо до того как появятся новые данные для чтения на сокете. Во втором случае select() вернёт 1 (один сокет получил событие), что удовлетворит условие и тело условного оператора выполнится. recvfrom() получит данные из сокета вместе с IP-адресом, который их отправил. Максимальное количество полученных байт передаётся через параметр len (8191 байт), а буфер через параметр buf, который в данном случае находится где-то в структуре хранилища. Кое-что мне напомнило это число...

Вернувшись к структуре struct Net_Storage, я понял, что выравнивание размером в 8192 байта на самом деле было ни чем иным, как буфером для принимаемых по сети данных. Правда на этом этапе было не совсем понятно почему буфер на один байт больше, чем максимальный размер читаемых данных, передаваемый в recvfrom(). Вероятно это ещё одно поле размером в один байт, принадлежащее struct Net_Storage, но точно не struct Net_Buffer, ведь адрес этой структуры передаётся в xmemset(). Крайне маловероятно что программа обнуляет структуру целиком, за исключением первого поля.

    v10 = j_inet_ntoa(*(struct in_addr *)&from.sa_data[2]);

Опять неурядица с преобразованиями типа поля структуры. Я разобрался с этим повнимательнее и заметил ту же проблему, что уже встречалась ранее — декомпилятор вновь ошибочно предположил тип структуры с адресом. Функция inet_ntoa() конвертирует IPv4 адрес из 4-х байтового двоичного числа в читаемую строку вида "XXX.XXX.XXX.XXX". Поменял тип структуры, вывод стал в разы чище, теперь видно что приложение преобразовывает в строку адрес, полученный из recvfrom():

    v10 = j_inet_ntoa(from.sin_addr);

Полученная строка v10 передаётся в очередную нераспознанную библиотечную функцию unknown_libname_30(). Она принимает два параметра — полученную строку и адрес, приходящий из аргумента вызывающей функции. Похоже на то, что внутри сначала идёт сильно оптимизированный цикл вычисления длины строки, а затем вызывается ещё пара процедур. В конечном счёте поток исполнения доходит до проприетарного вызова MultiByteToWideChar(), который преобразовывает строку из определённой кодировки в Юникод. Выглядит так, будто эта функция не имеет особо важной роли, поэтому пока остановился на этом предположении.

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

BOOL __fastcall next_device(struct Net_Storage *storage, LPCWSTR *ip_u16, int *port, char *buf, int len, int *recv_len, int timeout_ms)
{
  BOOL ret; // ebx
  char *ip_cstr; // eax
  struct sockaddr_in from; // [esp+Ch] [ebp-128h] BYREF
  fd_set readfds; // [esp+1Ch] [ebp-118h] BYREF
  int fromlen; // [esp+120h] [ebp-14h] BYREF
  struct timeval timeout; // [esp+124h] [ebp-10h] BYREF
  int *_port; // [esp+12Ch] [ebp-8h]

  _port = port;
  ret = FALSE;
  timeout.tv_sec = 0;
  timeout.tv_usec = 1000 * timeout_ms;
  FD_ZERO(&readfds);
  FD_SET(storage->socket, &readfds);
  if ( j_select(2080, &readfds, NULL, NULL, &timeout) > 0 )
  {
    fromlen = 16;
    *recv_len = j_recvfrom(storage->socket, buf, len, 0, (struct sockaddr *)&from, &fromlen);
    ip_cstr = j_inet_ntoa(from.sin_addr);
    cstr_to_u16(ip_u16, ip_cstr);
    *_port = j_htons(from.sin_port);
    LOBYTE(ret) = TRUE;
  }
  return ret;
}

Проверка ответов

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

  ...

  if ( storage->is_initialized )
  {
    fill_net_buffer(_storage, 0xFFFF, 0, 0xFFFFu, -1);
    if ( broadcast_net_buffer(_storage, &_storage->buffer) )
    {
      j_Sleep_0(0xC8u);

      ...

      do
      {
        got_device = next_device(_storage, &ip_u16, &port, _storage->recv_buffer, 8191, &recv_len, 500);
        if ( got_device && recv_len > 10 && port == 55992 )
        {
          _storage->recv_buffer[recv_len] = '\0';
          if ( (unsigned __int8)sub_4D3C7C((int)_storage, (int)_storage->recv_buffer, recv_len, v19, v20, v18, 1) )
          {

            ...

          }
        }
      }
      while ( got_device );

      ...
      
      TObject::Free(v18);
    }
  }

  ...

Теперь понятно для чего нужен был тот лишний байт в буфере приёма внутри структуры хранилища — чтобы окончить принятый массив нулевым байтом, как строку. Это выступает в роли некой подсказки о том, что вероятно программа ожидает данные в виде строки. И здесь же, в условии видно, что программа ожидает пакет не менее 11 байт (символов) и чтобы он был отправлен используя порт 55992.

Я решил проверить, сработает ли отправка 11 символов на указанный порт, запустив netcat в режиме UDP сервера.

$ echo -n "AAAAAAAAAA" | nc -u -l -p 55992

И нет, к сожалению после нажатия на кнопку никаких новых записей в таблице не появилось. Зато перед этим программа отправила ранее сконструированный на её стороне буфер, что означает то, что и порт и протокол были выбраны правильно. Выведя полученные на стороне сервера данные, я увидел двоичную структуру, как и ожидалось размером ровно 22 байта:

5c 89 71 2e 16 00 00 00 ff ff ff ff ff ff ff ff
ff ff ff ff ff ff

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

(gdb) b *0x4D36CA
Breakpoint 1 at 0x4d36ca
(gdb) c
Continuing.
[Thread 38836.0x7310 exited with code 0]
[New Thread 38836.0xa88]
[New Thread 38836.0x9318]
[Switching to Thread 38836.0x7980]

Thread 1 hit Breakpoint 1, 0x004d36ca in ?? ()

(gdb) x/11bx *(void **)($ebp - 8) + 0x14
0x24352b4:      0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x24352bc:      0x41    0x41    0x41
(gdb)

Буквы 'A', отправленные сервером, успешно записались в массив внутри хранилища. sub_4D3C7C() затем проверяет эти данные и решает, будет ли ответ воспринят как корректный ответ устройства, или же нет. Я понимал что очень близок к победе, ведь возвращаемое значение этой процедуры является последним условием на пути к добавлению новой записи.

Переименовал sub_4D3C7C() в validate_packet() и увидел, что буфер с принятыми данными используется как структура, а не как строка. Возможно даже та же самая структура, которую приложение отправляет.

  ...

  v7 = _InterlockedExchange((volatile __int32 *)&_recv_buffer, recv_len);
  _recv_buffer = recv_buffer;
  v8 = (int)a4;

  ...

  ret = FALSE;
  (*(void (__fastcall **)(_DWORD *, _DWORD))(*a6 + 68))(a6, *a6);
  *a4 = 0;
  a4[1] = 0;
  *a5 = 0;
  v35 = 0;
  if ( v7 >= 22 )
  {
    size = recv_buffer->size;
    if ( v7 == size && size <= 0xFFu )
    {
      v11 = (size - 22) / 8;
      if ( size == 8 * v11 + 22 )
      {
        v12 = (unsigned __int16)recv_buffer->field_E;
        *a4 = recv_buffer->field_A;
        a4[1] = v12;
        *a5 = (unsigned __int16)recv_buffer->field_6;
        if ( !a7 || a4[1] != 0xFFFF || *a4 != -1 )
        {
          ret = TRUE;

          ...

_InterlockedExchange() — это интринсик от Microsoft для инструкции xchg. Другими словами первый вызов на самом деле не является вызовом: так декомпилятор сообщает о том, что переменные _recv_buffer и recv_len обмениваются значениями. Сама мнемоника в коде выглядит так:

  xchg ecx, [ebp+_recv_buffer]
  
  ...

  mov edi, ecx

В результате этой операции, v7 принимает значение recv_len(поступающей из параметра), ведь _InterlockedExchange() возвращает свой второй параметр. Это также видно в листинге: переменная v7 хранится в регистре edi.

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

    size = recv_buffer->size;
    if ( v7 == size && size <= 0xFFu )

И это странно, ведь зачем тогда программа выделяет буфер принимаемых данных на 8 Кб, если любой пакет больше 255 байт автоматически считается некорректным. И даже если этот буфер, помимо поиска используется также и в других частях программы, загадкой остаётся максимальное количество принимаемых данных, передающееся в recvfrom() и равное 8 Кб.

      v11 = (size - 22) / 8;
      if ( size == 8 * v11 + 22 )

Сначала это условие показалось мне немного странным, тут производятся разные арифметические операции над размером пакета. Но приглядевшись, я понял, что всё это нужно для проверки делимости на 8 оставшейся части пакета, не считая первых 22 байт. Похоже на то, что первые 22 байта обязательны потому, что являются заголовком пакета, а размер тела должен быть кратен 8 из-за того, что является массивом из __int64. Если в первой строке целочисленное деление выполнится с остатком, то в ходе выполнения обратных операций получится уже совсем другой результат, что заставит условие провалится. Эти действия можно было гораздо более кратко записать через оператор взятия остатка так: !((size - 22) % 8).

        v12 = (unsigned __int16)recv_buffer->field_E;
        *a4 = recv_buffer->field_A;
        a4[1] = v12;
        *a5 = (unsigned __int16)recv_buffer->field_6;
        if ( !a7 || a4[1] != 0xFFFF || *a4 != 0xFFFFFFFF ) {
          ret = TRUE;
          
          ...

Изначально, декомпилятор представил константу 0xFFFFFFFF как знаковое десятичное число -1, поэтому я исправил это. Последнее условие состоит из трёх частей, разделённых оператором "ИЛИ". Если хотя бы одна из них выполнится, то и условие целиком выполнится, а приложение воспримет пакет как правильный. Первое условие !a7 всегда будет проваливаться, ведь в седьмой параметр передаётся константа 1, вероятно флаг. Эта функция во всей программе используется только два раза, в обоих её вызовах a7 принимает единицу, что значит в обоих случаях условие будет провалено ¯\_(ツ)_/¯. Следующее условие проверяет нижнее машинное слово field_E полученного буфера на заполненность единицами, а последнее — field_A полученного буфера на заполненность единицами.

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

  • Пакет был получен используя UDP порт 55992

  • Размер пакета больше, либо равен 22-м байтам

  • Фактический размер пакета равен логическому, записанному внутри

  • Размер пакета не превышает 255 байт

  • Размер тела пакета (если таковое имеется) кратен 8-ми байтам

  • Либо верхняя половина поля заголовка field_E, либо поле field_A должно содержать как минимум один нулевой бит

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

echo -ne "\xAA\xAA\xAA\xAA\x16\x00\xBB\xBB\xCC\xCC\xDD\xDD\xDD\xDD" \
         "\xEE\xEE\xFF\xFF\x55\x55\x55\x55" | nc -u -l -p 55992

Это поможет найти соответствия между полями и отображаемыми данными. После нажатия на кнопку "Поиск", на экране появилась заветная строка в таблице, говорящая о том, что было найдено новое устройство.

В качестве IP здесь отображается адрес моего локального хоста, на котором и был запущен сервер. Из того как распарсились данные пакета видно, что MAC-адрес на уровне приложения составляется из двух частей, несмотря на то, что в самом пакете он хранится неразрывно. Теперь понятно почему приложение делило поле field_E на верхнее и нижнее машинное слово, ведь оно было второй, оставшейся частью MAC-адреса. А если перевести значение идентификатора оборудования в шестнадцатеричную систему, то получится 0xFFFF, что соответствует верхней половине field_E. Ещё непонятным осталось и то, почему поля "Порт TCP" и "Порт UDP" остались нулевыми, в то время как в отправленном пакете отсутствуют нулевые поля. Я предположил, что их значения извлекаются из тела пакета, которого в моём случае нет.

Конфигурационный IP-адрес в панельке ниже принял значения 85, что соответствует байтам 0xAA, или же полю field_0.

Тело пакета

Задача по разбору алгоритма поиска фактически выполнена, на этом можно было бы и закончить. Но мне хотелось понять, что же это всё-таки за дополнительные значения, которые можно передать программе. Вот так я собрал пакет с телом:

#   define OPTIONAL_SIZE   10

    struct __attribute__((__packed__)) Packet_Header {
        uint32_t ipv4;
        uint16_t size;
        uint16_t reserved1;
        uint16_t reserved2;
        uint32_t reserved3;
        uint32_t mac_first;
        uint32_t mac_second_hwid;
    };
    _Static_assert(sizeof(struct Packet_Header) == 0x16u);

    const size_t size = sizeof(struct Packet_Header) + 8 * OPTIONAL_SIZE;
    uint8_t p[size];

    struct Packet_Header *ph = (struct Packet_Header *)p;
    memset(ph, 0, sizeof *ph);
    ph->size = size;

    const uint64_t templates[] = {
        0xAAAAAAAAAAAAAAAA,
        0xBBBBBBBBBBBBBBBB,
        0xCCCCCCCCCCCCCCCC,
        0xDDDDDDDDDDDDDDDD,
        0xEEEEEEEEEEEEEEEE,
        0xFFFFFFFFFFFFFFFF
    };

    for (int i = 0; i < OPTIONAL_SIZE; i++)
        ((uint64_t *)(p + sizeof(struct Packet_Header)))[i] = templates[i % 6];

    fwrite(p, 1, size, stdout);

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

Если пролистать вывод декомпилятора чуть ниже по телу функции validate_packet(), то можно заметить цикл, выполняющийся столько раз, сколько элементов по 8 байт осталось в пакете:

          v13 = v11 - 1;
          if ( v13 >= 0 )
          {
            v38 = v13 + 1;
            v8 = 0;
            do
            {
              v13 = *((unsigned __int16 *)&recv_buffer[1].ipv4 + 4 * v8);
              if ( (_WORD)v13 == 1 || (_WORD)v13 == 2 )
              {

                ...
                
              }
              else
              {

                ...

              }
              ++v8;
              --v38;
            }
            while ( v38 );
          }

v11 ранее сохраняет в себе результат выражения (size - 22) / 8, что эквивалентно количеству дополнительных полей. Если таковые имеются, цикл внутри условного оператора начинает исполняться, выделяя по итерации на каждое из этих чисел. Следующее извлечённое число сохраняется в v13, однако способ довольно странный: берётся следующая за recv_buffer структура, затем происходит обращение к полю ipv4. Этот адрес приводится к адресу на 16-битное число, к нему прибавляется четвёрка умноженная на v8... Отчётливо видно, что v8 — это итератор цикла. Из-за того, что декомпилятор не знает, что за последним полем struct Net_Storage может следовать ещё и массив чисел, он предположил что за этой структурой следует такая же. Добавив последним полем __int64 optional[1], я получил результат, больше похожий на правду:

              v13 = LOWORD(recv_buffer->optional[v8]);

В зависимости от нижнего машинного слова извлечённого числа исполняется либо блок if, либо блок else условного оператора. В обоих случаях происходят примерно одни и те же действия — сначала верхнее двойное машинное слово полученного значения конвертируется в строку, а далее эта строка модифицируется и сохраняется в объект a6 каким-то виртуальным методом.

В паре шаблонов я попробовал поставить верхнее машинное слово в значения 1 и 2 соответственно, что должно удовлетворить условие в обоих случаях:

    const uint64_t templates[] = {
        0xAAAAAAAAAAAA0001,
        0xBBBBBBBBBBBB0002,
        0xCCCCCCCCCCCCCCCC,
        0xDDDDDDDDDDDDDDDD,
        0xEEEEEEEEEEEEEEEE,
        0xFFFFFFFFFFFFFFFF
    };

Сформировав и отправив пакет по тому же принципу, что и раньше, сразу же заметны изменения в группе элементов "Конфигурация":

Маска подсети имеет все 4 байта в значении 170, что соответствует 0xAA, а переведённые в шестнадцатеричную систему байты шлюза станут 0xBB. Программа использует именно верхние части __int64 как значения для шлюза и маски подсети.

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

              else
              {
                IntToStr(current_optional, &v27);
                IntToStr_0(&v26, HIDWORD(recv_buffer->optional[i]), 0);
                v16 = v26;
                sub_407E4C(&v28, 3, (int)recv_buffer, current_optional, i);
                (*(void (__fastcall **)(_DWORD *, int, _DWORD, int *, LPCCH))(*a6 + 56))(
                  a6,
                  v28,
                  *a6,
                  &dword_4D3F50,
                  v16);
                if ( LOWORD(recv_buffer->optional[i]) == 4 )
                  v39 = 1;
              }

Сначала нижнее машинное слово очередного числа конвертируется в строку v27 вызовом IntToStr(). Затем верхнее двойное машинное слово конвертируется похожим вызовом в строку v26.

Процедура sub_407E4C() на вход принимает структуру буфера с принятыми данными, текущее число, итератор и v28. Не заглядывая внутрь процедуры, я предположил, что та вернёт результат в v28, скорее всего как строку. Я поставил точку останова ровно на вызове процедуры. После возврата из неё, в v28 действительно записался адрес строки L"52428=3435973836". Функция перевела числа в десятичную систему и склеила две полученные строки в пару L"<ключ>=<значение>".

Итого общая структура числа из тела пакета такова:

+----------------+------------+------------+
| Данные         | ???        | Тип данных |
+----------------+------------+------------+
^                ^            ^
|                |            |
8 байт           4 байта      2 байта

В самом конце блока else тип данных проверяется на 4, и в случае если условие верно, флаг v39 ставится в единицу. Чуть позже от его значения зависит исполнение этого фрагмента, схожего с теми, что были в обоих блоках условного оператора выше:

          if ( !v39 )
          {
            IntToStr(4, &v24);
            IntToStr_0(&v23, recv_buffer->mac_second_hwid, 0);
            v17 = v23;
            key_value(&v25, 3, (int)recv_buffer, current_optional, i);
            (*(void (__fastcall **)(_DWORD *, int, _DWORD, int *, LPCCH))(*a6 + 56))(a6, v25, *a6, &dword_4D3F50, v17);
          }

Я поменял идентификатор типа данных третьего шаблона на 4 (получив 0xCCCCCCCCCCCC0004), сформировал и отправил новый пакет, после чего конфигурационный IP-адрес принял новое значение.

Таким же экспериментальным путём, перебирая различные идентификаторы типов данных, я нашёл следующие типы:

Идентификатор

Значение

0x0001

Маска подсети

0x0002

Шлюз

0x0004

IP адрес (если присутствует, то заменяет поле из заголовка)

0x0005

Адр.

0x0006

Порт UDP

Извлечённый алгоритм

Корректный принимаемый пакет обладает следующей структурой:

Сам алгоритм поиска описывается так:

  1. Проинициализировать работу с сетью

  2. Создать UDP сокет

  3. Включить опцию SO_BROADCAST, чтобы получить возможность отправлять данные на broadcast адреса

  4. Сконструировать некий пакет на стороне приложения

  5. Используя широковещательную отправку отправить его всем на порт 55992

  6. Ждать, пока появится возможность прочесть данные из сокета

  7. Если никаких данных за 500 миллисекунд получено не было, остановить поиск. В ином случае считать полученные данные и запомнить адрес отправителя

  8. Проверить пакет на правильность

  9. Распарсить данные внутри пакета и запомнить их

  10. Создать новый элемент в таблице обнаруженных устройств

  11. Вернуться к шагу 6

И всё-таки остались тайны, которые вероятно никогда не будут разгаданы:

  • Зачем структура WSADATA заполняется нулями перед передачей в WSAStartup()?

  • Почему в параметр nfds процедуре select() передаётся 2080?

  • Почему буфер принимаемых данных занимает 8 Кб, в то время как максимально допустимая длина пакета исходя из проверок — 255 байт?

Итоги

После того, как я полностью и в деталях разобрался в коде алгоритма, я переписал его, немного изменив и адаптировав определённые моменты под своё окружение. Все остались довольны проделанной работой.

Этот случай является хорошим примером того, что обратная разработка может пригодиться где угодно и её применение не ограничивается поиском уязвимостей, анализом вредоносного ПО или другими распространёнными ситуациями. Декомпилировать данный экземпляр конечно было сложнее, чем если бы программа была написана на том же C++, как минимум из-за того, что Delphi использует собственное соглашение о вызовах __register (определяемое IDA Pro как __fastcall или __usercall), а также из-за того, что часто приходилось обращаться к официальной документации за правильными заголовками библиотечных функций. Можно было бы разобрать приложение ещё глубже, может даже найти уязвимости, и если таковые имеются, даже написать игрушечный эксплойт. Но это уже не входит в рамки данной статьи.

В любом случае оставлю это здесь, надеюсь кто-нибудь найдёт эту информацию полезной или даже узнает для себя что-нибудь новое.

Теги:
Хабы:
+11
Комментарии3

Публикации