Бінарний парсинг з PHP

20

Від автора: бінарні операції в PHP – трохи дивні. Так як PHP з самого початку був шаблонним шаром для C-коду, в ньому все ще багато цих C-ізмів. Безліч назв функції в точності відображають API C-рівня, навіть якщо працюють іноді трохи по-різному. Наприклад, PHP strlen безпосередньо встановлюють відповідність з STRLEN(3), і тому є безліч прикладів. Однак, як тільки справа доходить до роботи з бінарними даними, все несподівано сильно змінюється.

Деталі підручника

Тема: PHP

Складність: просунута

Двійкові дані, говорите?

Що таке двійкові дані? Бінарний (двійковий) код насправді – всього лише уявлення даних, а будь-які дані можна представити як нулі та одиниці. Говорячи про бінарних даних, ми зазвичай маємо на увазі представлення даних, як послідовність бітів. І звичайно нам потрібно закодувати якісь дані для передачі в біти, а потім на іншій стороні декодувати їх. Бінарне представлення – це просто ефективний формат передачі.

Щоб шифрувати і дешифрувати, нам потрібно якось отримати доступ до окремих бітів, а потім отримати функції, здатні конвертуватися з якогось існуючого подання в упаковане і назад. Один із здатних до цього і забезпечуваних мовами програмування інструментів – побітові операції.

Спосіб C

До того, як розглянути його роботу в PHP, я хотів би спочатку зсередини розглянути, як C управляється з ним.

Хоча C є мовою високого рівня, він все ще дуже близький до «заліза». Всередині CPU і ПАМ’ЯТІ дані зберігаються, як послідовність бітів. Отже, цілі числа в межах C – теж послідовність бітів. Символ – теж послідовність бітів, а рядок – це масив символів.

Давайте розглянемо приклад:

char *hello = «Hello World»;
printf(«char: %c\n», hello[0]);
printf(«ascii: %i\n», hello[0]);

ми звертаємося до першого символу H і друкуємо два його подання. Перше – це представлення символу (%c), друге – подання цілого числа (%i). Представлення символу – це H, подання цілого числа – 72. Чому 72, запитаєте ви? Тому що десяткове число 72 являє літеру H в таблиці ascii (Американського стандартного коду для обміну інформацією), що визначає набір знаків, який призначає кожному числа від 0 до 128 окреме значення. Деякі з них – керуючі символи, що представляють деякі числа, а деякі літери.

Все в порядку. Дані – це просто дані, які зберігаються, і нам потрібно вирішити, як їх інтерпретувати.

PHP: як би то не було, вам не слід цього робити в PHP

Одна з основних причин, чому в PHP це так відрізняється – той факт, що рядок – це зовсім інший тип. Давайте розберемося, що робить PHP:

$hello = «Hello World»;
var_dump($hello[0]);
var_dump(ord($hello[0]));

Щоб отримати код ascii символу в PHP, вам потрібно викликати ord до символу (який насправді не символ, а рядок з одного символу, так як тут немає типу символу). Ord повертає ascii символу.

На відміну від прикладу в C, тут у нас є більше одного представлення даних. В C є лише одиничне уявлення, у якого можуть виявитися різні інтерпретації. Число 72 в один і той же час може бути символом H. PHP вимагає від нас конвертації рядків і значень ascii, зберігаючи їх обидва в різних змінних з різними типами.

І це є основним головним болем при виконанні бінарного парсингу в PHP. Так як дані можуть представлятися як рядок або число, вам необхідно знати, з чим ви маєте справу. А в залежності від цього можна застосовувати різні інструменти.

Опускаємося до рівня бітів

Поки що ми бачили, як отримати доступ до окремих байтам і як отримати їх значення ascii. Але поки що нам це не дуже придатне. Для парсингу бінарних протоколів нам потрібно отримати доступ до окремих байтам.

В якості прикладу я використовую заголовок пакета DNS. Заголовок складається з 12 байтів. Ці байти розділені на 6 полів по 2 байти в кожному. Ось формат, визначений RFC 1035:

1 1 1 1 1 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+
| ID |
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+
|QR| Opcode |AA|TC|RD|RA| Z | RCODE |
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+
| QDCOUNT |
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+
| ANCOUNT |
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+
| NSCOUNT |
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+
| ARCOUNT |
+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+—+

Всі поля, крім другого, повинні читатися, як повні номери. Друге поле відрізняється, тому що в своїх 2 байтах воно вміщує безліч значень.

Давайте припустимо, що у нас є пакет DNS, представлений рядком, і нам потрібно зробити парсинг цієї «бінарної рядки» за допомогою PHP. Виділення значень чисел робиться легко. У PHP є функція unpack, що дозволяє вам розпакувати будь-який рядок, розбиваючи її в набір полів. Вам потрібно сказати їй, яка кількість байтів вам потрібно в кожному з полів. Так як у нас по 16 бітів в кожному полі, можна просто застосувати n, що визначається як unsigned short (завжди 16 bit, зворотний порядок байтів). Unpack дає можливість повторення формату як зразка шляхом прикріплення *, так що його можна розпакувати, просто застосувавши:

list($id, $fields, $qdCount, $anCount, $nsCount, $arCount) = array_values(unpack(‘n*’, $header));

Вона конвертує рядок байтів в 6 чисел, кожне з яких засновано на двох байтах. Ми викликаємо array_values, тому що значення, що повертається unpack – це 1-проіндексований масив. Щоб застосувати list, нам потрібен 0-проіндексований масив.

Ось дані заголовка DNS, представлені як шістнадцяткові. Два символи відповідають одному байту. Два байти – одне поле.

72 62 01 00 00 01 00 00 00 00 00 00

Це означає, що значення дорівнюють:

id – це 0×7262, що відповідає 0111 0010 0110 0010 в бінарному обчисленні, 29282 в десятковому.

fields – це 0×0100, що відповідає 0000 0001 0000 0000 в бінарному.

qdCount – це 0×0001, що відповідає 0000 0000 0000 0001 в бінарному, 1 в десятковому.

anCount, nsCount і arCount – це 0.

Тепер давайте розглянемо розширення, яке fields (розміщує) змінну в содержимые нею значення. Для цього можна застосовувати розпаковування unpack, тому що unpack має справу тільки з цілими байтами. Але можна використовувати значення, отримане нами від декодування з n, і витягти з нього байти з допомогою двійковими операторів.

Побітові оператори

Існує безліч двійковими операторів, що працюють з бінарної інтерпретацією цілих чисел PHP.

& — це побітове AND

| — це побітове OR

^ — це побітовий XOR

~ — це NOT, що означає, що він інвертує всі біти

« — це зсув вліво

» — це зсув вправо

Основне застосування & — це бітова маска. Бітова маска дає вам можливість повертати певні біти в початковий стан. Це допомагає відзначити тільки потрібні вам біти і ігнорувати всі інші.

Ми визначили, що значення fields – це число, яке представляє 0000 0001 0000 0000. Ми опрацюємо це значення справа наліво. Перше суб-поле – це rcode, і воно має довжину 4 біта. Значить, треба проігнорувати всі, крім останніх 4 бітів. Це можна зробити, застосувавши бітову маску:

value: 0000 0001 0000 0000
bitmask: 0000 0000 0000 1111
result of & op: 0000 0000 0000 0000

Оператор & встановлює ті біти 1 як значення, так і в бітову маску. Так як в даному випадку збігів немає, результатом буде 0. У коді PHP та ж операція виглядає так:

$rcode = $fields & bindec(‘1111’);

Примітка: Ми застосовуємо bindec, щоб отримати ціле число, яке бінарне 1111, тому що побітові оператори працюють з числами. З часів PHP 5.4 стало можливим писати 0b1111, PHP автоматично конвертує його в значення цілого числа 15.

Тепер потрібно отримати наступне значення – z. Можна таким же чином застосувати бітову маску, але тепер у нас виникла нова проблема. Значення, про який ми турбуємося, має кілька зайвих бітів праворуч. Точніше, 4 біта від rcode. Їх можна встановити на 0, застосувавши бітову маску, але це означає, що там є кілька непотрібних нам 0.

Вирішення проблеми криється в побитовом зсуві. Можна взяти число в двійковому вигляді і зрушити його вліво або вправо. Зсув вправо знищує крайні праворуч біти, так як вони зрушуються «за край». У цьому випадку нам потрібно зрушити їх вправо, і зробити так 4 рази.

value: 0000 0001 0000 0000
result of >> 4: 0000 0001 0000

Тепер можна застосовувати до цього значення бітову маску, щоб виділити останні 3 біта для отримання значення z.

value: 0000 0001 0000 0000
result of >> 4: 0000 0001 0000
bitmask: 0000 0000 0000 0111
result of & op: 0000 0000 0000

І те ж саме в коді PHP:

$z = ($fields >> 4) & bindec(‘111’);

Застосовувати цю методику можна знову і знову, щоб провести розбір всього заголовка. У підсумку вийде ось що:

list($id, $fields, $qdCount, $anCount, $nsCount, $arCount) = array_values(unpack(‘n*’, $header));
$rcode = $fields & bindec(‘1111’);
$z = ($fields >> 4) & bindec(‘111’);
$ra = ($fields >> 7) & 1;
$rd = ($fields >> 8 ) & 1;
$tc = ($fields >> 9) & 1;
$aa = ($fields >> 10) & 1;
$opcode = ($fields >> 11) & bindec(‘1111’);
$qr = ($fields >> 15) & 1;

Ось так робиться парсинг бінарних даних в PHP.

Резюме

У PHP є різні способи представлення двійкових даних.

Для конвертації з «бінарної рядки» в ціле число застосовуйте unpack.

Щоб отримати доступ до окремих бітів цього цілого числа, використовуйте побітові оператори.

Для подальшого читання

PHP: Побітові оператори (PHP: Bitwise Operators)

PHP: unpack

RFC 1035: Доменні імена – застосування та специфікація (RFC 1035: Domain Names — Implementation and specification)

github.com/reactphp/react/blob/master/src/React/Dns/Protocol/Parser.php — Вихідний код: React\Dns\Protocol\Parser (Source code: React\Dns\Protocol\Parser)