18th Кві, 2008

Обробка зовнішніх даних. Частина ІІ: Валідація

У попередньому записі я розповідав про джерела вводу і їх убезпечення. У цій статті я хочу показати як можна перевірити на дійсність дані, що вже потрапили у скрипт. Якщо ви не будете перевіряти дані, то рівень безпеки ані трохи не підвищиться, навіть, якщо ви зробили сам ввід даних більш безпечним.
У минулій статті було згадано, що всі дані приходять як строки чи масиви строк, але у різних ситуаціях ми можемо очікувати різні види даних, наприклад, числа чи які-небудь шаблонні дані (e-mail’и, URL’и, телефонні номери і т.п.). Тож розглянемо ці види даних і методи їх перевірки.

Чисельні дані

Найшвидший і найпростіший метод валідації чисельних даних - приведення типу до необхідного чисельного типу (int, float):

[-]
View Code PHP
<?php
    $_GET['id'] = (int)$_GET['id'];
    $_POST['price'] = (float)$_POST['price'];
?>


Приведення типу переводить чисельні дані у строках - це дає впевненість, що у скрипт потраплять лише чисельні дані, незалежно від того, що було введено. Якщо введена строка містить лише нечисельні дані, то приведення поверне число 0. У більшості випадків це буде небажаним, тож проста умова після приведення типу дасть змогу ефективно перевірити дані, і у разі необхідності видати помилку:

[-]
View Code PHP
<?php
   if (!$value) {
       //Помилка
   }
?>

Пам’ятайте, що при можливості введення занадто великого числа, краще приводити до типу із плаваючою точкою (float). Наприклад, на 32-бітних системах розмір цілочисленого типу (int) сягає 4 байт, окрім того він є знаковим, тобто максимальне число для змінної такого типу - 2 147 483 647 ((2^4^8)/2). Якщо більше число буде введено, то станеться переповнення і втрата даних як наслідок. Приведення до типу із плаваючою точкою збереже дані у форматі “знак * 2^експонента * мантиса”, таким чином ті ж 4 байт дозволяють зберігати значно більші числа.

Щоправда, таким чином можна пререводити лише десяткові числа, а наприклад, шістнадцяткові та вісімкові вже - ні. Для цього існує більш гнучка функція PHP is_numeric. Вона приймає всі формати даних і повертає булеве true, в разі, якщо їй передані чисельні дані та false у іншому випадку. Ви спитаєте, чому б посто завжди не використовувати цю функцію? За все треба платити - вона працює дещо повільніше ніж просте приведення типів і не зовсім вірно перевіряє вісімкові числа, адже вважає допустимими всі цифри у проміжку [0-9], що не дійсно для вісімкових чисел. Приклад:

[-]
View Code PHP
<?php
    is_numeric('9234'); //true
    is_numeric('0xFF7F'); //true
    is_numeric('0xYR3F'); //false
    is_numeric('0999'); //true
?>

Ще однією проблемою у валідації чисельних даних є різниця у локалях. Справа у тому, що числа із плаваючою точкою вважаються дійсними тільки, якщо цілу та дробову частину розділяє знак “.” (точка). Це складає проблему для багатьох європейських локалей - наприклад, у французькій, німецькій та й українській локалях прийнято використовувати кому як розподільний знак. І ніяка насильна зміна локалі у цьому не допоможе. У PHP можливе лише тільки використання точки.

Тепер трохи про парадигму використання цієї перевірки. Приклад перший:

[-]
View Code PHP
<?php
    //$_GET['id'] = '1; /*Бугагагага!*/ TRUNCATE users;'
 
    if ((int)$_GET['id']) {
        mysql_query("DELETE FROM users WHERE id=".$_GET['id']);
    }
?>

Проблему бачите? :) Так, умова із приведенням типу буде успішно пройдена і поверне “1″, а потім запитом до БД буде видалений користувач із id=1, а разом із ним і вся таблиця “users”. Це окремий тип уразливості - ін’єкція SQL коду, який буде розглянутий більш детально у одній із наступних заміток. А тепер розглянемо правильне використання приведення типу:

[-]
View Code PHP
<?php
    if (($_GET['id'] = (int)$_GET['id'])) {
        mysql_query("DELETE FROM users WHERE id=".$_GET['id']);
    }
?>

Тепер все добре, у запит буде вставлено лише чисельне значення.

Строкові дані

Із строковими даними не все так просто як із чисельними. Їх валідація дуже залежить від того, які власне дані передаються: URL, e-mail, ім’я, телефон і т.п.

Найпростійший і найшвидший шлях перевірити строкові дані у PHP - використати розширення ctype(воно типово увімкнене). Наприклад для перевірки імені користувача можна задіяти ctype_alpha. Ця функція поверне true, коли всі символи у переданій їй строці є літерами, незалежно від регістру. Якщо допускаються як літери так і цифри, то слід використовувати ctype_alnum, щоправда дійсними будуть лише цілі числа. Приклади:

[-]
View Code PHP
<?php
    ctype_alpha('teststring'); //true
    ctype_alpha('teststring12'); //false
    ctype_alnum('teststring12'); //true
?>

Функції розширення ctype є залежними від встановленої локалі. Якщо строка містить літери, які не належать даній локалі - вона вважається недійсною. Тож, що працювати із необхідною вам мовою потрібно перескнути локаль за домогою функції setlocale. До того ж, ці функції працюють лише із однобайтовими символами, тож можете забути про перевірку строк із багатобайтовими символами (наприклад, не-латинські літери закодовані UTF-8, японська і т.п.). Також ця функція вважає не дійсними символ “-” та всі види пробілів.

Щоб перекрити усі інші випадки треба використовувати регулярні вирази. У цій статті ми розглянемо регулярні вирази сумісні із Perl (PCRE), оскількі вони швидші, розуміють багатобайтові символи (без спеціальних розширень як mbstring), безпечніші та гнучкіші за розширення ereg. Перевірка строк за допомогою PCRE здійснюється через функцію preg_match. При сходженні заданих шаблону та строки, функція повертає int(1) та int(0) у зворотньому випадку. Якщо на вході очікується строка із латинськими літерами, знаками “-”, “‘” та пробілами можна використати наступний вираз:

[-]
View Code PHP
<?php
    preg_match("/[^-'a-zA-Z \t\n]/", "it's a phrase that vali-dates"); //int(0)
?>

Квантор “^” на початку виразу означає “не входить“. У фразі немає символів, які б не входили у вираз, отже повернено 0.

Також PCRE можна використовувати разом із setlocale. У деяких випадках вводити вручну символи для перевірки досить проблематично (наприклад, ієрогліфи). Для цього існує ідентифікатор “/w”, який означає алфавіт, складений із літер поточної локалі. Також PCRE повністю підтримує UTF-8, тож можна використовувати Unicode коди у виразах із оператором “u”.

Розмір даних

Для безпечної і правильної роботи скрипта також часто є сенс перевіряти розмір даних, що надходять ззовні. Наприклад, у базі даних є поле для імені користувача і воно має тип VARCHAR(20). Що станеться, якщо користувач введе ім’я довжиною 25 символів? А це залежить від самої бази даних: якщо це PostgreSQL, то буде видана помилка, якщо ж MySQL, то дані запишуться у БД, але будуть автоматично обрізані до необхідної довжини. Не знаю навіть, що гірше. Отже, нам слід відстежувати такі випадки і попереджати користувача про помилки.

Для цього існує два підходи: обмежування на клієнтській стороні (у браузері) та серверній. Найкраще застосовувати обидва. Спочатку розглянемо клієнтську сторону.

Текстові поля у HTML-формах можна обмежувати у розмірі за допомогою атрибута “maxlength“, таким чином браузер самотужки попереджає ввід більшої за цей атрибут кількості символів:

[-]
View Code XHTML
    <input type="text" name="uname" maxlength="20" />

Нажаль, maxlength можна задіяти лише для полів типу “text” та “password“. Для “textarea” наприклад, вже потрібно відстежувати це за допомогою JavaScript.

Але це ні в якому разі не є панацеєю. Проблема у тому, що зловмисник може просто скопіювати вашу форму і змінити атрибути чи надіслати відповідний POST-запит до вашого скрипта, який оброблює ці дані. Найпростіше, що можна зробити для того, щоб запобігти цьому - створити масив з необхідними вам обмеженнями і перевіряти їх при вводі у скрипті.

[-]
View Code PHP
<?php
    $validate = array(
        'uname' => 20,
        'address' => 200,
        /* ... */
    );
 
    foreach ($validate as $input => $rule)
        if (!empty($_POST[$input]) && strlen($_POST[$input]) > $rule)
            exit('Поле '.$input.' більше за дозволену довжину '.$rule);
?>

Таким чином, для кожного визначеного поля перевіряється його довжина за допомогою функції strlen. Якщо довжина перевищує задане для цього поля значення - користувач отримує помилку. Сама перевірка дуже швидка, адже strlen не буде щоразу підраховувати довжину строки, натомість ця функція бере вже визначене значення із спеціальної внутрішньої структури PHP. Незважаючи на це, ми все-одно маємо виклик функції, який можна дещо оптимізувати. Окрім того слід пам’ятати, що для використання із багатобайтовими кодуваннями символів треба використовувати функцію mb_strlen із вказанням кодування, якщо увімкнене розширення mbstring. Якщо ж цього розширення нема - то такий спосіб не підійде, адже наприклад, 2-байтові кодування будуть підраховуватись відповідно за по 2 байти і довжина строки буде у 2 рази більшою.

Починаючи із PHP 4.3.10, можна використати одну маловідому можливість вбудованої конструкції isset. Вона може перевіряти наявність даних за зсувом (offset). Тож ми можемо це застосувати, перевіряючи, чи є щось за зсувом {$rule + 1}:

[-]
View Code PHP
<?php
    $validate = array(
        'uname' => 20,
        'address' => 200,
        /* ... */
    );
 
    foreach ($validate as $input => $rule)
        if (!empty($_POST[$input]) && isset($_POST[$input]{$rule + 1}))
            exit('Поле '.$input.' більше за дозволену довжину '.$rule);
?>

Оскільки isset не є функцією, а натомість конструкцією мови, PHP інтерпретує це у одну інструкцію, а вона буде виконано просто миттєво. Мінус - ні в якому разі не підходить для багатобайтових кодувань строк.

Білий список

Велика кількість розробників занадто сильно покладається на одне вкрай невірне припущення - виринаючі списки, чекбокси, кнопки вибору та приховані поля не потребують перевірки, думаючи, що вони все-одно мають лише попередньо визначені варанти даних. Насправді ж, зловмисник може легко передати будь-які інші дані, скопіювавши форму або створивши свій запит до вашого скрипта. У першій частині було сказано, що неможна довіряти будь-яким зовнішнім даним. Все потрібно перевіряти.

На щастя, перевірити такі поля дуже легко. Достатньо просто створити масив із значеннями цей полів і перевіряти введені дані на наявність у цьому масиві:

[-]
View Code PHP
<?php
    $jobTypes = array('fulltime', 'parttime', 'contract');
 
    if (empty($_POST['jobType']) || !in_array($_POST['jobType'], $jobTypes))
        exit('Обламайся, у нас все гаразд із безпекою :-P ');
?>


Завантаження файлів

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

Налаштування:

  • upload_max_size - це потрібно зробити якнайменшим, щоб попередити завантаження надто великих файлів, завантажуючи сервер. Типово, це налаштування має значення 2Мб. Часто можна зробити його меншим.
  • post_max_size - обмежує розмір POST-запиту. Якщо ваша програма може завантажувати лише один файл за раз, то слід поставити його трохи більшим за upload_max_size. Якщо ж багато, то трохи більше ніж сумарний розмір всіх файлів. Типово, це має немаленьке значенняу 8Мб. В більшості випадків треба зменшити.
  • upload_tmp_dir - директорія для розміщення тимчасових файлів. Типово, PHP використовує системну тимчасову директорію (наприклад, /tmp/ на *nix). Ця директорія може бути прочитана будь-ким та у деяких випадках ще й писати туди може хто-завгодно. Вона доступна для будь-якого користувача та процесу. Зазвичай правильно змінити це налаштування для кожної вашої програми.


Тепер власне про завантаження файлів.

Коли приходить запит, який включає у себе файли, то суперглобальний масив $_FILES наповнюється масивом для кожного файлу:

[-]
View Code PHP
    //print_r($_FILES);
    $_FILES['file'] => Array
    {
        [name] => picture.jpg //назва файла
        [type] => image/jpeg //MIME-тип
        [tmp_name] => /tmp/phpsdf34ss //тимчасова назва і місце збереження
        [error] => 0 //код помилки
        [size] => 213951 //розмі
    }

Параметр “name” за специфікацією W3C має містити лише оригінальну назву файла без директорії. Але, нажаль, не всі браузери дотримуються цієї специфікації (звичайно ж ви здогадались, які саме “не всі” браузери маються на увазі :) Так, це Internet Explorer, який порушаючи специфікацію та приватність користувача, надсилає повний шлях до файлу). Такі браузери стають інструментом для зловмисних дій і, якщо не валідувати ввід файлів, то кінця проблем не буде видно. Приклад такого використання:

[-]
View Code PHP
<?php
    //$_FILES['file']['name'] = '../../config.php';
 
    move_uploaded_file($_FILES['file']['tmp_name'], '/home/www/app/uploads/'.$_FILES['file']['name']);
?>

Таким чином, якщо існує файл /home/www/config.php і на нього у веб-сервера є права запису - він буде перезаписаний завантаженим. В принципі, PHP намагається автоматично захистити від таких дірок, обрізаючи все до назви файла, але це не завжди вірно працює. Так, наприклад, у версії для Windows до 4.3.10 роздільники шляху “\” могли потрапити у шлях (про це трохи далі). Щоб убезпечити програму від дірок у старих версіях і передбачити нові слід обрізати все непотрібне вручну:

[-]
View Code PHP
<?php
    //$_FILES['file']['name'] = '../../config.php';
 
    move_uploaded_file($_FILES['file']['tmp_name'],
             '/home/www/app/uploads/'.basename($_FILES['file']['name']));
?>

Функція basename дозволяє пройти лиш імені файла.

Тепер щодо вмісту файлу. Пам’ятайте, що тип файлу, який передається у $_FILES['file']['type'] є таким, яким його вважає браузер. Це абсолютно ненадійна інформація, якій ніколи і нізащо неможна довіряти. Вся проблема у тому, що браузер визначає MIME-тип файлу просто тупо, судячи з його розширення у файловій системі, а не з його заголовків (як мав би). Тож, якщо файл bloody_virus.exe буде перейменований у beautiful_flowers.jpg, то браузер не задумуючись відправить MIME-тип image/jpeg.

Для найбільш часто завантажуваних файлів, картинок, існує функція getimagesize. Вона перевіряє заголовки файла та повертає інформацію про зображення, або false, якщо це не картинка. Для більш складних файлів існує розширення PECL fileinfo. Воно може оброблювати майже будь-які формати файлів через функцію finfo_file. Спочатку треба створити контекст за допомогою функції finfo_open (якщо передати їй FILEINFO_MIME, то у видачі буде використовуватись MIME-тип замість текстової інформації), далі можна використовувати його скільки завгодно раз для будь-яких файлів.

От ми і підготувались до власне доступу до завантаженого файлу. Тут треба запам’ятати дві функції: move_uploaded_file та is_uploaded_file. Перша переміщає завантажений файл із тимчасової директорії у постійну, а друга перевіряє, чи дійсно вказаний файл був завантажений. Обидві функції безпечні, адже перевіряють шлях до файлу через внутрішній хеш завантажених файлів. Коли файл пересунутий у постійне місце за допомогою move_uploaded_file, то його тимчасова назва видаляється із цього хешу, унеможливлюючи таким чином будь-які подальші операції із тимчасовим файлом.

І, нарешті, останнє, що можна сказати про файли - перевірка розміру. Цю інформацію можна вважати безпечною. Інформація про розмір файлу передана браузерем завжди збігається із розміром тимчасового файлу, в разі безперешкодного завантаження. Тож, щоб не засмічувати пошкодженими файлами директорію для постійних файлів слід зробити ще одну невеличку перевірку:

[-]
View Code PHP
<?php
    if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest))
        exit('Ой...');
 
    if (filesize($dest) != $_FILES['file']['size']) {
        unlink($dest);
        exit('Страшний глюк! Рятуйтесь, хто може!!');
    }
?>

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

Чорна магія “Magic Quotes”

Суть механізму PHP magic_quotes_gpc у тому, що ввід користувача може включати у себе різні види спеціальних символів, як лапки (’ та “), NUL (\0) та зворотній слеш (\). Якщо залишити їх не відфільтрувавши, можна наразитись на небезпеку, передаючи їх у різні функції. Тож розробники PHP вирішили автоматично їх екранувати, фактично здійснюючи функцію addslashes до кожного джерела вводу даних, і, навіть, до назви файлів. З першого погляду, що тут поганого? Розробники турботливо захистили від небезпеки лінивих програмістів (а ми всі такі :) ), давши їм чарівну таблетку. Але не попередили, що вона має деякі побічні ефекти.

Основна проблема “магічних лапок” є бездумна залежність від них. А факт у тому, що цей механізм може бути вимкнутим через php.ini, httpd.conf та й .htaccess. Тому, якщо ви вважаєте, що magic quotes завжди увімкнено і не екрануєте ввід вручну, то ви самі собі вороги, оскільки залишаєте свою програму на беззастережне ураження великою кількістю експлоітів, як ін’єкція SQL, ін’єкція команд операційної системи та багато іншого.

По-друге, не завжди цей механізм продукує вірний вивід. Наприклад при використанні із спеціальними функціями для екранування можна отримати небажаний результат у вигляді подвійного екранування (а отже відміненого екранування). І нарешті, все це не надто ефективно. Функція addslashes пожирає вдвічі більше пам’яті ніж необхідно на збереження переданої строки і для нормалізації має бути реверсовано, поглинаючи вдвічі більше процесорного часу.

Очевидно, вірним рішенням є вимкнення magic_quotes_gpc та ручне екранування у необхідній мірі. Але для коректної роботи вашої програми у будь-якому середовищі потрібно нормалізувати дані. Ось шматок коду, який це виконує:

[-]
View Code PHP
<?php
    if (get_magic_quotes_gpc()) {
    function stripQuotes(&$val) {
        if (is_array($val))
            array_walk($val, 'stripQuotes');
        else
            $val = stripslashes($val);
        }
 
        foreach (array('GET', 'POST', 'COOKIE') as $superGlobal) {
            if (!empty(${'_'.$superGlobal}))
                array_walk(${'_'.$superGlobal}, 'stripQuotes');
            }
    }
?>

Цей код реверсує діяльність магічних лапок, якщо вони увімкнені, враховуючи будь-який рівень вкладеності у суперглобальних масивах через рекурсію. Все начебто непогано, але є одна проблема, суть якої є у одній обмеженості PHP. У рекурсивних функціях PHP використовує системний стек для відстежування викликів. Але цей стек обмежений у глибині і цілком можливо переповнити стек та цим завалити PHP. У даному прикладі це тривіально зробити:

[-]
View Code PHP
<?php
    $str = str_repeat('[]', 1000000);
    file_get_contents('http://vulnerable.site.com/insecure.php?badvar='.$str);
?>

Ми передаємо GET-запитом масив із мільйонною вкладеністю. Таким чином наш скрипт просто завалиться на рекурсії разом із PHP. Тож маємо придумати більш безпечний засіб. А це просто - ми уникнемо рекурсії, якщо “розрівняємо” багатовимірний масив у одновимірний:

[-]
View Code PHP
<?php
    if (get_magic_quotes_gpc()) {
        $inputs = array(&$_GET, &$_POST, &$_COOKIE, &$_ENV, &$_SERVER); //масив із суперглобальними, приєднними за посиланнями, щоб виловити усі зміни до них
 
        while (list($input, $data) = each($inputs)) {
            foreach ($data as $key => $val) {
                if (!is_array($val)) { //якщо під-елемент не є масивом - реверсувати екранування
                    $inputs[$input][$key] = stripslashes($val);
                    continue;
                }
                $inputs[] =& $inputs[$input][$key]; //розрівнювання - якщо під-елемент сам є масивом, приєднуємо його за посиланням в кінець масиву із суперглобальними
            }
        }
        unset($inputs); //вивільнюємо використаний масив
    }
?>

Ми позбавились рекурсії та зайвої функції, цим зробили наш код безпечнішим і швидшим.

І ще трохи інформації про імена файлів та magic_quotes_gpc. Це зовсім цікавий випадок. Нехай у нас є файл із іменем bad’file.txt. Він включає в себе апостроф - це цілком нормально, хоча і не дуже звично. Отже екранована версія цього імені буде bad\’file.txt. А тепер уявімо, що наш скрипт працює у ОС Windows (фєє) із PHP < 4.3.10 & < 5.0.3 (для 5ої гілки). А наскільки відомо, ця ОС інтерпретує “\” як розподільник директорій. У таких умовах файл буде мати директорію “bad” і назву “‘file.txt“. Щодо більш свіжих версій PHP, то частина до останнього символу лапок буде обрізана, що призведе до втрати даних. На *nix системах це не актуально, але все-одно може призвести до втрати даних. Тож давайте розберемось із цим:

[-]
View Code PHP
<?php
    if (get_magic_quotes_gpc())
        $_FILES['file']['name'] = stripslashes($_FILES['file']['name']);
?>

От і все. Якщо ж ви самостійно робите екранування, то краще просто оминайте ім’я файлів.

На останок хочу дати посилання на шпаргалку з убезпечення та фільтрації даних.

На цьому завершую другу частину статті про обробку зовнішніх даних. Сподіваюсь, вона була вам цікава та корисна. Будь ласка, коментуйте, якщо побачите неточності, маєте щось доповнити чи просто виразити свою думку.
Наступною заміткою із серії про безпеку буде стаття про ін’єкції SQL. Підписуйтесь на RSS-стрічку, якщо ви вважаєте мої замітки корисними. Їх буде більше! :)

Натисніть на одну з наступних кнопок для того, щоб долучити цей запис до вашого улюбленого сервісу соціальних закладок: These icons link to social bookmarking sites where readers can share and discover new web pages.
  • Blogosvit
  • Chuv
  • MyNews
  • Digg
  • del.icio.us
  • Sphinn
  • Facebook
  • Mixx
  • Google
  • connotea
  • Furl
  • Ma.gnolia
  • NewsVine
  • Spurl
  • StumbleUpon
  • Technorati
  • YahooMyWeb

Схожі замітки

Відповіді

Класна стаття, дякую!

Радий, що вам сподобалось :)

Залиште відповідь, будь ласка

XHTML: Ви можете використовувати такі теги: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Ваша відповідь:

Категорії