Эта история о том, как я нашел уязвимость в фреймворке Webasyst и, в частности, в ecommerce-движке Shop-Script 7.
Все началось с того, что вечером я решил приобрести мерч русскогоязычного рэп-исполнителя. После оплаты мне пришло письмо, содержащее ссылку на детали моего заказа:
В глаза сразу бросился идентификатор заказа, встречающийся в середине строки хеша:
9fe684d6508769ef213111ed917d1cce94088
Мне стало интересно, как генерируется эта строка, а для этого нужно было взглянуть на исходники движка. Изучив html source страницы, я узнал какой движок используется в магазине, а немного погуглив нашел где его скачать.
Оказалось, что данная строка генерируется случайно и никакой закономерности не прослеживается. Но волею случая, пока я искал функцию отвечающую за этот хеш, я наткнулся на довольно любопытные участки кода.
wa-system/contact/waContact.class.php
Параметр $data содержит данные в формате ‘название поля’ => ‘значение поля’, в функции я не заметил защиты от Mass Assignment, но не исключал, что фильтрация аргумента происходит до вызова самой функции. Мне стало лениво просматривать все места в коде, где вызывается save() и я решил проверить теорию экспериментальным путем.
Установив на локалку движок, первым делом я решил посмотреть структуру таблицы `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 сентября – получил добро на публикацию данной статьи
Все началось с того, что вечером я решил приобрести мерч русскогоязычного рэп-исполнителя. После оплаты мне пришло письмо, содержащее ссылку на детали моего заказа:
Просмотр информации о заказе:(примечание: ID заказа был видоизменен для публикации)
https://o***yshop.com/my/order/21311/9fe684d6508769ef213111ed917d1cce94088/
PIN: 3302
В глаза сразу бросился идентификатор заказа, встречающийся в середине строки хеша:
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 сентября – получил добро на публикацию данной статьи
Комментарии (14)
AboutUs
14.10.2017 10:03Комментариев почти нет, видимо люди пошли проверять)
Вообще странно, что спустя такой короткий промежуток времени было дано добро на публикацию. Вряд ли все сколько угодно тысяч сайтов успели обновиться.manok Автор
14.10.2017 10:11Меня предупредили, что обновление сайтов на их платформе может занять около 1-1,5 месяца. Три дня назад я уточнил достаточно ли времени прошло и могу ли я опубликовать статью.
printercu
14.10.2017 11:07Функция save($data, $validate) — осторожно, много кода!
Security through obscurity :)
KREGI
мне одному интересно какое?)
saboteur_kiev
Кроме всего прочего — удовлетворение от своей работы и на удивление быстрой реакции со стороны разработчиков движка.
А то в последнее время слишком много статей о том, как месяцами игнорируют репорты о реальных уязвимостях.
Akuma
Всем интересно как раз «все прочее» :)
manok Автор
10 000 рублей.
Вообще я не ожидал какого-либо вознаграждения за это всё, официальной bug bounty программы, насколько я понимаю, у этой компании нет. Так что это оказалось приятным бонусом.
croupier
Жмоты)
Могли бы хоть приличное количество лицензий на свои «чудо» продукты подкинуть. Им, по-сути, бесплатно, а вы может что и заработали на этом.