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

Статья Подмена браузерного отпечатка: Chrome - часть 2

hipeople

RAM
Пользователь
Регистрация
13.05.2022
Сообщения
123
Реакции
103
Гарант сделки
3
Депозит
0.0022
Автор: hipeople
Источник https://xss.pro

В прошой части я показал как вручную подменять отпечатки браузера: от Canvas и WebGL до таймзоны и шрифтов. Это думаю это дало вам полное понимание, что именно нужно скрывать. Теперь пора сделать следующий шаг — автоматизировать процесс и сделать его устойчивым к детекту. В этой части я покажу, как собрать два автономных инструмента для подмены отпечатков: один — гибкий и мощный на базе Puppeteer, второй — лёгкий и простой на Bash.Но инструменты — это только половина задачи. Вторая половина — это реалистичные данные. Ведь как бы круто вы ни подменили параметры, если в поле navigator.hardwareConcurrency стоит «1», а navigator.vendor — «Generic Corp», то любая система тут же вас спалит. Поэтому особое внимание я уделю добыче, генерации и структуре достоверных отпечатков, которые выглядят так, будто они действительно сняты с реального пользователя. Я расскажу, откуда брать актуальные User-Agent, Client Hints, таймзоны, локали, видеокарты, плагины, сетевые параметры и медиа устройства. Все данные мы будем собирать или генерировать через Python и Bash-скрипты, с минимальной зависимостью от сторонних API. Эта часть подойдёт тем, кто хочет не просто подменять отпечатки, а создать свой полноценный стек для маскировки, чтобы сделать свой отпечаток максимально реалистичным.


Создание инструментов для подделки отпечатков
Мы с вами изучили, как подменять User-Agent, шрифты, локаль, таймзону, аппаратные характеристики и даже медиаустройства. Пора взять мои наши наработки и собрать из них полноценный инструмент, который поможет остаться незамеченными при использование Chrome. В этом разделе я расскажу, как спроектировать и реализовать два инструмента для подмены отпечатков: один более полноценный и сложный, основанный на Puppeteer, и второй более лёгкий и доступный, написанный на Bash. Каждый из них решает задачу анонимизации, но подходит для разных задач и уровней подготовки.

Первый инструмент — на Puppeteer. Сможет может подменять почти всё, от User-Agent и шрифтов до Canvas и WebGL. Хотите, чтобы сайт думал, что вы на другом компе, с другой системой и даже с фейковой веб-камерой? Без проблем! Puppeteer запускает Chrome так, что можно через JavaScript или DevTools Protocol менять любые данные, которые сайт пытается подсмотреть, так же ествественно он будет скрывать автоматизацию и я сделаю его гибким в настройке. Он подойдёт как для ручного сёрфинга, так для автоматизации.

Второй инструмент, написанный на Bash, будет проще и быстрее в запуске. Он ориентирован на пользователей, которым достаточно базовой подмены отпечатков для повседневного использования. Этот вариант использует флаги запуска Chrome, изолированные профили и системные утилиты чтобы минимизировать утечки данных без сложной настройки. Хотя его возможности ограничены по сравнению с Puppeteer, он выигрывает в простоте, а также проще сделать его автозапуск.

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

Инструмент с использованием Pupeteer
Прежде чем разбирать инструмент c pupeteer, я решил, что без чёткой архитектуры дело не пойдёт. И дальше я подумал - всё должно быть разбито на независимые модули, чтобы легко добавлять или убирать функции.

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

Для начала, вот где что лежит в проекте: конфиг config.json — в корне, данные для спуфинга — в папке data с кучей текстовых файлов вроде user-agents.txt или videocards.txt, зависимости — в package.json, а весь код — в src. Там main.js и data-loader.js, а модули подмены — в подпапке spoof, где каждый файл, типа user-agent.js или webgl.js, отвечает за свою фичу. Теперь разберу, как это всё работает, с примерами конфига и кода.

Скриншот струтуры проекта:
photo_2025-05-11_06-57-15.jpg

Конфиг — это config.json, главный файл, который задаёт, что и как спуфить. В нём прописаны все характеристики, которые можно подменить: User-Agent, Client Hints, локаль, таймзону, разрешение экрана, плагины, медиаустройства и так далее. Для каждой — флаг "yes" или "no", чтобы включить или выключить подмену. Там же пути к файлам с данными: списки User-Agent’ов, разрешений экрана, видеокарт. Ещё есть регион, например "Japan"(как у меня в примере), чтобы подтянуть правильные координаты или локаль, и путь к профилю браузера для сохранения или сброса настроек.

Вот конфиг:
Код:
{
  "spoof": {
    "user-agent": "yes",
    "client-hints": "yes",
    "locale": "yes",
    "timezone": "yes",
    "local-ip": "yes",
    "network": "yes",
    "screen": "yes",
    "plugins": "yes",
    "firefox": "no",
    "cpu_and_memory": "yes",
    "platform": "yes",
    "battery": "yes",
    "vendor": "yes",
    "webgl": "no",
    "mediaDevices": "yes"
  },

  "data_paths": {
    "user-agents": "./data/user-agents.txt",
    "client-hints": "./data/client-hints.txt",
    "locales": "./data/locales.txt",
    "timezones": "./data/timezones.txt",
    "country-codes": "./data/country-codes.txt",
    "local-ips": "./data/local-ips.txt",
    "network-params": "./data/network-params.txt",
    "screen-sizes": "./data/screen-sizes.txt",
    "plugins": "./data/plugins.txt",
    "vendors": "./data/vendors.txt",
    "videocards": "./data/videocards.txt",
    "media-devices": "./data/media-devices.txt"
  },

  "profile_path": "",
  "region": "Japan"
}

Загрузчик данных — это data-loader.js в папке src, модуль, который берет данные для спуфинга из файлов в папке data. Он набит асинхронными функциями, каждая для своего типа данных. Например, одна функция берёт случайный User-Agent из файла, другая — локаль и таймзону для региона, третья — координаты через библиотеку all-the-cities, она выдаёт случайный город в нужном регионе и далее получает его координаты. Для User-Agent’а, к примеру, он делает похожую штуку — получает случайную строку из файла или выдаёт дефолт, если что-то пошло не так:

JavaScript:
async function loadUserAgent(filePath) {
  try {
    const data = await fs.readFile(filePath, 'utf8');
    const userAgents = data.split('\n').filter(line => line.trim());

    if (userAgents.length === 0) {
      throw new Error('No User-Agents found in file');
    }
    return userAgents[Math.floor(Math.random() * userAgents.length)];
  } catch (error) {
    console.error('Error reading User-Agent file:', error);
    return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
  }
}
Функция читает файл, выбирает случайный User-Agent, а если что-то идёт не так, возвращает дефолтный. Так работают и остальные функции загрузчика — для видеокарт, медиаустройств и прочего. Некоторые отличия есть в загрузке всего, что связано с регионом: там данные, вроде локалей или таймзон, подбираются по стране, указанной в конфиге, с сопоставлением из файлов. Платформа подтягивается на основе User-Agent, а если в конфиге выбрана подделка под Firefox, вендоры берутся соответствующие (User-Agent’ы для Firefox можно добавить в файлы вручную). По мелочи могут быть ещё нюансы, но в целом всё работает по одному принципу.

Модули спуфинга — это файлы в папке src/spoof, каждый подменяет одну характеристику. Например, user-agent.js меняет User-Agent через page.setUserAgent, media-devices.js подсовывает фейковые веб-камеры, cpu_memory.js подделывает количество ядер и память. Каждый модуль берёт данные от загрузчика и вшивает их в браузер через Puppeteer. Они независимы и запускаются, только если в конфиге стоит "yes". Это даёт гибкость: можно включить только локаль или шрифты, а остальное выключить. Я начинал с простых модулей, вроде User-Agent, а потом добавил сложные, типа батареи или медиаустройств, чтобы закрыть все возможные утечки.

Особняком стоит модуль автоматизации — automation.js в папке src/spoof. Он работает по умолчанию для всех страниц и прячет Puppeteer от сайтов, которые ищут ботов. Этот модуль убирает свойство navigator.webdriver, перехватывает вызовы navigator.permissions.query для webdriver и возвращает { state: 'denied' }, а ещё создаёт прокси для объекта navigator, чтобы скрыть любые следы автоматизации. Плюс он подменяет toString у navigator.permissions.query, чтобы функция выглядела как нативная.

Вот его код:
JavaScript:
async function applyAutomationSpoof(page) {
  try {
    await page.evaluateOnNewDocument(() => {
      Object.defineProperty(Navigator.prototype, 'webdriver', {
        get: () => undefined,
        configurable: true
      });

      const originalQuery = window.navigator.permissions.query;
      window.navigator.permissions.query = function (parameters) {

        if (parameters.name === 'webdriver') {
          return Promise.resolve({ state: 'denied', onchange: null });
        }
        return originalQuery.call(this, parameters);
      };

      const newNavigator = new Proxy(navigator, {
        has: (target, key) => (key === 'webdriver' ? false : key in target),
        get: (target, key) =>
          key === 'webdriver' ? undefined : Reflect.get(target, key)
      });

      Object.defineProperty(window, 'navigator', {
        get: () => newNavigator
      });

      const originalToString = Function.prototype.toString;
      Function.prototype.toString = new Proxy(originalToString, {

        apply(target, thisArg, args) {
          if (thisArg === window.navigator.permissions.query) {
            return 'function query() { [native code] }';

          }
          return target.apply(thisArg, args);
        }
      });

    });
  } catch (error) {
    console.error('Error applying automation spoofing:', error);
  }
}

Я тестировал этот модуль на https://bot.sannysoft.com/, и он отлично справился — сайт не понял, что я использую puppeteer:
photo_2025-05-11_06-57-15 (2).jpg


Центральный модуль — main.js в папке src — собирает всё воедино. Он читает конфиг, чтобы понять, что спуфить, и для каждой включённой фичи вызывает загрузчик, тянет данные и пишет в лог, что будет подделывать.

Вот как он загружает и логирует User-Agent и Client Hints:
JavaScript:
if (config.spoof['user-agent'] === 'yes') {
  userAgent = await loadUserAgent(config.data_paths['user-agents']);
  console.log('User-Agent spoofing is enabled');
}

if (config.spoof['client-hints'] === 'yes') {
  clientHints = await loadClientHints(config.data_paths['client-hints']);
  console.log('Client Hints spoofing is enabled');
}

Чтобы убедиться, что фейковый отпечаток выглядит правдоподобно, я тестировал его на https://browserleaks.com/javascript:
photo_2025-05-11_06-57-16.jpg

Cайт показал подменённые данные, как и было задумано, без намёка на реальные характеристики.

Чтобы полностью стало понятно, как работает инструмент, напишу его полный путь работы на примере модуля для подмены таймзоны. Сначала main.js читает конфиг и зовёт data-loader.js, чтобы подгрузить данные. Функция loadLocaleAndTimezone открывает locales.txt (где, например, "Japan|ja-JP|ja,ja-JP") и timezones.txt (например, "Japan|Asia/Tokyo"). Она подбирает локаль и таймзону под регион из конфига, а если что-то не так, возвращает дефолт, чтобы ничего не сломалось.

Вот код:
JavaScript:
async function loadLocaleAndTimezone(localePath, timezonePath, selectedRegion) {
  try {
    const localeRaw = await fs.readFile(localePath, 'utf8');
    const timezoneRaw = await fs.readFile(timezonePath, 'utf8');
    const locales = localeRaw.split('\n').filter(Boolean).map(line => {
      const [country, locale, languages] = line.split('|');
      return { country: country.trim(), locale: locale.trim(), languages: languages.split(',').map(lang => lang.trim()) };
    });

    const timezones = timezoneRaw.split('\n').filter(Boolean).map(line => {
      const [country, timezone] = line.split('|');
      return { country: country.trim(), timezone: timezone.trim() };
    });

    const selectedLocale = selectedRegion
      ? locales.find(l => l.country.toLowerCase() === selectedRegion.toLowerCase())
      : locales[Math.floor(Math.random() * locales.length)];
    const selectedTimezone = timezones.find(t => t.country.toLowerCase() === selectedLocale.country.toLowerCase())
      || timezones[Math.floor(Math.random() * timezones.length)];
    return { country: selectedLocale.country, locale: selectedLocale.locale, languages: selectedLocale.languages, timezone: selectedTimezone.timezone };
  } catch (error) {
    console.error('Error loading locale and timezone:', error);
    return { country: 'United States', locale: 'en-US', languages: ['en-US', 'en'], timezone: 'America/New_York' };
  }
}

Функция выдаёт объект, например, { country: "Japan", locale: "ja-JP", languages: ["ja", "ja-JP"], timezone: "Asia/Tokyo" }, который пойдёт дальше.

main.js видит в конфиге, что локаль и таймзона включены, вызывает loadLocaleAndTimezone, получает данные и передаёт их в модуль locale_timezone.js. Логирует, чтобы я знал, что подменяется.

Вот кусок:
JavaScript:
if (config.spoof['locale'] === 'yes' || config.spoof['timezone'] === 'yes') {
  intlSettings = await loadLocaleAndTimezone(
    config.data_paths['locales'],
    config.data_paths['timezones'],
    config.region
  );

  console.log(`Locale spoofing is enabled for ${intlSettings.country} (${intlSettings.locale})`);
  console.log(`Timezone spoofing is enabled for ${intlSettings.country} (${intlSettings.timezone})`);

}

Потом main.js запускает Puppeteer и применяет подмену к странице, вызывая модуль таймзоны:

JavaScript:
if (intlSettings) {
  await applyIntlSpoof(page, intlSettings);
}

Данные (locale, languages, timezone) доходят до locale_timezone.js. Модуль задаёт заголовок Accept-Language, меняет таймзону через Puppeteer и переписывает navigator.language, navigator.languages и Intl.DateTimeFormat, чтобы даты и время в браузере соответствовали нужной стране.

Код:
JavaScript:
async function applyIntlSpoof(page, { locale, languages, timezone }) {
  try {
    await page.setExtraHTTPHeaders({
      'Accept-Language': `${locale},${locale.split('-')[0]};q=0.9`
    });

    await page.emulateTimezone(timezone);
    await page.evaluateOnNewDocument((locale, languages, timezone) => {
      Object.defineProperty(navigator, 'language', { get: () => locale, configurable: true });
      Object.defineProperty(navigator, 'languages', { get: () => languages, configurable: true });
      const originalResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
      Object.defineProperty(Intl.DateTimeFormat.prototype, 'resolvedOptions', {
        value: function () {
          const options = originalResolvedOptions.apply(this);
          return { ...options, locale, timeZone: timezone, calendar: 'gregory', numberingSystem: 'latn', hourCycle: 'h24' };
        },
        writable: true,
        configurable: true

      });
    }, locale, languages, timezone);
    console.log(`Spoofed locale (${locale}) and timezone (${timezone})`);
  } catch (error) {
    console.error('Error spoofing Intl settings:', error);
  }
}

Когда всё срабатывает, в консоли появляется лог, что-то вроде:
Код:
Locale spoofing is enabled for Japan (ja-JP)
Timezone spoofing is enabled for Japan (Asia/Tokyo)
 Spoofed locale (ja-JP) and timezone (Asia/Tokyo)

Это показывает, что таймзона и локаль успешно подменены.

Сборка и запуск моего инструмента
И напоследок — как собрать и запустить мой инструмент. Есть два пути: либо запустить main.js напрямую, либо собрать проект в бинарник.

Для запуска main.js сначала ставим зависимости:
Код:
npm install

Потом запускаем:
Код:
node src/main.js

Если хочешь бинарник, тоже сначала ставим зависимости:
Код:
npm install

А потом собираем под свою платформу:
Для Linux:
Код:
npm run build:linux
Для macOS:
Код:
npm run build:mac
Для Windows:
Код:
npm run build:win

Но есть одна загвоздка: я проверял всё только на Linux, так что за macOS и Windows не ручаюсь — могут быть косяки. Если что, пишите в треде в логи, ошибки помогу разобраться с запуском.


Инструмент на bash
Что же я показал, что можно написать инструмент на Puppeteer, который подменяет всё — от User-Agent до веб-камер. Но что, если хочется чего-то попроще, без Node.js и кучи зависимостей? Вот тут Bash приходит на помощь. Этот инструмент будет лёгким, и идеально подойдёт для базовой подмены отпечатков — шрифты, локаль, таймзона, User-Agent. Погнали разбираться, как это работает!

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

Bash:
FAKE_FONTS_DIR="/tmp/fake-fonts"
CLONE_DIR="$HOME/cloned_extensions"
CONFIG_DIR="$(dirname "$0")"
USERAGENTS_FILE="$CONFIG_DIR/useragents.txt"
CONFIG_FILE="$CONFIG_DIR/country_map.txt"
RESOLUTIONS_FILE="$CONFIG_DIR/resolutions.txt"
PLUGINS_FILE="$CONFIG_DIR/plugins.txt"

Первый — useragents.txt. Там User-Agent’ы, каждый на своей строке. Скрипт случайно выбирает один, чтобы сайт видел другой браузер или устройство.
Второй файл — country_map.txt. Он хранит инфу о странах, таймзонах, локалях и языках в формате страна|таймзона|локаль|код_языка. Это нужно для подмены локали и таймзоны, чтобы казалось, что ты в другой стране.
Третий — resolutions.txt. В нём разрешения экрана, по одной строке на каждое. Скрипт берёт случайное, чтобы подделать размер окна Chrome.
Последний — plugins.txt. Там названия для фейковых расширений, чтобы замаскировать настоящие.
Скрипт модульный — каждая функция подменяет один отпечаток. Даже есть аргументы вроде all для всего или useragent, locale для чего-то конкретного. Функции добавляют флаги в переменную CHROME_ARGS, которая потом идёт в команду запуска Chrome. Еще есть функция очистки, чтобы не оставлять мусора.

Функция spoof_useragent берёт случайный User-Agent:
Bash:
spoof_useragent() {
    UA=$(shuf -n 1 "$USERAGENTS_FILE") || UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"
    CHROME_ARGS="$CHROME_ARGS --user-agent='$UA'"
}
Если файл пустой или что-то пошло не так, используется дефолт, прописанный в коде. User-Agent добавляется в CHROME_ARGS через флаг --user-agent, чтобы сайты думали, что ты на другом устройстве или браузере.

Функция spoof_fonts подменяет шрифты, чтобы Chrome видел только один:
Bash:
spoof_fonts() {
    mkdir -p "$FAKE_FONTS_DIR"
    if [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then
        cp /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf "$FAKE_FONTS_DIR/"
        chmod -R a+r "$FAKE_FONTS_DIR"
        sudo mount --bind "$FAKE_FONTS_DIR" /usr/share/fonts
        sudo fc-cache -f
        CHROME_ARGS="$CHROME_ARGS --disable-remote-fonts"
    else
        echo "Warning: DejaVuSans.ttf not found, skipping font spoofing"
    fi
}
Создаётся папка для фейковых шрифтов, туда копируется шрифт DejaVuSans.ttf, если он есть. Папка монтируется на /usr/share/fonts, так что Chrome думает, что в системе только этот шрифт. Кэш шрифтов обновляется , и добавляется флаг --disable-remote-fonts, чтобы сайты не тянули веб-шрифты. Если шрифт не найден, выдаётся предупреждение, и подмена пропускается.

Функция spoof_locale подменяет локаль и задаёт таймзону для следующей функции:
Bash:
spoof_locale() {
    COUNTRY=$(printf "%s\n" "${!COUNTRY_MAP[@]}" | shuf -n 1) || COUNTRY="USA"
    IFS='|' read -r TZ LOCALE LANG_CODE <<< "${COUNTRY_MAP[$COUNTRY]}"
    export LANG="${LOCALE:-en_US.UTF-8}"
    export LC_ALL="${LOCALE:-en_US.UTF-8}"
    CHROME_ARGS="$CHROME_ARGS --lang=${LANG_CODE:-en-US} --accept-lang=${LANG_CODE:-en-US}"
    export TZ="${TZ:-America/New_York}"
}

Она выбирает случайную страну, парсит строку страна|таймзона|локаль|код_языка и устанавливает переменные окружения LANG и LC_ALL для локали, а TZ — для таймзоны. В CHROME_ARGS добавляются флаги --lang и --accept-lang с кодом языка.

Функция spoof_time подменяет таймзону:
Bash:
spoof_time() {
    CHROME_ARGS="$CHROME_ARGS --timezone='${TZ:-America/New_York}'"
}

Она использует таймзону из spoof_locale и добавляет флаг --timezone в CHROME_ARGS. Если таймзона не задана, берётся America/New_York.

Функция spoof_screen подменяет разрешение экрана:
Bash:
spoof_screen() {
    RESOLUTION=$(shuf -n 1 "$RESOLUTIONS_FILE") || RESOLUTION="1280x720"
    WIDTH=${RESOLUTION%x*}
    HEIGHT=${RESOLUTION#*x}
    SCALE_FACTOR=$(awk 'BEGIN{srand(); print 1+rand()*0.5}')
    CHROME_ARGS="$CHROME_ARGS --window-size=$WIDTH,$HEIGHT --force-device-scale-factor=$SCALE_FACTOR"
}

Данная функция тянет случайную строку из resolutions.txt, разбивает её на ширину и высоту, генерирует случайный масштаб (от 1 до 1.5). В CHROME_ARGS добавляются флаги --window-size=ширина,высота и --force-device-scale-factor=масштаб.

Функция spoof_extensions маскирует плагины:
Bash:
spoof_extensions() {
    EXT_DIR="$HOME/.config/google-chrome/Default/Extensions"
    rm -rf "$CLONE_DIR"
    mkdir -p "$CLONE_DIR"
    declare -a EXT_PATHS
    if [ -d "$EXT_DIR" ]; then
        while IFS= read -r manifest; do
            plugin_dir=$(dirname "$manifest")
            new_id=$(cat /dev/urandom | tr -dc 'a-p' | fold -w 32 | head -n 1)
            new_path="$CLONФE_DIR/$new_id"
            plugin_name=$(shuf -n 1 "$PLUGINS_FILE") || plugin_name="Generic Extension"
            mkdir -p "$new_path"
            cp -r "$plugin_dir"/* "$new_path"
            chmod -R a+r "$new_path"

            jq "del(.key) | .name = \"$plugin_name\" | .description = \"Spoofed: $plugin_name\"" \
               "$new_path/manifest.json" > "$new_path/manifest.json.tmp" && mv "$new_path/manifest.json.tmp" "$new_path/manifest.json" || {
                echo "Warning: Failed to modify $new_id manifest, skipping"
                rm -rf "$new_path"
                continue
            }
            EXT_PATHS+=("$new_path")

        done < <(find "$EXT_DIR" -name "manifest.json")

        if [ ${#EXT_PATHS[@]} -gt 0 ]; then
            CLONED_EXTS=$(IFS=','; echo "${EXT_PATHS[*]}")
            CHROME_ARGS="$CHROME_ARGS --disable-extensions-except='$CLONED_EXTS' --load-extension='$CLONED_EXTS'"

        else
            CHROME_ARGS="$CHROME_ARGS --disable-extensions"
        fi

    else
        CHROME_ARGS="$CHROME_ARGS --disable-extensions"
    fi

}

Она копирует настоящие плагин из ~/.config/google-chrome/Default/Extensions в $HOME/cloned_extensions, создавая для каждого новый случайный ID. В manifest.json меняется имя и описани, используя случайное название из plugins.txt. Папки с фейковыми расширениями добавляются в CHROME_ARGS через --disable-extensions-except и --load-extension. Если расширений нет, включается --disable-extensions.

Функция spoof_media подменяет веб-камеры и микрофоны:
Bash:
spoof_media() {
    CHROME_ARGS="$CHROME_ARGS --use-fake-device-for-media-stream --use-fake-ui-for-media-stream"
}
Эта функция не использует файлы, просто добавляет в CHROME_ARGS флаги --use-fake-device-for-media-stream и --use-fake-ui-for-media-stream, чтобы Chrome подсовывал сайтам фейковые устройства без запросов.

Функция cleanup убирает временные изменения:
Bash:
cleanup() {
    if mountpoint -q /usr/share/fonts; then
        sudo umount /usr/share/fonts || echo "Warning: Failed to unmount /usr/share/fonts"
    fi
    rm -rf "$FAKE_FONTS_DIR"
}
Эта функция размонтирует /usr/share/fonts, если он был смонтирован в spoof_fonts, и удаляет папку /tmp/fake-fonts. Она запускается автоматически при выходе, даже если скрипт крашнется.

Вот инструмент целиком:
Bash:
#!/bin/bash
FAKE_FONTS_DIR="/tmp/fake-fonts"
CLONE_DIR="$HOME/cloned_extensions"
CONFIG_DIR="$(dirname "$0")"
USERAGENTS_FILE="$CONFIG_DIR/useragents.txt"
CONFIG_FILE="$CONFIG_DIR/country_map.txt"
RESOLUTIONS_FILE="$CONFIG_DIR/resolutions.txt"
PLUGINS_FILE="$CONFIG_DIR/plugins.txt"

for file in "$USERAGENTS_FILE" "$CONFIG_FILE" "$RESOLUTIONS_FILE" "$PLUGINS_FILE"; do
    [ -f "$file" ] || { echo "Error: $file missing!"; exit 1; }
done

command -v sudo >/dev/null || { echo "Error: sudo required!"; exit 1; }
command -v jq >/dev/null || { echo "Error: jq required!"; exit 1; }

declare -A COUNTRY_MAP

while IFS='|' read -r country tz locale lang_code; do
    [ "$country" ] && COUNTRY_MAP["$country"]="$tz|$locale|$lang_code"
done < "$CONFIG_FILE"

[ ${#COUNTRY_MAP[@]} -eq 0 ] && { echo "Error: No valid entries in $CONFIG_FILE!"; exit 1; }

cleanup() {
    if mountpoint -q /usr/share/fonts; then
        sudo umount /usr/share/fonts || echo "Warning: Failed to unmount /usr/share/fonts"
    fi
    rm -rf "$FAKE_FONTS_DIR"
}
trap cleanup EXIT

spoof_useragent() {
    UA=$(shuf -n 1 "$USERAGENTS_FILE") || UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"
    CHROME_ARGS="$CHROME_ARGS --user-agent='$UA'"
}

spoof_fonts() {
    mkdir -p "$FAKE_FONTS_DIR"
    if [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then
        cp /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf "$FAKE_FONTS_DIR/"
        chmod -R a+r "$FAKE_FONTS_DIR"
        sudo mount --bind "$FAKE_FONTS_DIR" /usr/share/fonts
        sudo fc-cache -f
        CHROME_ARGS="$CHROME_ARGS --disable-remote-fonts"
    else
        echo "Warning: DejaVuSans.ttf not found, skipping font spoofing"
    fi
}

spoof_locale() {
    COUNTRY=$(printf "%s\n" "${!COUNTRY_MAP[@]}" | shuf -n 1) || COUNTRY="USA"
    IFS='|' read -r TZ LOCALE LANG_CODE <<< "${COUNTRY_MAP[$COUNTRY]}"
    export LANG="${LOCALE:-en_US.UTF-8}"
    export LC_ALL="${LOCALE:-en_US.UTF-8}"
    CHROME_ARGS="$CHROME_ARGS --lang=${LANG_CODE:-en-US} --accept-lang=${LANG_CODE:-en-US}"
    export TZ="${TZ:-America/New_York}"
}

spoof_time() {
    CHROME_ARGS="$CHROME_ARGS --timezone='${TZ:-America/New_York}'"
}

spoof_screen() {
    RESOLUTION=$(shuf -n 1 "$RESOLUTIONS_FILE") || RESOLUTION="1280x720"
    WIDTH=${RESOLUTION%x*}
    HEIGHT=${RESOLUTION#*x}
    SCALE_FACTOR=$(awk 'BEGIN{srand(); print 1+rand()*0.5}')
    CHROME_ARGS="$CHROME_ARGS --window-size=$WIDTH,$HEIGHT --force-device-scale-factor=$SCALE_FACTOR"
}

spoof_extensions() {
    EXT_DIR="$HOME/.config/google-chrome/Default/Extensions"
    rm -rf "$CLONE_DIR"
    mkdir -p "$CLONE_DIR"
    declare -a EXT_PATHS

    if [ -d "$EXT_DIR" ]; then
        while IFS= read -r manifest; do
            plugin_dir=$(dirname "$manifest")
            new_id=$(cat /dev/urandom | tr -dc 'a-p' | fold -w 32 | head -n 1)
            new_path="$CLONE_DIR/$new_id"
            plugin_name=$(shuf -n 1 "$PLUGINS_FILE") || plugin_name="Generic Extension"
            mkdir -p "$new_path"
            cp -r "$plugin_dir"/* "$new_path"
            chmod -R a+r "$new_path"

            jq "del(.key) | .name = \"$plugin_name\" | .description = \"Spoofed: $plugin_name\"" \
               "$new_path/manifest.json" > "$new_path/manifest.json.tmp" && mv "$new_path/manifest.json.tmp" "$new_path/manifest.json" || {
                echo "Warning: Failed to modify $new_id manifest, skipping"
                rm -rf "$new_path"
                continue
            }
            EXT_PATHS+=("$new_path")

        done < <(find "$EXT_DIR" -name "manifest.json")

        if [ ${#EXT_PATHS[@]} -gt 0 ]; then
            CLONED_EXTS=$(IFS=','; echo "${EXT_PATHS[*]}")
            CHROME_ARGS="$CHROME_ARGS --disable-extensions-except='$CLONED_EXTS' --load-extension='$CLONED_EXTS'"
        else
            CHROME_ARGS="$CHROME_ARGS --disable-extensions"
        fi
    else
        CHROME_ARGS="$CHROME_ARGS --disable-extensions"
    fi
}

spoof_media() {
    CHROME_ARGS="$CHROME_ARGS --use-fake-device-for-media-stream --use-fake-ui-for-media-stream"
}

CHROME_ARGS="--no-first-run --no-sandbox --disable-background-networking --disable-cache --disk-cache-dir=/dev/null"
[ $# -eq 0 ] && { echo "Usage: $0 [all|useragent|fonts|locale|time|screen|extensions|media]..."; exit 1; }

for arg in "$@"; do
    case "$arg" in
        all) spoof_useragent; spoof_fonts; spoof_locale; spoof_time; spoof_screen; spoof_extensions; spoof_media ;;
        useragent) spoof_useragent ;;
        fonts) spoof_fonts ;;
        locale) spoof_locale ;;
        time) spoof_time ;;
        screen) spoof_screen ;;
        extensions) spoof_extensions ;;
        media) spoof_media ;;
        *) echo "Unknown option: $arg"; exit 1 ;;
    esac
done

eval "google-chrome $CHROME_ARGS & disown"

Целиком он работает будучи запущенным с аргументами, которые говорят, что подменять. Например, ./spoof-chrome.sh all подменять всё, а ./spoof-chrome.sh useragent screen — только User-Agent и разрешение. Потом country_map.txt парсится в массив COUNTRY_MAP. Задаются пути для временных папок (/tmp/fake-fonts, $HOME/cloned_extensions) и базовые флаги Chrome: --no-first-run, --no-sandbox, --disable-background-networking, --disable-cache, --disk-cache-dir=/dev/null.
Каждый аргумент вызывает свою функцию, добавляющую флаги в CHROME_ARGS. Когда всё собрано, Chrome запускается через eval "google-chrome $CHROME_ARGS & disown" в фоновом режиме. При выходе cleanup убирает временные файлы.

Запускам скрипт и проверяем результат подмены на https://browserleaks.com/javascript:
Screenshot_2025-05-15_23-11-03.png


Как мы видим все работает, юзер агенты, локаль, время и плагины подменяются.


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

Для начала разберёмся с таймзонами, локалями и кодами стран. Хороший источник для таких данных — публичный API на https://cdn.simplelocalize.io/public/v1/locales. Там лежит JSON с кучей полезного: названия стран, их коды, локали, языки, таймзоны и даже столицы:
photo_2025-05-11_06-54-27.jpg

Проблема в том, что JSON нам не совсем подходит: для Puppeteer нужен структурированный формат, а для Bash — простые текстовые файлы с разделителями.

Поэтому я написал Python-скрипт, который тянет данные с API и конвертирует их в нужные форматы для обоих инструментов:
Python:
import requests
from typing import List, Dict

url = "https://cdn.simplelocalize.io/public/v1/locales"
response = requests.get(url)
data = response.json()

country_code_list: List[str] = []
locale_list: List[str] = []
timezone_list: List[str] = []
country_map_list: List[str] = []

for entry in data:
    country_name = entry['country']['name']
    country_code = entry['country']['code']
    locale = entry['locale']
    language = entry['language']['iso_639_1']
    continent = entry['country']['continent']
    capital_name = entry['country']['capital_name']
    timezone_raw = entry['country']['timezones'][0] if entry['country']['timezones'] else ''

    timezone = f"{continent}/{capital_name}" if continent and capital_name else ''

    offset = 0
    if timezone_raw and 'UTC' in timezone_raw:
        try:
            time_str = timezone_raw.replace('UTC', '').replace(':', '')
            hours = int(time_str[:3])
            minutes = int(time_str[3:]) if len(time_str) > 3 else 0
            offset = hours * 60 + minutes
        except ValueError:
            offset = 0

    if country_code:
        country_code_list.append(f"{country_name}:{country_code}")

    if locale and language:
        language_variants = f"{locale},{language}"
        locale_list.append(f"{country_name}|{locale}|{language_variants}")

    if timezone:
        timezone_list.append(f"{country_name}|{timezone}|{offset}")

    if timezone and locale and language:
        formatted_locale = f"{locale.replace('-', '_')}.UTF-8"
        country_map_list.append(f"{country_name}|{timezone}|{formatted_locale}|{locale}")

def save_to_file(filename: str, lines: List[str]) -> None:
    with open(filename, 'w', encoding='utf-8') as f:
        f.write('\n'.join(sorted(lines)) + '\n')

save_to_file('country-codes.txt', country_code_list)
save_to_file('locales.txt', locale_list)
save_to_file('timezones.txt', timezone_list)
save_to_file('country_map.txt', country_map_list)

print("Данные успешно сохранены в файлы:")
print("- country-codes.txt")
print("- locales.txt")
print("- timezones.txt")
print("- country_map.txt")

Тестим:
photo_2025-05-11_06-52-48.jpg

Что делает этот код? Он запрашивает информацию о странах из API, которая приходит в виде JSON, и для каждой страны выхватывает ключевые детали: название, код региона, локаль, язык, континент, столицу и таймзону. Затем он обрабатывает эти данные, чтобы ничего не сломалось, даже если какие-то поля пустые. Например, если таймзона не указана код подставляет значения по умолчанию.
После обработки данные распределяются по четырём файлам. Первый файл сохраняет пары "страна:код региона". Второй формирует строки: "страна|язык|локаль" для подмены локалей тоже для Puppeteer. Третий содержит таймзоны, например, "страна|таймзона|смещение в минутах от UTC". Последний комбинирует всё для Bash-скрипта в формате "страна|таймзона|язык|локаль".
После запуска получаем готовые файлы, которые можно закинуть в папку data для Puppeteer или рядом с Bash-скриптом.

Теперь к городам — это уже только для Puppeteer, в нем есть скрипт для подделки координат, который работает по городам определенных стран. Страны мы уже умеем брать из cdn.simplelocalize.io, но вот города — это отдельная песня. Можно, конечно, парсить базы вроде OpenStreetMap, но я решил не усложнять и взял Python-библиотеку geonamescache. Она помогает получать города конкретных стран, а так же в ней можно использовать различные фильтры.

Вот код:
Python:
import requests
from geonamescache import GeonamesCache
from typing import List, Dict

gc = GeonamesCache()

url = "https://cdn.simplelocalize.io/public/v1/locales"
response = requests.get(url)

data = response.json()

countries: Dict[str, str] = {}
for entry in data:
    country_name = entry['country']['name']
    capital_name = entry['country']['capital_name']
    country_code = entry['country']['code']
    if country_name not in countries:
        countries[country_name] = {'capital': capital_name, 'code': country_code}

regions_list: List[str] = []

for country_name, info in countries.items():
    capital_name = info['capital']
    country_code = info['code']
  
    cities = set([capital_name]) if capital_name else set()
  
    if country_code:
        all_cities = gc.get_cities()
        country_cities = [
            all_cities[city_id]['name'] for city_id in all_cities
            if all_cities[city_id]['countrycode'] == country_code
            and all_cities[city_id]['population'] > 500000
            and all_cities[city_id]['name'] != capital_name
        ]
        cities.update(country_cities)
  
    if cities:
        regions_list.append(f"{country_name}: {', '.join(sorted(cities))}")

def save_to_file(filename: str, lines: List[str]) -> None:
    with open(filename, 'w', encoding='utf-8') as f:
        f.write('\n'.join(sorted(lines)) + '\n')

save_to_file('regions.txt', regions_list)

print("Данные успешно сохранены в файл: regions.txt")

Проверяем как работает код:
photo_2025-05-11_06-52-46.jpg

Код работает так:
Cначала он получает страны, их столицы и коды из API, складывая всё в словарь. Потом для каждой страны c помощью geonamescache и ищет города с населением больше 500000, на самом деле вы можете сделать и ограничение меньше, я так сделал чтобы попадались только крупные города, да и скрипт так быстрее работает. Столица добавляется в список автоматом, а к ней подтягиваются найденные города, это сделано на случай, если не найдется города подходящего размера, чтобы столица была в любом случае. Далее собранные данные добавляются в regions.txt.

Я рассказал как получать данные для таймзон и регионов, но теперь пришло время User-Agent’ов. Я искал, где взять списки User-Agent, но чистых файлов, готовых для скачивания, где достаточно много агентов, не нашёл — может, плохо смотрел. В итоге наткнулся на репу на GitHub — https://github.com/tamimibrahim17/List-of-user-agents. Там всё разложено по файлам: Chrome, Firefox, Safari, Edge, Opera, Internet Explorer и Android Webkit. Формат удобный, можно использовать по отдельности или склеить в один список. Проблема только в том, что данные старые — большинству 5 лет, а некоторым и все 8. Но думаю сойдёт для обычной маскировки, хотя умные трекеры и могут заподозрить неладное, если браузер имеет слишком старую версию. Ничего, для начала хватит, а дальше я расскажу, как их можно объединить в один файл для удобства.

Вот Bash-скрипт, который я написал, чтобы склеить и перемешать файлы из репозитория с юзер-агетами:
Bash:
#!/bin/bash

files=("Android+Webkit+Browser.txt" "Chrome.txt" "Edge.txt" "Firefox.txt" "Internet+Explorer.txt" "Opera.txt" "Safari.txt")

for file in "${files[@]}"; do
    if [[ ! -f "$file" ]]; then
        echo "Ошибка: Файл $file не найден"
        exit 1
    fi
done

cat "${files[@]}" | shuf > all_useragents.txt

echo "Файлы успешно добавлены в all_useragents.txt"

Скрипт простой: я закинул имена файлов в массив в скрипте, если вам нужны только определенные браузеры просто уберите не нужные файлы из массива. Сначала мой код проверяет, что файлы на месте. Потом сливает все файлы в один поток, а shuf тасует строки рандомно. На выходе — all_useragents.txt.
Теперь про то, где искать свежие User-Agent’ы. Репа, что я нашёл, хороша, но устарела. Есть варианты получше: например, https://gist.github.com/pzb/b4b6f57144aea7827ae4 — там готовый файл с User-Agent’ами, который можно просто скачать и закинуть в скрипт. Ещё круче — https://user-agents.net/download, где куча агентов для разных браузеров, доступных для скачивания, правда там нельзя скачать файл один сразу с кучей User-Agent разных браузеров, зато они посвежее.

Раз мыс вами заговорили про user-agent, расскажу про client hint заголовки и про то где их находить. Я искал готовые списки client hints и нашёл кое-что на https://deviceandbrowserinfo.com/data/fingerprints/attribute/headers.sec-ch-ua, но там только часть данных, а полного набора нигде не было. И тут меня осенило: а что, если генерировать client hints из User-Agent? Ведь они часто содержат похожую инфу! Недолго думая, я засел за код, чтобы превратить User-Agent в правдоподобные client hints.

Вот мой Python-скрипт:
Python:
from user_agents import parse

def user_agent_to_client_hints(user_agent):
    try:
        ua = parse(user_agent.strip())
      
        browser = ua.browser.family
        version = ua.browser.version_string.split('.')[0]
        full_version = ua.browser.version_string
      
        browser_map = {
            'Firefox': 'Firefox',
            'Chrome': 'Google Chrome',
            'Edge': 'Microsoft Edge',
            'Safari': 'Safari',
            'Opera': 'Opera',
            'Internet Explorer': 'Internet Explorer',
            'Chromium': 'Chromium'
        }
        browser_name = browser_map.get(browser, browser)
      
        ch_ua = f'"{browser_name}";v="{version}", "Not A Brand";v="99"'
      
        ch_ua_full = f'"{browser_name}";v="{full_version}", "Not A Brand";v="99.0.0.0"'
      
        os = ua.os.family
        os_version = ua.os.version_string or "10.0.0"
        os_map = {
            'Windows': 'Windows',
            'Mac OS X': 'macOS',
            'Linux': 'Linux',
            'Ubuntu': 'Linux',
            'Android': 'Android',
            'iOS': 'iOS'
        }
        os_name = os_map.get(os, os)
      
        is_mobile = ua.is_mobile
        mobile_flag = '?1' if is_mobile else '?0'
      
        architecture = 'x86'
        bitness = '64'
        if 'arm' in ua.device.family.lower():
            architecture = 'arm'
        elif 'aarch64' in ua.ua_string.lower():
            architecture = 'arm'
            bitness = '64'
      
        model = '""'
      
        result = (
            f'{ch_ua}|"{os_name}"|{mobile_flag}|"{architecture}"|"{bitness}"|'
            f'"{full_version}"|{ch_ua_full}|{model}|"{os_version}"'
        )
        return result
    except Exception as e:
        return f'Error parsing "{user_agent.strip()}": {str(e)}'

def process_user_agents(input_file, output_file):
    with open(input_file, 'r', encoding='utf-8') as f:
            user_agents = f.readlines()
      
    unique_hints = set()
      
    for ua in user_agents:
        if ua.strip():
           client_hints = user_agent_to_client_hints(ua)
           unique_hints.add(client_hints)
      
    with open(output_file, 'w', encoding='utf-8') as f:
         for hint in sorted(unique_hints):
             f.write(f'{hint}\n')
            
    return True
 
if __name__ == "__main__":
    input_file = "all_useragents.txt"
    output_file = "client-hints.txt"
    if process_user_agents(input_file, output_file):
        print(f"Client hints successfully written to {output_file}")

Проверяем работу скрипта:
photo_2025-05-11_06-50-08.jpg

Мой код берет файл all_useragents.txt, сделанный мной ранее, и прогоняю его через библиотеку user_agents, которая парсит строки и вытягивает данные о браузере, ОС и устройстве. Функция user_agent_to_client_hints берёт каждый User-Agent и генерит из него Client Hints. Сначала она определяет браузер и его версию. Полную версию тоже сохраняю для Sec-CH-UA-Full-Version. ОС мапится на понятные имена: Windows остаётся Windows, Mac OS X превращается в macOS, Ubuntu — в Linux. Если User-Agent с мобилы, ставлю флаг ?1, иначе ?0. Архитектуру (x86 или arm) и разрядность (64) угадываю по устройству, а модель пока оставляю пустой, я могу конечно генерить случайную модель, но мне кажется это будет более подозрительно. Да, модели можно брать допустим из базы устройств, но я не стал так запариваться, если хотите, можете сделать это самостоятельно, такая база для телефонов к примеру есть на https://www.teoalida.com/database/phones. Далее скрипт всё собирает в строку с разделителями | и результат загружаться в client-hints.txt.

Я разобрал как добывать Client Hints, а теперь пришло время локальных IP-адресов — это нужно для инструмента на Puppeteer, чтобы подменять IP компа и делать отпечаток ещё правдоподобнее. Я подумал, что брать списки IP из интернета — не особо умная затея, ведь локальные IP всегда лежат в чётких диапазонах, так что любой адрес из этих диапазонов будет выглядеть реалистично, потому морока с поисками списков будет лишней. Вместо этого я написал Python-скрипт, который сам генерит IP в нужных диапазонах.

Вот мой код:
Python:
import random

def generate_random_ip(ranges):
    range_choice = random.choice(list(ranges.keys()))
    start, end = ranges[range_choice]
  
    ip_int = random.randint(start, end)
  
    ip = f"{(ip_int >> 24) & 255}.{(ip_int >> 16) & 255}.{(ip_int >> 8) & 255}.{ip_int & 255}"
    return ip

ranges = {
    "10.0.0.0/8": (167772160, 184549375),   
    "172.16.0.0/12": (2886729728, 2887778303),
    "192.168.0.0/16": (3232235520, 3232301055)
}

total_ips = sum(end - start + 1 for start, end in ranges.values())

num_ips = 400

if num_ips > total_ips:
    raise ValueError(f"Запрошено {num_ips} IP, но доступно только {total_ips} уникальных адресов")

ip_set = set()
while len(ip_set) < num_ips:
    ip_set.add(generate_random_ip(ranges))

ip_list = list(ip_set)
random.shuffle(ip_list)

with open('local_ips.txt', 'w') as f:
    for ip in ip_list:
        f.write(ip + '\n')

print(f"Сгенерировано {num_ips} уникальных IP-адресов и записано в local_ips.txt")

Проверяем работу:
photo_2025-05-11_06-50-08 (2).jpg

Этот скрипт получает три стандартных диапазона для локальных IP: 10.0.0.0/8, 172.16.0.0/12 и 192.168.0.0/16 — это те самые адреса, которые обычно используют домашние сети и роутеры. Каждый диапазон переведён в числовые границы (от и до), чтобы можно было генерировать IP как числа. Функция generate_random_ip случайным образом выбирает один из диапазонов, берёт случайное число в его границах и превращает его в IP-адрес вида 192.168.1.123. Чтобы не было дублей, я собираю IP в множество ip_set, пока не наберётся 400 уникальных адресов (в переменной num_ips, но если вам нужно вы можете указать другое число, в любом случае если запросить больше IP, чем возможно в диапазонах, скрипт будет ругаться). Потом адреса пропускаються через random.shuffle и записываются в local_ips.txt.

Давайте теперь поговорим, как получать реалистичные названия плагинов.
Я облазил интернет в поисках готовых списков плагинов, но ничего толкового не нашёл — максимум какие-то сборники, да и те небольшие. Генерировать названия самому тоже не вариант: выйдет что-то вроде “SuperBlocker3000”, и любой сайт сразу заподозрит фейк. Тут меня осенило: а что, если брать названия прямо из Chrome Web Store? Там же куча реальных плагинов, и их можно спарсить! Недолго думая, я написал Python-скрипт, который ходит по магазину и собирает имена плагинов.

Я написал скрипт на питоне для этого:
Python:
import requests
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time
import random
import threading
import os

MAX_PLUGINS_PER_QUERY = 10
MAX_ITERATIONS = 8         
NUM_THREADS = 3           
BASE_URL = "https://chromewebstore.google.com/search/"
OUTPUT_FILE = "chrome_plugins.txt"
LOCK = threading.Lock()   

WORDS = [
    "pdf", "video", "adblock", "privacy", "download", "tab", "reader", "cloud",
    "note", "timer", "translate", "grammar", "dark", "theme", "password", "save",
    "productivity", "todo", "block", "search", "editor", "calendar", "music", "sync"
]

def get_existing_plugins():
    if not os.path.exists(OUTPUT_FILE):
        return set()
    with open(OUTPUT_FILE, "r", encoding="utf-8") as f:
        return set(line.strip() for line in f if line.strip())

def parse_plugins(url, max_plugins, existing_plugins):
    chrome_options = Options()
    chrome_options.add_argument("--headless")
    driver = webdriver.Chrome(options=chrome_options)
    plugins = []
  
    try:
        driver.get(url)
        wait = WebDriverWait(driver, 10)

        while len(plugins) < max_plugins:
            soup = BeautifulSoup(driver.page_source, "html.parser")
            plugin_elements = soup.find_all("h2", class_="CiI2if")
          
            for elem in plugin_elements:
                if len(plugins) >= max_plugins:
                    break
                plugin_name = elem.text.strip()
                if plugin_name and plugin_name not in existing_plugins and plugin_name not in plugins:
                    plugins.append(plugin_name)

            try:
                load_more_button = wait.until(
                    EC.element_to_be_clickable((By.XPATH, "//span[@jsname='V67aGc' and text()='Load more']"))
                )
                load_more_button.click()
                time.sleep(2)
            except:
                break
    finally:
        driver.quit()
  
    return plugins

def process_word(word, max_plugins, existing_plugins):
    search_url = f"{BASE_URL}{word}"
    print(f"Поиск по слову '{word}'")
  
    plugins = parse_plugins(search_url, max_plugins, existing_plugins)
  
    if plugins:
        with LOCK:
            with open(OUTPUT_FILE, "a", encoding="utf-8") as f:
                for plugin in plugins:
                    if plugin not in existing_plugins:
                        f.write(f"{plugin}\n")
                        existing_plugins.add(plugin)

def main():
    existing_plugins = get_existing_plugins()
  
    threads = []
    for i in range(MAX_ITERATIONS):
        word = random.choice(WORDS)
        thread = threading.Thread(
            target=process_word,
            args=(word, MAX_PLUGINS_PER_QUERY, existing_plugins)
        )
        threads.append(thread)
  
    active_threads = []
    for i in range(0, MAX_ITERATIONS, NUM_THREADS):
        for j in range(min(NUM_THREADS, MAX_ITERATIONS - i)):
            threads[i + j].start()
            active_threads.append(threads[i + j])
      
        for thread in active_threads:
            thread.join()
      
        active_threads = []
  
    print(f"Результаты сохранены в {OUTPUT_FILE}")

if __name__ == "__main__":
    main()

Проверяем работу:
photo_2025-05-11_06-44-44.jpg

Код парсит плагины с помощью Selenium, это как Puppeteer, только для Python и попроще. Скрипт работает в headless-режиме, чтобы не мельтешить окнами. Он парсит Chrome Web Store, прогоняя поиск по списку ключевых слов вроде “adblock” или “pdf” Для каждого слова он заходит на страницу поиска, собирает названия плагинов (до указанного числа в MAX_PLUGINS_PER_QUERY), щёлкает “Load more”, если надо, и идёт дальше, пока не наберёт нужное или не кончатся плагины. Чтобы всё шло быстрее, я сделал скрипт многопоточным — он может искать по несколько слов одновременно, их число можно указать в NUM_THREADS, так же скрипт делает несколько итераций, по разным словам, число итераций тоже можно указать вверху скрипта, в MAX_ITERATIONS. Названия плагинов сохраняются в chrome_plugins.txt, и скрипт проверяет, чтобы не было дублей, добавляя только новые имена.

Давайте поговорим про вендоров — это производители браузеров, чьи имена всплывают в отпечатках через свойства вроде navigator.vendor и navigator.productSub. Как и с другими данными, я полез искать готовые списки, но, как уже стало традицией, ничего толкового не нашёл. Сначала я хотел спарсить вендоров из списка браузеров на https://en.wikipedia.org/wiki/Comparison_of_web_browsers, думая, что имя разработчика будет совпадать с вендором. Но потом до меня дошло: вендор не всегда равен названию компании, да и данные в отпечатках бывают хитрее. Парсинг тоже не особо помог, так что пришлось вручную собирать список популярных вендоров, роясь в доках и на сайтах. В итоге я составил список, который выглядит правдоподобно и подходит для подмены.

Мой список вендоров выглядит так:
Код:
Google Inc.|20030107|
Google LLC|20030107|
Apple Computer, Inc.|20030107|
Apple Inc.|20030107|
Mozilla Corporation|20100101|
Opera Software ASA|20030107|
Microsoft Corporation|undefined|
Brave Software Inc.|20030107|
Vivaldi Technologies|20030107|
Yandex LLC|20030107|
Samsung Electronics Co., Ltd.|20030107|
Amazon.com, Inc.|20030107|
Baidu, Inc.|20030107|
Tencent Holdings Ltd.|20030107|
UCWeb Inc.|20030107|
Maxthon International Limited|20030107|
The Chromium Authors|20030107|
SeaMonkey Council|20100101|
Waterfox Ltd.|20100101|
Pale Moon Project|20100101|
Basilisk Team|20100101|
Comodo Group, Inc.|20030107|
SRWare|20030107|
Avast Software s.r.o.|20030107|
Cliqz GmbH|20030107|
Dooble Project|20030107|
KDE e.V.|20030107|
GNOME Foundation|20030107|
Haiku, Inc.|20030107|
Lunascape Corporation|20030107|
Torch Media Inc.|20030107|
Slimjet|20030107|
Epic Privacy Browser|20030107|
Cent Browser|20030107|
Cốc Cốc|20030107|
360 Security Technology Inc.|20030107|
DuckDuckGo, Inc.|20030107|
Tor Project, Inc.|20100101|
Basilisk Team|20100101|
Midori Team|20030107|
Otter Browser Team|20030107|
Qwant SAS|20030107|
Чтобы собрать этот список, я начал с https://en.wikipedia.org/wiki/Comparison_of_web_browsers — там таблица с браузерами, их разработчиками и движками. Это дало мне основу: Google, Mozilla, Apple, Microsoft. Но вендоры в navigator.vendor не всегда повторяют имя разработчика — Chrome может показывать “Google Inc.” или “Google LLC”, а Firefox — “Mozilla Corporation”. Для точности я полез в документацию на https://developer.mozilla.org/en-US/docs/Web/API/Navigator/vendor и https://developer.mozilla.org/en-US/docs/Web/API/Navigator/productSub. Там сказано, что vendor — это строка с именем производителя, а productSub — обычно дата вроде “20030107” или “20100101”, хотя у Microsoft стоит “undefined”. Эти доки помогли уточнить формат и добавили пару вендоров, вроде Opera Software ASA.
Но Вики и Mozilla не хватило — я хотел охватить и менее известные браузеры. Тогда я начал копать глубже, я заглянул на официальные сайты браузеров — Brave, Vivaldi, Yandex Browser — и подтвердил вендоров вроде “Brave Software Inc.” или “Yandex LLC”. Для нишевых браузеров, таких как Waterfox, Pale Moon или Cốc Cốc, пришлось рыться в их блогах, GitHub-репозиториях и обзорах на сайтах вроде TechRadar, Softonic и AlternativeTo. Например, “Waterfox Ltd.” и “Pale Moon Project” нашёл на их сайтах, а “Cốc Cốc” попался в статье про вьетнамские браузеры.

Давайте теперь поговорим, о том, где брать списки медиаустройств для подмены в скрипте на Puppeteer — это геймпады, микрофоны и веб-камеры. Я искал готовые списки, особо не надеясь на удачу, и, как и ожидал, ничего толкового не нашёл. Пытался искать по отдельности — геймпады, микрофоны, камеры — но и тут облом. Зато в процессе я натыкался на интернет-магазины, где продают эти устройства. И вдруг мне пришла идея, а почему бы не спарсить названия оттуда? А что, инфа свежая, и выглядит реалистично, да и парсинг должен быть не особо сложным.
Я выбрал три магазина, с которых, как мне показалось, проще всего парсить данные: https://recordinghacks.com, https://www.provantage.com и https://ek.ua/ua/ek-list.php.

И написал Python-скрип для парса:
Python:
import requests
from bs4 import BeautifulSoup
import ssl
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

DEVICE_LIMIT = 26

def parse_microphone_names(limit):
    microphone_names = []
    base_url = "https://recordinghacks.com"
    main_url = f"{base_url}/microphones"
  
    try:
        response = requests.get(main_url, verify=False)
        response.raise_for_status()
        soup = BeautifulSoup(response.text, 'html.parser')
      
        manufacturer_items = soup.find_all('li', class_=['', 'alt'])
        manufacturer_links = []
      
        for item in manufacturer_items:
            link = item.find('a')
            if link and 'href' in link.attrs and '/microphones/' in link['href']:
                manufacturer_links.append(base_url + link['href'])
      
        for manuf_url in manufacturer_links:
            if len(microphone_names) >= limit:
                break
            try:
                manuf_response = requests.get(manuf_url, verify=False)
                manuf_response.raise_for_status()
                manuf_soup = BeautifulSoup(manuf_response.text, 'html.parser')
              
                mic_tiles = manuf_soup.find_all('li', class_='mictile')
              
                for tile in mic_tiles:
                    if len(microphone_names) >= limit:
                        break
                    span = tile.find('span')
                    if span:
                        mic_name = span.text.strip()
                        microphone_names.append(mic_name)
                      
            except requests.RequestException as e:
                print(f"Error fetching {manuf_url}: {e}")
                continue
              
    except requests.RequestException as e:
        print(f"Error fetching {main_url}: {e}")
  
    return microphone_names
  
def parse_camera_names(limit):
    camera_names = []
    base_url = "https://www.provantage.com/~67CWEBCM{}.htm"
  
    for i in range(1, 7):
        if len(camera_names) >= limit:
            break
        url = base_url.format(i)
        try:
            response = requests.get(url)
            response.raise_for_status()
            soup = BeautifulSoup(response.text, 'html.parser')
          
            camera_divs = soup.find_all('div', class_='BOX5B')
          
            for div in camera_divs:
                if len(camera_names) >= limit:
                    break
                product_link = div.find('a', class_='BOX5PRODUCT')
                if product_link:
                    camera_name = product_link.text.strip()
                    camera_names.append(camera_name)
                  
        except requests.RequestException as e:
            print(f"Error fetching {url}: {e}")
            continue
  
    return camera_names

def parse_gamepad_names(limit):
    gamepad_names = []
    base_url = "https://ek.ua/ua/ek-list.php?katalog_=200&page_={}&presets_=841&preset_mode_=0"
  
    for page in range(1, 12):
        if len(gamepad_names) >= limit:
            break
        url = base_url.format(page)
        try:
            response = requests.get(url)
            response.raise_for_status()
            soup = BeautifulSoup(response.text, 'html.parser')
          
            gamepad_rows = soup.find_all('tr', valign='top')
          
            for row in gamepad_rows:
                if len(gamepad_names) >= limit:
                    break
                title_link = row.find('a', class_='model-short-title')
                if title_link:
                    gamepad_name = title_link.find('span', class_='u').text.strip()
                    gamepad_names.append(gamepad_name)
                  
        except requests.RequestException as e:
            print(f"Error fetching {url}: {e}")
            continue
  
    return gamepad_names

def combine_device_lists():
    cameras = parse_camera_names(DEVICE_LIMIT)
    microphones = parse_microphone_names(DEVICE_LIMIT)
    gamepads = parse_gamepad_names(DEVICE_LIMIT)
  
    if not (cameras or microphones or gamepads):
        raise ValueError("Error: No devices found. At least one device list must contain a non-empty value.")
  
    cameras.extend([""] * (DEVICE_LIMIT - len(cameras)))
    microphones.extend([""] * (DEVICE_LIMIT - len(microphones)))
    gamepads.extend([""] * (DEVICE_LIMIT - len(gamepads)))
  
    with open('media-devices.txt', 'w', encoding='utf-8') as f:
        for i in range(DEVICE_LIMIT):
            line = f"{cameras}|{microphones}|{gamepads}\n"
            f.write(line)   
    
combine_device_lists()

Проверяем работает ли код:
photo_2025-05-11_06-44-44 (2).jpg

В данном скрипте я использую библиотеку BeautifulSoup, чтобы парсить названия устройств с трёх интернет-магазинов. Скрипт настроен так, что ты задаёшь лимит устройств (DEVICE_LIMIT = 26), и он собирает ровно столько названий для каждой категории — веб-камер, микрофонов и геймпадов. Если на сайте заканчиваются устройства или не хватает до лимита, он добавляет пустые строки, чтобы сохранить структуру, ведь в реальной жизни у юзера может не быть, скажем, геймпада, но камера и микрофон есть. Но если все списки пустые, скрипт ругается и падает, чтобы не создавать бессмысленный файл.
Для микрофонов я распарсил https://recordinghacks.com. Функция parse_microphone_names заходит на страницу /microphones, находит ссылки на производителей, переходит по ним и собирает названия из элементов с классом mictile. Парсинг идёт, пока не наберётся нужное число микрофонов или пока не кончатся страницы. Для веб-камер я беру https://www.provantage.com, где функция parse_camera_names проходит по страницам с URL типа ~67CWEBCM{}.htm (до 6 страниц) и выхватывает названия из элементов с классом BOX5PRODUCT. Для геймпадов используется https://ek.ua/ua/ek-list.php — функция parse_gamepad_names обходит до 11 страниц и берёт названия из строк таблицы с классом model-short-title. Если страница не грузится, скрипт логирует ошибку и идёт дальше, чтобы не ломаться.
После парсинга функция combine_device_lists объединяет списки: берёт камеры, микрофоны и геймпады, выравнивает их по длине пустыми строками и записывает в media-devices.txt в формате камера|микрофон|геймпад.

Давайте теперь поговорим, где брать списки видеокарт для подмены в скрипте на Puppeteer. Как обычно, я полез искать готовые списки, особо не надеясь на успех. Но тут мне повезло: нашёл два источника. Первый — старый и достаточно короткий список на GitHub, https://gist.github.com/roalercon/51f13a387f3754615cce#file-graphics-card-device-ids-L9. Второй — большой список на 3000 строк, https://www.kaggle.com/datasets/ala...ecs?resource=download&select=gpu_specs_v7.csv. Естественно, я скачал новый и написал Python-скрипт, чтобы перевести данные из CSV в формат, который нужен для моего скрипта.

Мой код:
Python:
import csv

input_file = "gpu_specs_v7.csv"
output_file = "videocards.txt"

with open(input_file, newline='', encoding='utf-8-sig') as csvfile, open(output_file, 'w', encoding='utf-8') as txtfile:
    reader = csv.DictReader(csvfile)
    
    for row in reader:
        manufacturer = row['manufacturer'].strip()
        product_name = row['productName'].strip()
        
        line = f"{manufacturer}|{product_name}\n"
        
        txtfile.write(line)

print(f"Данные успешно записаны в {output_file}")

Проверяем:
photo_2025-05-11_06-44-44 (3).jpg

Мой код довольно прост. Открывает CSV-файл gpu_specs_v7.csv с Kaggle, читает его построчно с помощью csv.DictReader и выхватывает два столбца: manufacturer(производитель, типа NVIDIA или AMD) и productName(модель, например, “GeForce RTX 3080”). Потом склеивает их в строку формата производитель|модель, и пишет в videocards.txt.

Далее осталась подделка по мелочи, а именно список параметров сети. Параметры сети можно пересчитать по пальцам: тип сети, полоса пропускания, задержка и флаг мобильности. Я полез в интернет, чтобы собрать инфу, и составил такой список:
Код:
cellular|3g|150|2|true
cellular|4g|80|20|true
wifi|2.4g|35|30|false
wifi|5g|15|200|false
satellite|starlink|60|150|true
Есть ещё 2G, но им почти никто не пользуется, а локальные сети JavaScript толком не видит, так что мой список вышел довольно полным. Но вот в чём штука: задержка и пропускная способность могут варьироваться, так что я написал Python-скрипт, который генерирует эти параметры в реалистичных пределах для каждой сети.

Вот мой код:
Python:
import random

num_networks = 12

networks = [
    {"type": "cellular", "band": "3g", "latency": (150, 400), "bandwidth": (2, 5), "is_mobile": True},
    {"type": "cellular", "band": "4g", "latency": (50, 100), "bandwidth": (10, 50), "is_mobile": True},
    {"type": "cellular", "band": "5g", "latency": (10, 30), "bandwidth": (100, 1000), "is_mobile": True},
    {"type": "wifi", "band": "2.4g", "latency": (20, 40), "bandwidth": (10, 50), "is_mobile": False},
    {"type": "wifi", "band": "5g", "latency": (10, 20), "bandwidth": (50, 1000), "is_mobile": False},
    {"type": "satellite", "band": "starlink", "latency": (30, 100), "bandwidth": (50, 250), "is_mobile": True}
]

def generate_network_list(num_networks):
    selected_networks = random.choices(networks, k=num_networks)
    
    result = []
    for network in selected_networks:
        latency = random.randint(network["latency"][0], network["latency"][1])
        bandwidth = random.randint(network["bandwidth"][0], network["bandwidth"][1])
        result.append({
            "type": network["type"],
            "band": network["band"],
            "latency": latency,
            "bandwidth": bandwidth,
            "is_mobile": network["is_mobile"]
        })
    
    return result

network_list = generate_network_list(num_networks)

with open("network-params.txt", "w") as file:
    for network in network_list:
        line = f"{network['type']}|{network['band']}|{network['latency']}|{network['bandwidth']}|{network['is_mobile']}\n"
        file.write(line)

Проверяем:
photo_2025-05-11_06-41-06.jpg

Этот скрипт довольно простой, в нем я задал список сетей с их типами, полосами, диапазонами задержки и пропускной способности, а также флагом мобильности. Например, для 4G задержка от 50 до 100 мс, а пропускная способность от 10 до 50 Мбит/с. Скрипт генерит num_networks(12) случайных сетей, выбирая их из списка. Для каждой сети он рандомно подбирает задержку и пропускную способность в заданных пределах. Потом всё склеивается в строки формата тип|полоса|задержка|пропускная_способность|мобильность и пишется в network-params.txt.


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

В этой статье мы разобрались, как Google Chrome шпионит за нами, собирая кучу данных. Он тянет всё: от железа — типа процессора, видеокарты, шрифтов — до того, как ты мышкой водишь или сколько секунд на сайте сидишь. Через всякие Canvas, WebGL и заголовки. Мы с вами покопались в коде Chromium, я показал, как он вытаскивает инфу о ядрах или драйверах.

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

Дальше я написал два инструмента для подмены отпечатка. Первый — на Puppeteer, и он модульный. Этот скрипт запускает Chrome через Node.js и разбит на куски, где каждый модуль отвечает за свою часть: один подменяет User-Agent и Client Hints, другой — локали и таймзоны, третий — геолокацию, тд. Можно выбрать что включить, через конфиг. Я показал, как он устроен, конфиг, примеры модулей, сборку и запуск. Второй инструмент — Bash-скрипт. Он собирает флаги для запуска Chrome, чтобы подделать его отпечаток.

Ещё мы с вами разобрались, где брать нормальные данные, чтобы подмена выглядела правдоподобно. А именно, как выгружать таймзоны, локали и коды стран из API, как собирать User-Agent’ы или генерить Client Hints. Для плагинов, видеокарт и веб-камер с микрофонами я даже написал скрипты, которые парсят реальные названия. В итоге у нас получился целый набор данных, чтобы Chrome видел только то, что ты ему покажешь.

Надеюсь, вам было интересно и полезно! Ведь это еще не конец. В следующей части мы разберём, как подделывать отпечатки в Tor и Firefox, потому что эти браузеры работают иначе, и там свои заморочки.
 

Вложения

  • spoofer_fingerprint.zip
    54.8 КБ · Просмотры: 24
  • xss_soofer_bash.zip
    16.5 КБ · Просмотры: 19
  • xss_assets.zip
    47.5 КБ · Просмотры: 19
сегодня мы своими руками будем делать антидетект браузер :D
спасибо за статью!
 


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