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

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


Все началось с того, что вечером я решил приобрести мерч русскогоязычного рэп-исполнителя. После оплаты мне пришло письмо, содержащее ссылку на детали моего заказа:
Просмотр информации о заказе:
https://o***yshop.com/my/order/21311/9fe684d6508769ef213111ed917d1cce94088/
PIN: 3302
(примечание: ID заказа был видоизменен для публикации)

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

9fe684d6508769ef213111ed917d1cce94088

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

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


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

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, но не исключал, что фильтрация аргумента происходит до вызова самой функции. Мне стало лениво просматривать все места в коде, где вызывается save() и я решил проверить теорию экспериментальным путем.

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

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


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

Тестируем


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



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



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



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


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

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

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

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

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

8 августа, 22:30 – купил футболку
9 августа, 08:00 – сообщил об уязвимости Webasyst, прикрепил видео с Proof of Concept
9 августа, 13:00 – получил подтверждение от службы поддержки
14 августа – получил вознаграждение, уязвимость была закрыта
11 сентября – получил добро на публикацию данной статьи
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 18

    +3
    получил вознаграждение

    мне одному интересно какое?)
      +3
      Кроме всего прочего — удовлетворение от своей работы и на удивление быстрой реакции со стороны разработчиков движка.
      А то в последнее время слишком много статей о том, как месяцами игнорируют репорты о реальных уязвимостях.
        0
        Всем интересно как раз «все прочее» :)
        +3
        10 000 рублей.
        Вообще я не ожидал какого-либо вознаграждения за это всё, официальной bug bounty программы, насколько я понимаю, у этой компании нет. Так что это оказалось приятным бонусом.
          +1
          Жмоты)
          Могли бы хоть приличное количество лицензий на свои «чудо» продукты подкинуть. Им, по-сути, бесплатно, а вы может что и заработали на этом.
            0
            Точно жмоты)
        0
        Комментариев почти нет, видимо люди пошли проверять)
        Вообще странно, что спустя такой короткий промежуток времени было дано добро на публикацию. Вряд ли все сколько угодно тысяч сайтов успели обновиться.
          0
          Меня предупредили, что обновление сайтов на их платформе может занять около 1-1,5 месяца. Три дня назад я уточнил достаточно ли времени прошло и могу ли я опубликовать статью.
          0
          Автор все правильно сделал, молодец, просто интересно, может кто знает, сколько бы стоила такая уязвимость в даркнете? )
            +3
            250-300k rub
              0
              Смотря что за сайты/интернет-магазины используют этот фреймворк. Я бегло посмотрел список, но интересного нашёл мало. Я совсем не специалист
              0
              8 августа, 22:30 – купил футболку
              Футболку — то на память забрал? Или отменил заказ?)
                0
                Конечно, покупка футболки была основной целью вечера :)
                0
                Функция save($data, $validate) — осторожно, много кода!

                Security through obscurity :)

                0
                Благородно, а можно было скупить весь интернет.

                Only users with full accounts can post comments. Log in, please.