- PVSM.RU - https://www.pvsm.ru -

Как взломать более 17 000 сайтов за одну ночь

Эта история о том, как я нашел уязвимость в фреймворке Webasyst и, в частности, в ecommerce-движке Shop-Script 7.

Как взломать более 17 000 сайтов за одну ночь - 1


Все началось с того, что вечером я решил приобрести мерч русскогоязычного рэп-исполнителя. После оплаты мне пришло письмо, содержащее ссылку на детали моего заказа:

Просмотр информации о заказе:
https://o***yshop.com/my/order/21311/9fe684d6508769ef213111ed917d1cce94088/ [1]
PIN: 3302

(примечание: ID заказа был видоизменен для публикации)

В глаза сразу бросился идентификатор заказа, встречающийся в середине строки хеша:

9fe684d6508769ef213111ed917d1cce94088

Мне стало интересно, как генерируется эта строка, а для этого нужно было взглянуть на исходники движка. Изучив html source страницы, я узнал какой движок используется в магазине, а немного погуглив нашел где его скачать [2].

Изучаем исходники

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

wa-system/contact/waContact.class.php

Функция save($data, $validate) — осторожно, много кода!

/**
 * Saves contact's data to database.
 *
 * @param array $data Associative array of contact property values.
 * @param bool $validate Flag requiring to validate property values. Defaults to false.
 * @return int|array Zero, if saved successfully, or array of error messages otherwise
 */
public function save($data = array(), $validate = false)
{
    $add = array();
    foreach ($data as $key => $value) {
        if (strpos($key, '.')) {
            $key_parts = explode('.', $key);
            $f = waContactFields::get($key_parts[0]);
            if ($f) {
                $key = $key_parts[0];
                if ($key_parts[1] && $f->isExt()) {
                    // add next field
                    $add[$key] = true;
                    if (is_array($value)) {
                        if (!isset($value['value'])) {
                            $value = array('ext' => $key_parts[1], 'value' => $value);
                        }
                    } else {
                        $value = array('ext' => $key_parts[1], 'value' => $value);
                    }
                }
            }
        } else {
            $f = waContactFields::get($key);
        }
        if ($f) {
            $this->data[$key] = $f->set($this, $value, array(), isset($add[$key]) ? true : false);
        } else {
            if ($key == 'password') {
                $value = self::getPasswordHash($value);
            }
            $this->data[$key] = $value;
        }
    }
    $this->data['name'] = $this->get('name');
    $this->data['firstname'] = $this->get('firstname');
    $this->data['is_company'] = $this->get('is_company');
    if ($this->id && isset($this->data['is_user'])) {
        $c = new waContact($this->id);
        $is_user = $c['is_user'];
        $log_model = new waLogModel();
        if ($this->data['is_user'] == '-1' && $is_user != '-1') {
            $log_model->add('access_disable', null, $this->id, wa()->getUser()->getId());
        } else if ($this->data['is_user'] != '-1' && $is_user == '-1') {
            $log_model->add('access_enable', null, $this->id, wa()->getUser()->getId());
        }
    }

    $save = array();
    $errors = array();
    $contact_model = new waContactModel();
    foreach ($this->data as $field => $value) {
        if ($field == 'login') {
            $f = new waContactStringField('login', _ws('Login'), array('unique' => true, 'storage' => 'info'));
        } else {
            $f = waContactFields::get($field, $this['is_company'] ? 'company' : 'person');
        }
        if ($f) {
            if ($f->isMulti() && !is_array($value)) {
                $value = array($value);
            }
            if ($f->isMulti()) {
                foreach ($value as &$val) {
                    if (is_string($val)) {
                        $val = trim($val);
                    } else if (isset($val['value']) && is_string($val['value'])) {
                        $val['value'] = trim($val['value']);
                    } else if ($f instanceof waContactCompositeField && isset($val['data']) && is_array($val['data'])) {
                        foreach ($val['data'] as &$v) {
                            if (is_string($v)) {
                                $v = trim($v);
                            }
                        }
                        unset($v);
                    }
                }
                unset($val);
            } else {
                if (is_string($value)) {
                    $value = trim($value);
                } else if (isset($value['value']) && is_string($value['value'])) {
                    $value['value'] = trim($value['value']);
                } else if ($f instanceof waContactCompositeField && isset($value['data']) && is_array($value['data'])) {
                    foreach ($value['data'] as &$v) {
                        if (is_string($v)) {
                            $v = trim($v);
                        }
                    }
                    unset($v);
                }
            }
            if ($validate !== 42) { // this deep dark magic is used when merging contacts
                if ($validate) {
                    if ($e = $f->validate($value, $this->id)) {
                        $errors[$f->getId()] = $e;
                    }
                } elseif ($f->isUnique()) { // validate unique
                    if ($e = $f->validateUnique($value, $this->id)) {
                        $errors[$f->getId()] = $e;
                    }
                }
            }
            if (!$errors && $f->getStorage()) {
                $save[$f->getStorage()->getType()][$field] = $f->prepareSave($value, $this);
            }
        } elseif ($contact_model->fieldExists($field)) {
            $save['waContactInfoStorage'][$field] = $value;
        } else {
            $save['waContactDataStorage'][$field] = $value;
        }
    }

    // Returns errors
    if ($errors) {
        return $errors;
    }

    $is_add = false;
    // Saving to all storages
    try {
        if (!$this->id) {
            $is_add = true;
            $storage = 'waContactInfoStorage';

            if (wa()->getEnv() == 'frontend') {
                if ($ref = waRequest::cookie('referer')) {
                    $save['waContactDataStorage']['referer'] = $ref;
                    $save['waContactDataStorage']['referer_host'] = parse_url($ref, PHP_URL_HOST);
                }
                if ($utm = waRequest::cookie('utm')) {
                    $utm = json_decode($utm, true);
                    if ($utm && is_array($utm)) {
                        foreach ($utm as $k => $v) {
                            $save['waContactDataStorage']['utm_'.$k] = $v;
                        }
                    }
                }
            }

            $this->id = waContactFields::getStorage($storage)->set($this, $save[$storage]);
            unset($save[$storage]);
        }
        foreach ($save as $storage => $storage_data) {
            waContactFields::getStorage($storage)->set($this, $storage_data);
        }
        $this->data = array();
        $this->removeCache();
        $this->clearDisabledFields();
        wa()->event(array('contacts', 'save'), $this);

    } catch (Exception $e) {
        // remove created contact
        if ($is_add && $this->id) {
            $this->delete();
            $this->id = null;
        }
        $errors['name'][] = $e->getMessage();
    }
    return $errors ? $errors : 0;
}

Параметр $data содержит данные в формате ‘название поля’ => ‘значение поля’, в функции я не заметил защиты от Mass Assignment (https://cwe.mitre.org/data/definitions/915.html [3]), но не исключал, что фильтрация аргумента происходит до вызова самой функции. Мне стало лениво просматривать все места в коде, где вызывается save() и я решил проверить теорию экспериментальным путем.

Установив на локалку движок, первым делом я решил посмотреть структуру таблицы `wa_contact`.

Структура таблицы `wa_contact`

Как взломать более 17 000 сайтов за одну ночь - 2

Чтобы пользователь имел доступ к админ-панели (в движке она называется «бэкэндом») у покупателя должны быть заданы поля `login`, `password`, а поле `is_user` должно быть равно 1.

Тестируем

Добавляем товар в корзину, переходим на страницу оформления заказа, заполняем стандартные поля… и самое время добавить новые:

Как взломать более 17 000 сайтов за одну ночь - 3

Отправляем запрос, пробуем зайти с нашими данными в админку (/wa/webasyst/). Авторизация проходит успешно, но… страница админки совершенно пустая: у нас нет никаких прав. Судорожно ищу поле в таблице, отвечающее за права доступа и понимаю, что такого поля нет, а все права как и подобает вынесены в отдельную таблицу.

Как взломать более 17 000 сайтов за одну ночь - 4

Я уже почти смирился с фиаско, пока не заметил, что таблица `wa_contact_rights` содержит права для пользователей по id и для групп по id со знаком минус. В голову сразу же пришла идея присвоить нашему пользователю отрицательный id, тем самым получив права группы. Сказано – сделано, меняем customer[id] на -1 по аналогии с тем, как мы меняли остальные параметры ранее. Опять авторизуемся в админке и получаем все права, которые доступны группе «Администраторы».

Как взломать более 17 000 сайтов за одну ночь - 5

Что имеем в итоге

Уязвимость, позволяющую на любом сайте на этом фреймворке, в любом интернет-магазине на этом движке получить полные права администратора, которые в свою очередь позволяют, например, получить конфединциальную информацию обо всех заказах и покупателях, менять статус заказов (скажем, помечать их оплаченными) и просто изменять настройки веб-сайта.

Условия для использования уязвимости нет, она работает и при отключенной регистрации на сайте (потому что по факту, при оформлении заказа регистрация все же происходит).

По данным PublicWWW более 17 000 сайтов используют данный фреймворк [4].

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

Хронология событий:

8 августа, 22:30 – купил футболку
9 августа, 08:00 – сообщил об уязвимости Webasyst, прикрепил видео с Proof of Concept
9 августа, 13:00 – получил подтверждение от службы поддержки
14 августа – получил вознаграждение, уязвимость была закрыта [5]
11 сентября – получил добро на публикацию данной статьи

Автор: Дмитрий Соболев

Источник [6]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/uyazvimost/265644

Ссылки в тексте:

[1] https://o***yshop.com/my/order/21311/9fe684d6508769ef213111ed917d1cce94088/ : #

[2] скачать: http://www.shop-script.ru/platform/download/

[3] https://cwe.mitre.org/data/definitions/915.html: https://cwe.mitre.org/data/definitions/915.html

[4] данный фреймворк: https://publicwww.com/websites/%2B%22%2Fwa-data%2Fpublic%2F%22/

[5] была закрыта: https://github.com/webasyst/webasyst-framework/commit/c6b96ab96720361f6144df7bf26b943a808e3e98

[6] Источник: https://habrahabr.ru/post/340066/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox