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

в 20:15, , рубрики: bug bounty, mass assignment, privilege escalation, webasyst, информационная безопасность, уязвимость

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

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


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

Просмотр информации о заказе:
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 (https://cwe.mitre.org/data/definitions/915.html), но не исключал, что фильтрация аргумента происходит до вызова самой функции. Мне стало лениво просматривать все места в коде, где вызывается 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 сайтов используют данный фреймворк.

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

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

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

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

Источник

Поделиться

* - обязательные к заполнению поля