Конечно же, взломал – громко сказано, но заголовок рождён эмоциями :-)
Эта история о том, как лень заставила меня окунуться в реверс-инжиниринг бинарного файла адресной книги Radmin (.rpb).
Внутри – странные заполнители, контрольные суммы, таинственные временные метки и структуры данных, где папки и компьютеры имеют одинаковый размер и бескрайние просторы нулей – о мои глаза!
Результат – opensource утилита для конвертации между RPB и JSON, возможно кому-то пригодится.
Представьте: вы – ответственный за парк из сотен компьютеров. Вам нужно актуализировать таблицу имен и IP-адресов компьютеров или адресную книгу Radmin, или, как в моём случае, использовать список этих записей в другом ПО. Работа трудоёмкая, но я – человек не ленивый, но оптимизированный. Вводить вручную имя, IP, порт, настройки для каждого компьютера – рутина, однообразное щёлканье кнопок! Кажется, жизнь пролетает впустую, так я подумал, когда представил, что мне предстоит это сделать.
Вот и мне довелось писать внутренние ПО по массовому контролю доступности и сбору инфы, а в основном, по массовому сетевому копированию и развертыванию с использованием промежуточных групповых серверов. И уже на этапе бета-тестирования от меня потребовалось внесение информации о сотнях сетевых устройств.
«Эврика! – подумал я. – У меня же есть Radmin, а там есть все адреса! Я экспортирую их и использую себе во благо!»
Эврика длилась ровно до момента, когда я обнаружил, что Radmin экспортирует адресную книгу только в свой собственный формат - rpb, добавили бы csv, с моей точки зрения логично, «Спасибо, разработчики». Документации? Конечно нет, это же не api и не опенсорс.
Глава 1: Первый контакт и старый друг — Hex-редактор (Notepad++)
Первым делом я, как и любой уважающий себя исследователь, открыл файл rpb в hex-редакторе.
Картина предстала замысловатая: сначала шёл набор данных, похожий на какой-то заголовок, а потом – большие разрывы нулевых значений с изредка попадающимися данными, очень похожими на записи. Как тут что-то разобрать? Откуда что читать?
На просторах интернета я нашел статью, где энтузиаст разбирал импорт адресной книги Radmin. Но увы, и эта радость была скоротечной, с текущей версией Radmin ничего не сошлось, ни данные заголовка, ни записей, видимо статья значительно устарела, видимо разработчики не спали и сделали запил на книге в новой версии, ... либо я ничего не понял )
НО Азарт уже проснулся. Остановиться было невозможно.
Пришлось действовать самостоятельно.
Рождение идеи: Исходя из изученного hex, попыток экспорта пустой книги, книги с одной записью, двумя и т.д. удалось выяснить размер заголовка, начальный адрес записи и размер записи. Так я решил, что нужно написать анализатор, который поможет понять структуру файла.
Начало анализатора было совсем простым. Он просто читал байты, а я пытался найти в них хоть что-то осмысленное.
static void AnalyzeRpb(string filePath)
{
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
using (var br = new BinaryReader(fs))
{
// ---------- Заголовок ----------
fs.Seek(0, SeekOrigin.Begin);
// Читаем данные заголовка по 4 байт
int version = br.ReadInt32(); // 0x00 Видимо версия
int chksumRecCounts = br.ReadInt32(); // 0x04 Какое-то число
int rbTimestamp = br.ReadInt32(); // 0x08 Таймстамп? Загадка!
int recordSize = br.ReadInt32(); // 0x0C Размер блока записи
int startSize = br.ReadInt32(); // 0x10 Размер заполнителя
int recordCount = br.ReadInt32() + 1; // 0x14 Кол-во записей
int startByte = br.ReadByte(); // 0x18 Начальный байт заполнителя
byte[] startRecords = br.ReadBytes(startSize); // 0х19 Заполнитель, 01 каждый байт на запись
Console.WriteLine($"0x19 Заполнитель 128байт : {BitConverter.ToString(startRecords)}");
// ---------- Данные ----------
long dataStart = fs.Position;
fs.Seek(dataStart, SeekOrigin.Begin);
for (int i = 0; i < recordCount; i++)
{
long recordStart = fs.Position;
byte[] record = br.ReadBytes(recordSize);
Console.WriteLine($"=== Запись {i + 1} (абс. 0x{recordStart:X})");
FindUtf16Strings(record); // Процедура поиска всех UTF16LE
AnalyzeSpecificAddresses(record); // Процедура чтения записей
Console.WriteLine();
}
}
}
Уже на этом этапе меня ждал сюрприз. Но это не поле recordCount, что хранило значение N-1, тут мне, как программисту, все понятно – все индексы начитаются с 0. Размер блока записи тоже сошёлся с предполагаемым, найденным при вычислении отличий пустой книги от книги с одной записью. И даже не chksumRecCounts, который я вычислил так же путем сравнения и вычислений, об этом дальше.
Главное найдено, это начало, то есть смещение, и размер блока записей, мне же просто нужны записи. На этом этапе я начал проводить разбор записей, а к заголовку вернулся позже, при реализации обратной функции сборки rpb файла. Но, чтобы не разрывать части кода в повествовании и не создавать мешанину, я последовательно разберу части кода.
Глава 2: Странный заголовок и заполнитель из единиц
Заголовок файла — история с юморком.
Контрольная сумма? chksumRecCounts оказалось равно количество_записей * 3 + 1. Зачем? Неизвестно. Может, это ритуал такой.
Заполнитель, так я его назвал. Может кто в курсе, что это за подход или остатки пережитка предыдущих версий? После поля recordSize шло поле startSize (размер этого заполнителя), после recordCount поле startByte которое всегда равно 0x01, а уже после него шёл блок байтов размером 128 байт (startSize). И он был заполнен единицами (0x01) — по одной на каждую запись в книге! Но самое весёлое началось, когда записей стало больше 128. Массив единиц не рос и не изменялся! Он оставался размером 128 байт, а новые записи просто в нём не отмечались. Победа оптимизации над логикой.
Но главной загадкой стало поле rbTimestamp.
Глава 3: Загадка Времени: точкой отсчёта был, не рассвет, а bootime
rbTimestamp — это 4 байта, которые выглядели как случайное большое число. Переводя его в миллисекунды или в разные форматы, либо отсчитывая от какой-либо начальной точки, я получал странные значения, не дающие повода привязаться хоть к какому-то точному времени. Это не было похоже на Unix time или что-либо знакомое.
Но сохраняя одну и ту же книгу в разное время, точно определил временную зависимость.
Тут мне пришло в голову сохранить адресную книгу с точно определенной разницей в несколько секунд. И что я обнаружил, что переведенная в миллисекунды разница примерно соответствовала заданному времени с некоторой погрешностью, которую можно списать на сам процесс записи файла. Значит, это точно время. Но время от какого момента? Почему вчерашние записи или даже сегодняшние имели меньшее значение, чем ранние из них?
Прозрение пришло внезапно. А что если... это время от последней загрузки системы? Вот же, я недавно ребутал!
Проверка подтвердила догадку. Radmin в заголовке файла хранил миллисекунды, прошедшие с момента последнего ребута Windows. Это было безумно или гениально, как Вы думаете?
Разработчики Radmin, вы подарили мне незабываемые моменты.
Глава 4: В сердце тьмы — разбор структуры записи
С заголовком разобрались, пора было приступать к трудоемкой по объему части — разбору структур записей. Каждая запись имела фиксированный размер — 6138 байт (0x17FA). Это много.
Методом научного тыка (создания записей с разными настройками в Radmin и сравнения hex-дампов) я начал составлять карту.
Мне очень помогла процедура поиска всех записей формата UTF16LE строк (символы + 00)
static void FindUtf16Strings(byte[] data)
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < data.Length - 1; i += 2)
{
byte lo = data[i];
byte hi = data[i + 1];
char c = (char)(hi << 8 | lo);
if (c == '\0')
{
if (sb.Length > 0)
{
Console.WriteLine($"0x{i - sb.Length * 2:X5}: \"{sb.ToString()}\"");
sb.Length = 0;
}
}
else if (char.IsLetterOrDigit(c) || char.IsPunctuation(c) || c == ' ' || c == '.')
{
sb.Append(c);
}
else if (sb.Length > 0)
{
Console.WriteLine($"0x{i - sb.Length * 2:X5}: \"{sb.ToString()}\"");
sb.Length = 0;
}
}
if (sb.Length > 0)
Console.WriteLine($"0x{data.Length - sb.Length * 2:X5}: \"{sb.ToString()}\"");
Console.WriteLine();
}
Вот что меня ждало внутри одной записи при чтении с адреса относительно начала записи:
static void AnalyzeSpecificAddresses(byte[] record)
{
uint fps = BitConverter.ToUInt32(record, 0x0000); // 1-2000
uint screenmode = BitConverter.ToUInt32(record, 0x0004); // 0-3
uint colormode = BitConverter.ToUInt32(record, 0x0008); // 0-5
uint kodekeyon = BitConverter.ToUInt32(record, 0x000C); // 0-1
uint nullInt1 = BitConverter.ToUInt32(record, 0x0010); // 0 ?
uint nullInt2 = BitConverter.ToUInt32(record, 0x0014); // 0 ?
uint cursormode = BitConverter.ToUInt32(record, 0x0018); // 0-2
uint unknown1 = BitConverter.ToUInt32(record, 0x001C); // 1 ?
// 108 пустых байт
uint unknown2 = BitConverter.ToUInt32(record, 0x008C); // 1 ?
uint nullInt3 = BitConverter.ToUInt32(record, 0x0090); // 0 ?
uint voicetune = BitConverter.ToUInt32(record, 0x0094); // 0-5
string uservoice = ReadUtf16String(record, 0x0098); // размер 64 байт
string voicecont = ReadUtf16String(record, 0x00D8); // размер 1024 байт
string usertext = ReadUtf16String(record, 0x04D8); // размер 64 байт
string textcont = ReadUtf16String(record, 0x0518); // размер 1024 байт
uint unknown3 = BitConverter.ToUInt32(record, 0x0918); // 2 ?
uint unknown4 = BitConverter.ToUInt32(record, 0x091C); // 2 ?
uint unknown5 = BitConverter.ToUInt32(record, 0x0920); // 3 ?
// 2564 пустых байт
string ip = ReadUtf16String(record, 0x1328); // размер 200 байт
string name = ReadUtf16String(record, 0x13F0); // размер 200 байт
uint port = BitConverter.ToUInt32(record, 0x14B8); // 1-65535
string nullStr1 = ReadUtf16String(record, 0x14BC); // размер 200 байт
string nullStr2 = ReadUtf16String(record, 0x1584); // размер 200 байт
string kerberos = ReadUtf16String(record, 0x164C); // размер 200 байт
string loginUser = ReadUtf16String(record, 0x1714); // размер 200 байт
uint kerbOn = BitConverter.ToUInt16(record, 0x17DC); // 0-1
uint uniqueId = BitConverter.ToUInt32(record, 0x17E0); //
uint interServer = BitConverter.ToUInt32(record, 0x17E4); // id промежуточного пк
uint parentId = BitConverter.ToUInt32(record, 0x17E8); //
ushort isFolder = BitConverter.ToUInt16(record, 0x17EC); // 0-1 размер 2 байт
uint number = BitConverter.ToUInt32(record, 0x17EE); //
uint nullInt4 = BitConverter.ToUInt32(record, 0x17F2); // 0x17F2-0x17F5 ?
uint nullInt5 = BitConverter.ToUInt32(record, 0x17F6); // 0x17F6-0x17F9 ?
// 0x17FA - конец записи
}
Одним из «почему?» для меня стало то, что запись папки и запись компьютера имеют идентичный размер. Они определяются маркером isFolder, единственным во всём файле, имеющим минимальный размер 2 байта. Если это папка, то все поля, относящиеся к настройкам соединения (IP, порт и т.д.), просто заполнены нулями. Казалось бы, можно было сделать отдельную, более легковесную структуру для папок. Но нет.
Я даже понимал, почему - любая запись, пусть то папка или компьютер, являлась типизированным объектом. Зачем заморачиваться, никто же не будет смотреть, что внутри, и размер книги все равно всегда будет не велик, подумал разработчик )
Кроме того, я обнаружил целые пустыни нулей — вряд ли выравнивание, может устаревшие или зарезервированные места, которые никогда не использовались.
А также несколько полей, названных мной именами unknown1, unknown2 и т.д., которые имели постоянные независимые значения, но не находили отражения в видимых настройках интерфейса Radmin. A nullStr1, nullInt1 и т.д. определены по границам, но всегда нулевые.
Ребята, если вы это читаете — что это за пустыня?!
Строки в формате UTF-16 LE (классика для Windows) были разбросаны по всей структуре, и их чтение было отдельным удовольствием. Сначала я не понял, почему некоторые названия заканчивались на ….апка или на ..пка. Оказалось, разрабы не заморачивались и сохраняют названия поверх старых, оставляя хвосты. То есть, при создании папки в Radmin, сначала она создается под названием "Новая папка", а затем записывается указанное Вами имя поверх старого.
Данная функция отработала на ура, отрезая лишнее:
static string ReadUtf16String(byte[] data, int startOffset)
{
StringBuilder sb = new StringBuilder();
for (int i = startOffset; i < data.Length - 1; i += 2)
{
byte lo = data[i];
byte hi = data[i + 1];
char c = (char)(hi << 8 | lo); // little-endian
if (c == '\0') break;
sb.Append(c);
}
return sb.ToString();
}
Глава 5: Сборка пазла – иерархия и JSON
После того, как структура записей была разобрана, встала задача восстановить древовидную структуру адресной книги. Каждая запись хранила UniqueId и ParentId. Алгоритм построения дерева был стандартным:
static List<Record> BuildHierarchy()
{
var recordDict = allRecords.ToDictionary(r => r.UniqueId);
var rootItems = new List<Record>();
foreach (var record in allRecords)
{
if (record.ParentId == 0)
{
rootItems.Add(record); // Корневой элемент
}
else if (recordDict.ContainsKey(record.ParentId))
{
// Добавляем дочерний элемент к родителю
recordDict[record.ParentId].Children.Add(record);
}
}
return rootItems;
}
Итогом моих трудов стала утилита с открытым исходным кодом RadminRpbParser, которая умеет:
Анализ RPB файла: /t RadminAddrBook.rpb
Экспорт RPB в JSON: /j RadminAddrBook.rpb [output.json]
Импорт JSON в RPB: /r JsonAddrBook.json [output.rpb]
Главный класс, который сериализуется в JSON, выглядит так:
class JsonOutput
{
public FileHeader Header { get; set; }
public List<object> Records { get; set; } // Здесь лежит наше дерево
}
Эпилог: Интеграция и триумф стремления к оптимизации
Этот инструмент был успешно интегрирован в наше внутреннее ПО для массового развёртывания. Соответственно в нашем ПО нет конвертации в Json и обратно, там прямая интеграция. Теперь процесс импорта и экспорта нескольких сотен компьютеров адресной книги занимает секунды.
Для Вашего свободного использования я выложил консольную версию, с помощью которой не составит труда выполнить, как вариант, следующее:
1. Сгенерируйте JSON-файл с нужными записями.
(можно использовать какой-нибудь конвертер csv to json
или исправить сконвертированный из существующей rpb книги)
И проверьте репозиторий, возможно функция RPB to CSV и обратно уже реализована :)
Заголовок в Json не обязателен
2. RadminRpbParser конвертирует JSON в .rpb.
3. Файл готов к импорту в Radmin.
Минуты работы вместо часов ручного труда. Миссия выполнена.
Можете использовать исходники в своем проекте:
Исходный код проекта [RadminRpbParser] доступен на GitHub
Спасибо разработчикам Radmin за интересный вызов. Ребята, если вы когда-нибудь решите задокументировать формат, дайте знать!
А вам приходилось сталкиваться с обратной разработкой бинарных форматов? Поделитесь своими историями в комментариях!
Комментарии (11)

horray
21.11.2025 13:47Была у меня как-то похожая задача, тоже из какой-то байды без нормального экспорта надо было много записей вытащить...
Работа была разовая, так что я почесал репу и тупо записал клавишный макрос в autoit, вроде бы.
Litemanager_remoteadmin
Спасибо за материал , просто супер! а вообще есть программы где адресная книга хранится в xml условно в открытом формате, и есть функции синхронизация ее между клиентами и многое многое другое полезное) например в лайтманагере
ViRKiS Автор
Да, спасибо. Но здесь как раз столкнулся с тем, что список контактов на момент необходимости был только в Radmin. Полез копать и не смог остановиться )