LIMB
Вход Регистрация

тема

Автоматизируем поиск багов в JS файлах

nipaah · · 0 ответов · 867 просмотров

Вместо введения​

В данной статье расскажу о своем опыте участия в баг-баунти и анализе клиентского кода веб-сервисов, а так же об автоматизации работы с ним. Понимание того, как работает сервис изнутри, его логика, является одним из важнейших аспектов поиска уязвимостей в SPA (Single Page Application - React/Vue/etc.), так как в коде зачастую спрятано самое интересное: скрытые или забытые эндпоинты, поля и значения для query и body, дополнительная логика, недоступная из UI, наконец, забытые секреты, и многое другое.

Представим, что у нас есть перспективный таргет на баг-баунти, по которому мы уже получили не одну выплату. Мы изучили его работу вдоль и поперёк: каждый endpoint, весь клиентский код, и сдали все найденные баги. Что же делать дальше? 

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

Здесь будет разобраны методы и техники построения собственного баг-баунти тулкита для сбора информации из JS-файлов и мониторинга их обновлений на конкретном таргете. Так же разберем некоторые кейсы, какие именно уязвимости может помочь найти наш тулкит. Подчеркну, что статья рассчитана не на новичков, а на тех, кто знаком хотя бы с поиском веб-уязвимостей или участвовал в баг баунти и хотел бы освоить автоматизацию на языке JavaScript/TypeScript. Так же вы должны уметь пользоваться git и знать основы Bash.

В качестве таргета возьмем  https://hackerone.com/udemy/ - хантить там я порекомендовать не могу, опыт сугубо отрицательный. Несмотря на неплохую статистику программы, я не получил никакого ответа на валидный баг (не дубликат) в течение месяцев после первого ответа от h1 - полный игнор. И как выяснилось потом, не я один столкнулся с таким отношением. Видимо, компания реагирует только на криты, а на остальное им просто плевать т.к. баги висят не исправленными до сих пор. К сожалению, в баг-баунти это не редкость. Так что не стоит доверять статистике и лучше спросить совета у товарищей, какую программу выбрать. Однако, именно на этом таргете я впервые применил описанные здесь инструменты, которые пригодились мне уже на других баг-баунти программах, так что разберем его. 

Пришла ко мне эта идея после того, как сдав несколько medium-severity багов на Udemy, я продолжил изучать работу их сервиса и наткнулся на код еще не активных в проде фич: разработчики регулярно обновляли код и заливали в прод, но отключали незавершенный функционал с помощью FEATURE флагов. Это просто список констант с булевым значением, получаемых с сервера, которые отвечают за то, какой функционал будет работать для данного пользователя, а какой нет. В моем случае это было что-то вроде PHONE_AUTH: false - возможность логина по номеру телефона, вместо email. Я решил, что будет неплохо первым узнать, когда эта фича заработает, а так же быть в курсе всех остальных обновлений.
Ранее, для анализа JS файлов я пользовался плагином Logger++ для Burp, через который отфильтровывал запросы с JS файлами и экспортировал их как HAR файл, с которым работал набором из своих скриптов. Но решил автоматизировать весь цикл и получил то, чему будет посвящена эта и последующие статьи.

Так же упомяну полезные инструменты для работы с JS файлами, но которые работают только с текущим трафиком, который проксируется через Burp, а значит они не годятся для полного цикла автоматизации, но все равно стоят внимания:
- https://jxscout.app/ - универсальный комбайн, которые вытаскивает из JS файлов все что только можно и нельзя, и складываем вам прямо в VSCode. Требователен к ресурсам, цена всего $10, но оно того стоит, есть пробная версия на 14 дней, а еще проверку лицензии несложно обойти ;^] Настоятельно рекомендую для первоначальной разведки.​
- https://github.com/PortSwigger/js-miner - ищет секреты, dependency confusion, endpoints, но работает не шибко хорошо​
- https://github.com/PortSwigger/trufflehog-integration - интеграция искалки секретов в Burp​
- https://github.com/PortSwigger/js-link-finder - вытаскивает ссылки​
- https://github.com/portswigger/get-all-parameters - аналог GAU внутри Burp, неплохо работает для составления wordlists​

Проблематика​

Казалось бы, задача просто решается с помощью всем известных open-source утилит: через hakrawler или katana запускаем краулер, собираем ссылки на JS-файлы какого-нибудь GoLinkFinder, сохраняем их, с помощью jsluice извлекаем URLs и пути для поиска API-вызовов и прочих интересностей. Складываем в папку и запускаем команду diff, верно?
Нет. Это лишь общий принцип, которому мы будем следовать на практике. На деле же в современных SPA-приложениях мы столкнёмся с множеством трудностей:

  1. 1. WAF вроде Cloudflare — не позволит просто запустить краулер и получить JS-файлы. Скорее всего, эту проблему можно решить и с помощью katana, т.к. она поддерживает краулинг через браузер, но зачастую проще и быстрее написать специфичное решение под конкретный таргет, чем пытаться обходить недостаточную гибкость универсальных инструментов.
  2. 2. Сборщики вроде Webpack — упаковывают код так, что он подгружается только при необходимости (так называемые chunks)
  3. 3. Изменчивые имена файлов — от сборки к сборке имена чанков могут различаться, появляются новые файлы, а нам необходимо сравнивать версии из предыдущего и текущего запуска
  4. 4. Минификация кода — если повезёт, будут включены source maps (разработчики часто забывают их отключить на Next.js проектах — хантерам на заметку), а если нет — придётся иметь дело с малопонятным минифицированным кодом
  5. 5. Автоматизация — с помощью каких инструментов выстраивать пайплайн, организовать хранение файлов и создание удобочитаемого diff между запусками

    Теперь разберём общую архитектуру нашего тулкита, используемые технологии и то, как решаем вышеизложенные проблемы.

Архитектура​

1. Crawler​

Идея проста: нам необходимо посетить сервис так, как будто мы делаем это с помощью обычного браузера. Здесь нам на помощь придут Playwright и Camoufox.

- Camoufox (https://camoufox.com/) — это антидетект-браузер, который позволяет с лёгкостью обходить капчу Cloudflare (вернее, просто не триггерить её). Он легко интегрируется в Playwright. Один из механизмов обхода защиты Cloudflare это подделка TLS-фингерпринта, имитирующая настройки обычныго браузера. Конечно, можно обойтись и без Camoufox, но зачем писать тонну кода вручную на каком-нибудь Python, когда уже есть готовое решение.
- Playwright — это фреймворк для E2E-тестирования. С его помощью можем закодить основные сценарии:
- Прокликивание ссылок на сайте без логина
- Логин и выполнение критического флоу (например, процесс покупки)
- Под учётными данными высокопривилегированного пользователя — прокликивание всех основных страниц внутри админки

Каждый из сценариев пытается максимально покрыть функционал сайта, чтобы захватить как можно большее количество JS-файлов. Весь этот трафик сохраняется в виде HAR-файлов (это JSON, куда записывается трафик в формате запрос/ответ).

 Дополнительные возможности​

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

2. HAR Processor​

Здесь всё просто: проходим по массиву запросов и ответов, фильтруем по домену и путям (например, только домен cdn.ourtarget.com), сохраняем содержимое файлов на диск, а также составляем списки из URL'ов всех сохранённых JS-файлов — они нам ещё пригодятся.
Самое ценное, что мы можем получить из JS-файлов — это исходный код без минификации и с сохранением структуры проекта. Лучшая утилита из всех, что я перепробовал — это https://github.com/unblu/js-source-extractor. Она умеет:
- Проверять ссылки на JS файлы, доабвляя постфикс .map , скачивать и распаковывать source maps. Так же можно работать с файлами локально, используя file:// проткол
- Искать source maps внутри уже скачанных JS-файлов, если разработчиками был выбран режим inline source maps
Извлечем JS файлы/ссылки из HAR и прогоним через эту утилиту.

Для приведения минифицированного кода в читаемый вид воспользуемся https://github.com/beautifier/js-beautify

3. JS Extractors​

Извлекаем полезные данные из кода. Приведу самое популярное:
- Домены​
- Ссылки​
- Пути и части путей для поиска вызовов API​
- GraphQL queries/mutations​
- JS-зависимости для проверки на уязвимости dependency confusion​
- Составление wordlist для последующего фаззинга​
- Поиск забытых секретов​

Большую часть проще написать самому на регулярках — под каждый таргет почти всегда специфичные требования. Но есть пара хороших open-source утилит, которые помогут всегда:
- https://github.com/BishopFox/jsluice — извлекает пути и ссылки​
- https://github.com/trufflesecurity/trufflehog — комбайн по поиску секретов​

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

4. Сравнение результатов​

Итак, теперь у нас есть JS-файлы и извлечённая полезная информация. Как же нам лучше всего сравнить результаты между текущей версией и предыдущей? Через Git!

Поэтому смело добавляем наши извлечённые source maps (только не забудьте выкинуть node_modules) и результаты работы JS-экстракторов в репозиторий и делаем коммит. И затем после очередного запуска просто делаем git diff

Работа с минифицированными файлами​

А вот теперь самое сложное и интересное — минифицированные JS-файлы Рассмотрим самый простой случай, когда имена загруженных JS-файлов отличаются только частью имени, например dashboard.abcd12.js и dashboard.feac21.js, и нам не нужно гадать, какой файл из предыдущей версии с каким соотносить из новой версии для запуска diff. Если же все файлы имеют в названии только хэш, то мы можем найти похожие файлы с помощью утилиты https://ssdeep-project.github.io/ssdeep/index.html но это выходит за рамки данной статьи.

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

Причина: каждый билд через webpack минифицирует код, меняя названия всех переменных и функций на случайные максимально короткие варианты вроде a, b, c, aa, bc, ba и т.д.

Решение: нормализация идентификаторов​

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

Поэтому мы выбираем какое-нибудь нейтральное новое имя, например __VAR__, обходим весь JS-файл и переименовываем все короткие идентификаторы. На выходе diff теперь короткий и ясный, отображающий только реальные изменения. Так же вместо обычного diff есть более красивый и удобный для чтения difft: sudo apt install difft

Технические детали​

Для переименования переменных вариант с регулярками, конечно же, не подойдёт. Поэтому мы воспользуемся обходом AST (Abstract Syntax Tree), а именно библиотекой @babel/traverse под Node.JS

Однако при работе с большими JS-файлами и их большим количеством она показала неэффективность. Поэтому я переписал решение на Rust с помощью swc_core — просто несоизмеримо быстрее, меньше потребляет памяти, и, как оказалось, работает гораздо точнее. При этом, Rust я не знаю совершенно, тут помог Клавдий Опус, выдав рабочее решение буквально с нескольких промптов.

5. Автоматизация​

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

Модуль 1. Crawler ​

Разбор кода начнем с модуля, отвечающего за сбор трафика с нужными нам JS-файлами,
В качестве языка программирования используем Bun — современный и удобный движок для Node.js, поддерживающий TypeScript из коробки, а так же имеющий свой пакетный менеджер. При этом, имеющий обратную совместимость с Node.js

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

Установка camoufox​

Bash:
❯ pip install -U camoufox
❯ python3 -m camoufox fetch
После команды fetch бинарник будет скачен и сохранен в директории  ~/.cache/camoufox/camoufox-bin
При проблемах с запуском pip или python3, попробуй pip3 или просто python - в зависимости от дистрибутива.

Создание проекта​

Bash:
❯ git init .
❯ bun init      

✓ Select a project template: Blank

 + CLAUDE.md
 + index.ts
 + tsconfig.json (for editor autocomplete)
 + README.md

To get started, run:

    bun run index.ts

bun install v1.2.22 (6bafe260)

+ @types/[email protected]
+ [email protected]

7 packages installed [3.50s]

Установка зависимостей​

Bash:
❯ bun add playwright yargs @babel/parser @babel/traverse @babel/generator
bun add v1.2.22 (6bafe260)

installed [email protected] with binaries:
 - playwright
installed [email protected]
installed @babel/[email protected] with binaries:
 - parser
installed @babel/[email protected]
installed @babel/[email protected]

33 packages installed [4.49s]

Описание зависимостей:​

- playwright — фреймворк для E2E-тестирования, который будем использовать для навигации по сайту и перехвата сетевого трафика
- yargs — библиотека для парсинга аргументов командной строки
- babel (@babel/parser, @babel/traverse, @babel/generator) — инструменты для разбора и анализа JS-файлов, так мы будет искать данные, с помощью которых сможем сформировать ссылки для скачивания js файлов

Создадим базовую структуру модуля:​

Bash:
❯ mkdir -p crawler/scenarios/manifest
❯ touch crawler/scenarios/{anon.ts,manifest.ts,manifest/manifest-parser.ts} crawler/{cli.ts,crawler.ts,scenario.d.ts}
❯ tree crawler/                      
crawler/
├── cli.ts
├── crawler.ts
├── scenario.d.ts
└── scenarios
    ├── anon.ts
    ├── manifest
    │   └── manifest-parser.ts
    └── manifest.ts
3 directories, 6 files

Описание файлов:​

- cli.ts — точка входа в программу. Здесь парсим аргументы командной строки и передаем управление в crawler.ts
- crawler.ts — основной код, запускающий сценарии сбора JS-файлов
- scenario.d.ts — типы TypeScript для описания структуры сценариев
- anon.ts — сценарий для сбора JS-файлов без аутентификации. Описывает, какие страницы необходимо посетить
- manifest.ts — сценарий поиска webpack-манифеста. Посещает главную страницу сайта, ищет файл с манифестом webpack, формирует список URL всех модулей и открывает их в браузере для записи трафика
- manifest-parser.ts — вспомогательный модуль, содержащий парсер JS-кода, который извлекает названия всех модулей из манифеста

Файл cli.ts​

Создаем новые директории:​

Bash:
❯ mkdir -p revisions/{previous,current}

В директории revisions мы будем хранить данные (в контексте краулера — HAR-файлы) от нашей утилиты для различных версий: текущий запуск и предыдущий.

Также добавим их в .gitignore:​

Code:
revisions/*
node_modules

Спойлер: Полный код файла crawler/cli.ts
JavaScript:
#!/usr/bin/env bun

import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { CrawlRevision, main } from './crawler';
import { existsSync, readdirSync } from 'fs';

const browserPath = `${process.env.HOME}/.cache/camoufox/camoufox-bin`;

const builder = (yargs) => {
    return yargs
        .option('scenarios', {
            alias: 's',
            type: 'array',
            describe: 'Specify scenario to process (path to file or scenario name in scenarios directory with/without .ts extension)',
            string: true,
        })
        .option('visible', {
            alias: 'v',
            type: 'boolean',
            describe: 'Run browser in non-headless mode',
            default: false
        })
        .option('browserPath', {
            alias: 'b',
            type: 'string',
            describe: 'Path to browser executable',
            default: browserPath
        });
};
const handler = revision => (args) => {
    main({ revision, ...args });
}
yargs(hideBin(process.argv))
    .strict()
    .scriptName('crawler')
    .usage('Usage: $0 <revision> [options]')
    .command({
        command: 'current [options]',
        aliases: ['c'],
        describe: 'Run crawler and save HARs as current revision',
        builder,
        handler: handler(CrawlRevision.Current)
    })
    .command({
        command: 'previous [options]',
        aliases: ['p'],
        describe: 'Run crawler and save HARs as previous revision',
        builder: builder,
        handler: handler(CrawlRevision.Previous)
    })
    .demandCommand(1, 'You must specify a command (current/c or previous/p)')
    .help('h')
    .alias('h', 'help')
    .parseSync();

Код достаточно говорит сам за себя. 

Теперь примеры использования:​

Bash:
# Сохранение HAR-файлов в текущую ревизию
bun run crawler/cli.ts current

# Сохранение в предыдущую ревизию
bun run crawler/cli.ts previous

# Ключ -v отключает headless-режим, т.е. окно браузера Camoufox будет отображаться — полезно для дебага
bun run crawler/cli.ts current -v

# По умолчанию выполняются все сценарии, но можно указать конкретный, например:
bun run crawler/cli.ts current -s anon

Сценарии​

Определим интерфейс для сценария:​

JavaScript:
// файл crawler/scenario.d.ts
import { Page } from "playwright"

export interface Scenario {
  customScenario: (page: Page, options: Record<string, string>) => Promise<void>
  urls: string[]
  beforeEachUrl?: (page: Page, url: string) => Promise<void>
  proxy?: string
  headers: Record<string, string>
  waitForTimeout?: number
}
Основные поля здесь — urls и customScenario.

Первой выполняется функция customScenario: она получает объект page от Playwright, который является интерфейсом для взаимодействия с браузером — переход по ссылкам, нажатие кнопок, заполнение форм и так далее. Конкретный пример будет дан в файле anon.ts.

urls — просто список ссылок, которые нам нужно открыть в браузере. Во многих случаях дополнительная логика не требуется, и это просто автоматизация того, что можно сделать через customScenario.
beforeEachUrl — функция, которая выполняется перед посещением каждой ссылки из urls. Используется для проставления заголовка E-Tag, чтобы избежать кэширования JS-файлов.
headers — здесь размещаем наши авторизационные куки.
waitForTimeout — пауза между посещением ссылок из urls.

Далее разберем базовый пример с посещением двух ссылок без авторизации. В реальности для полного покрытия этим ограничиваться нельзя, но дописать дополнительные сценарии труда не составит, посидев достаточно времени за Burp'ом и документацией Playwright.

Сценарий anon.ts​

Как следует из названия, здесь мы просто посещаем страницы без авторизации. Для наглядности оставим только две ссылки — главная страница и форма логина:
Не удалось прогрузить медиа




Не удалось прогрузить медиа



JavaScript:
const urls = [
  'https://www.udemy.com',
  'https://www.udemy.com/join/passwordless-auth/?locale=en_US&next=https%3A%2F%2Fwww.udemy.com%2F&response_type=html&action=login&mode',
];

Содержимое customScenario по сути дублирует функционал urls и оставлено только для демонстрации примера того, как можно использовать Playwright, т.к. клик по кнопке "Log in" перекинет нас на страницу /join/passwordless-auth
JavaScript:
async function customScenario(page: Page, options: Record<string, string>) {
  /*
  console.log('* Visiting https://www.udemy.com');
  let url = 'https://www.udemy.com';
  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
  await page.waitForTimeout(9424);
  await page.getByText('Log in').click();
  await page.waitForTimeout(9424);
  */
}

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

Спойлер: Полный код файла crawler/scenarios/anon.ts
JavaScript:
import { Page } from 'playwright';
import { Scenario } from '../scenario';

const proxy = ''; // socks5://localhost:2080
const COOKIES = '';

const urls = [
  'https://www.udemy.com',
  'https://www.udemy.com/join/passwordless-auth/?locale=en_US&next=https%3A%2F%2Fwww.udemy.com%2F&response_type=html&action=login&mode',
];

async function customScenario(page: Page, options: Record<string, string>) {
  /*
  console.log('* Visiting https://www.udemy.com');
  let url = 'https://www.udemy.com';
  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
  await page.waitForTimeout(9424);
  await page.getByText('Log in').click();
  await page.waitForTimeout(9424);
  */
}

const headers = {'Cookie':COOKIES};

export default {
  urls,
  proxy,
  customScenario,
  headers,
}  as Scenario

Сценарий manifest.ts​

Опишем общий алгоритм работы. Сперва посещаем страницу логина, с которой подгружается webpack-манифест - файл с перечислением всех динамически подгружаемых JS модулей. Ставим коллбэк на ответы с сервера и ищем среди них манифест по подстроке entry-main-manifest в пути.

Далее получаем тело ответа, парсим код манифеста и извлекаем все ID модулей. С помощью функции fillUrls формируем список ссылок на все JS-файлы и складываем их в поле urls нашего сценария. Следом после customScenario, согласно реализации в crawler, будут посещены все ссылки из urls, которые мы добавили. С помощью beforeEachUrl отключаем кэширование, т.к. некоторые JS-файлы уже были загружены при первой загрузки страницы.
Спойлер: Полный код файла файла crawler/scenarios/manifest.ts
JavaScript:
import { Page } from 'playwright';
import { Scenario } from '../scenario';
import { manifestParser } from './manifest/manifest-parser';

const COOKIE = '';

//const proxy = 'socks5://127.0.0.1:2080';
const proxy = '';
const headers = {}; // Cookie': COOKIE};
const urls: string[] = [];//.slice(0,1)

// базовый путь для модулей, извлеченных из манифеста
const base = 'https://www.udemy.com/staticx/udemy/js/webpack/';

const fillUrls = (obj) => {
  Object.entries(obj).forEach(([key, value]) => {
    if (!key.includes('-server-side')) { // дополнительная фильтрация неинтересущих нас файлов
      const url = `${base}${key}.${value}.js`;
      urls.push(url);
    }
  })
}

async function customScenario(page: Page, options: Record<string, string>) {
  console.log('customScenario');
  page.on('response', async response => {
      const url = response.url();
      if (!url.includes('entry-main-manifest')) {
        return;
      }

      console.log('Downloaded manifest file', url);

      try {
        const bodyBuf = await response.body();
        const body = new TextDecoder().decode(bodyBuf);
        const obj = manifestParser(body);
        fillUrls(obj);
        console.log(`Found ${urls.length} URLs to download`);
      } catch (err) {
        console.error('Failed to read response body:', err);
      }
  });
  console.log('Visiting main page to download manifest file');
  await page.goto('https://www.udemy.com/join/passwordless-auth/', { waitUntil: 'networkidle', timeout: 60000 });
  await page.waitForTimeout(1112);
}

// отключаем кэш
const beforeEachUrl = async (page: Page, url: string) => {
  function generateEtag() {
    return Array.from({length: 32}, () => Math.floor(Math.random() * 16).toString(16)).join('');
  }

  await page.setExtraHTTPHeaders({
    'If-None-Match': `W/"${generateEtag()}"`
  });
}

export default {
  urls,
  proxy,
  customScenario,
  headers,
  waitForTimeout: 1000,
  beforeEachUrl
} as Scenario

Парсер манифеста​

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

Скачиваем файл и форматируем его:
Bash:
❯ wget https://www.udemy.com/s4taticx/udemy/js/webpack/entry-main-manifest.e080d99058059da9d5f0.js
❯ f="./entry-main-manifest.e080d99058059da9d5f0.js"; bunx js-beautify $f > $f.tmp && mv $f{.tmp,}

Открываем в редакторе кода и листаем, пока не наткнемся на такой код:
JavaScript:
i.u = e => "" + e + "." + {
    "common-app-css": "6aba0217b0532ace1dce",
    "activity-notifications-server-side": "03d1a23e2dec29b5cc3e",
    "activity-notifications-udlite-app": "36183e97e8a8a3d357be",
    "assessment-not-available-udlite-app": "3cf544de4da61135d048"
    <SNIP>
      "node_modules_storybook_blocks_dist_Color-6VNJS4EI_mjs": "81308e96f40e9436df67",
    "node_modules_storybook_components_dist_syntaxhighlighter-NMPM6SWI_mjs": "c44add36fd696ac4c558",
    "vendors-node_modules_storybook_components_dist_formatter-SWP5E3XI_mjs": "798be7d81f01b9f4410b",
    "node_modules_storybook_components_dist_WithTooltip-4HIR6TLV_mjs": "d0cb52092f343ab43026"
  } [e] + ".js"
})();

Здесь определена функция, которая отображает название модуля в имя файла с помощью хешмапа, например:
assessment-not-available-udlite-app
в
assessment-not-available-udlite-app.3cf544de4da61135d048.js

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

Для обхода всех нод AST воспользуемся функцией traverse из библиотеки Babel:
JavaScript:
traverse(ast, {
  ObjectExpression(path) {
    const properties = path.node.properties;
    for (const prop of properties) {
          if (prop.type === "ObjectProperty") {
Предварительно конвертируем все ключи, которые встретятся, в строку:
JavaScript:
let keyValue = "";

if (prop.key.type === "Identifier") {
  keyValue = prop.key.name;
} else if (prop.key.type === "StringLiteral") {
  keyValue = prop.key.value;
} else if (prop.key.type === "NumericLiteral") {
  keyValue = prop.key.value.toString();
}
Для поиска нужного объекта делаем проверку по наличию ключа с вхождением подстроки _mjs
JavaScript:
if (keyValue.includes(KEY_MATCH)) {
Как нашли — генерируем из него валидный JavaScript-код и запускаем eval, так как в ключах или значениях могут присутствовать не только константы, но и выражения, например:
JavaScript:
[some_var]: "string",
"asdf": "string" + "2",
eval выполнит их и вернет готовый JS object:
JavaScript:
const objectCode = generator(path.node, {
    compact: false,
    minified: false
}).code;
const jsObject = eval(`(${objectCode})`);
Далее уберём из объекта неинтересующие нас JS‑файлы — например, библиотеки и содержимое node_modules:
JavaScript:
for (const k of Object.keys(jsObject)) {
    if (ignorePatterns.some(p => k.match(new RegExp(p)))) {
      delete jsObject[k]
    }
}
Хешмап со всеми данными для формирования списка URL с JS-файлами готов. Передаём его обратно в код сценария.
Спойлер: Полный код парсера:
JavaScript:
// file crawler/scenarios/manifest/manifest-parser.ts
import parser from "@babel/parser";
import traverse from "@babel/traverse";
import generator from "@babel/generator";

// регулярки для фильтрации ненужных нам файлов
const DEFAULT_PATTERNS = [
  '^vendors-node_modules_',
  '^react-syntax-highlighter_',
  '^brace',
  '^vendor-highcharts',
]

const KEY_MATCH = '_mjs'

export const manifestParser = (code: string, ignorePatterns = DEFAULT_PATTERNS) => {
  try {
    const ast = parser.parse(code, {
      sourceType: "unambiguous",
      allowReturnOutsideFunction: true,
      allowAwaitOutsideFunction: true,
      errorRecovery: true,
      // список плагинов для расширения синтаксиса, поддерживаемого babel
      // много из списка ниже не пригодится в данном случае можно убрать для увеличения производительности, но сейчас это некритично
      plugins: [
        "jsx",
        "classProperties",
        "dynamicImport",
        "optionalChaining",
        "nullishCoalescingOperator",
        "objectRestSpread"
      ]
    });

    let result = {}
    traverse(ast, {
      ObjectExpression(path) {
        const properties = path.node.properties;
        for (const prop of properties) {
          if (prop.type === "ObjectProperty") {
            let keyValue = "";

            if (prop.key.type === "Identifier") {
              keyValue = prop.key.name;
            } else if (prop.key.type === "StringLiteral") {
              keyValue = prop.key.value;
            } else if (prop.key.type === "NumericLiteral") {
              keyValue = prop.key.value.toString();
            }

            if (keyValue.includes(KEY_MATCH)) {
              const objectCode = generator(path.node, {
                compact: false,
                minified: false
              }).code;

              const jsObject = eval(`(${objectCode})`);
              for (const k of Object.keys(jsObject)) {
                if (ignorePatterns.some(p => k.match(new RegExp(p)))) {
                  delete jsObject[k]
                }
              }
              result = jsObject;
              return;
            }
          }
        }
      }
    });
    return result;
  } catch (error) {
    console.error(`Error processing manifest`, error.message);
  }
}


Файл crawler.ts​


Функция main вызывается из cli.ts, динамически подгружает JS модули из папки scenarios и выполняет их последовательно в цикле, передавая в функцию crawl.  
Если название сценария не было указано как аргумент через ключ -s, по умолчанию подгружаются все сценарии из директории scenarios.  
Функция crawl инициализирует сессию PlayWright, прописывает необходимые куки и сначала выполняет customScenario, а затем обрабатывает каждый URL из urls с помощью функции visitUrls.

Спойлер: Полный код
JavaScript:
// file crawler/crawler.ts
import { readdir, lstat } from "node:fs/promises";
import { firefox, Page } from 'playwright';
import process from 'node:process';

// логирование времени выполнения
async function runWithTiming(asyncFn) {
    const start = Date.now();
    try {
        const result = await asyncFn();
        const ms = Date.now() - start;

        const minutes = Math.floor(ms / 60000);
        const seconds = Math.floor((ms % 60000) / 1000);
        const millis = ms % 1000;

        let output = '';
        if (minutes) output += `${minutes}m `;
        if (seconds || minutes) output += `${seconds}s`;
        if (!minutes && !seconds) output = `${millis}ms`;

        console.log(`   Execution time: ${output.trim()}`);
        return result;
    } catch (err) {
        //console.error("   Error during execution:", err);
        throw err;
    }
}

// при ошибке выполнения сценария даем 5 попыток на повтор и добавляем паузу в минуту между повторами
const retrying = async (fn, maxRetries = 5) => {
    const retryDelay = 60 *  1000
    let tries = 0;
    while (tries < maxRetries) {
        try {
            await fn();
            if (tries) {
              console.log(`   Success on ${tries+1} try`);
            }
            break;
        } catch (err) {
            console.error(err);
            console.error(`   Error fetching URL. Sleep for a while and retrying ${tries+1}/${maxRetries}...`);
            await new Promise(resolve => setTimeout(resolve, retryDelay));
            tries++;
        }
    }
}


// автоматизация рутины посещения ссылок
async function visitUrls(page: Page, urls: string[], waitForTimeout: number, beforeEachUrl: (page: Page, url: string) => Promise<void>) {
    if (!urls.length) {
        return;
    }
    console.log('- Visiting URLs...');
    let i = 1;
    for (const url of urls) {
        const n = `${i}`.padStart(`${urls.length}`.length, ' ');
        console.log(`* ${n}/${urls.length} - ${url}`)
        await runWithTiming(async () => {
            await retrying(async () => {
                if (beforeEachUrl) {
                    await beforeEachUrl(page, url);
                }
                const response = await page.goto(url, { waitUntil: 'load', timeout: 60000 });               
                if (!response.ok()) {
                  console.error(`   [${response.status()}] ${url}`);
                  if (response.status() === 500) {
                    //throw new Error(response.status());
                  }
                }
            })
        });
        await page.waitForTimeout(waitForTimeout + Math.random() * 1000);
        i++;
    }
}

interface CrawlInput {
    outputPath: string
    headers: Record<string, string>
    proxy?: string
    urls: string[]
    customScenario?: (page: Page) => Promise<void>
    browserPath: string
    visible?: boolean
    waitForTimeout?: number
    beforeEachUrl?: (page: Page, url: string) => Promise<void>
}
async function crawl({ urls, outputPath, headers, proxy, customScenario, browserPath, visible, beforeEachUrl, waitForTimeout = 9000 }: CrawlInput) {
    const browser = await firefox.launch({
        headless: !visible,
        executablePath: browserPath,
        args: ['--no-sandbox'],
        proxy: proxy ? {
            server: proxy
        } : undefined
    });

    const context = await browser.newContext({
        recordHar: {
            path: outputPath
        },
        extraHTTPHeaders: {
            ...headers
        }
    });
    try {
        let page = await context.newPage();
        if (customScenario) {
            console.log('- Running custom scenario...');
            await customScenario(page);
        }
        page = await context.newPage();
        await visitUrls(page, urls, waitForTimeout, beforeEachUrl);
        await context.close();
    } catch (err) {
        await browser.close();
        throw err;
    }
}

export enum CrawlRevision {
    Current = 'current',
    Previous = 'previous'
}
export interface MainInput {
    revision: CrawlRevision
    scenarios: string[]
    browserPath: string
    visible: boolean
    maxRetries?: number
    retryDelay?: number
}
export async function main({ revision, scenarios, browserPath, visible, maxRetries = 5, retryDelay = 10_000 }: MainInput) {
    const cwd = process.cwd();

    if (!scenarios || !scenarios.length) {
        const content = await readdir(`${cwd}/crawler/scenarios`);
        scenarios = [];
        for (const item of content) {
             // пропускаем директории
             const stat = await lstat(`${cwd}/crawler/scenarios/${item}`);
             if (stat.isFile()) {
                 scenarios.push(item);
             }
        }
        // фиксируем порядок выполнения
        scenarios.sort();
    } else {
        scenarios = scenarios.map(f => f.endsWith('.ts') ? f : `${f}.ts`);
    }
    console.log('// Scenarios ', scenarios);
    for (const file of scenarios) {
        const path = file.includes('/') ? file : `${cwd}/crawler/scenarios/${file}`;
        console.log(`// Importing ${path}...`);
        const scenarioModules = await import(path);
        const scenario = scenarioModules.default;
        const name = file.replace('.ts', '');
        const input: CrawlInput = {
            outputPath: `${cwd}/revisions/${revision}/${name}.har`,
            headers: scenario.headers,
            urls: scenario.urls,
            customScenario: scenario.customScenario,
            browserPath,
            visible,
            waitForTimeout: scenario.waitForTimeout
        }
        if (scenario.proxy) {
            input.proxy = scenario.proxy;
        }
        if (scenario.beforeEachUrl) {
            input.beforeEachUrl = scenario.beforeEachUrl;
        }
        console.log(`// Start execution of ${name} scenario...`);
        let tries = 0;
        while (tries < maxRetries) {
            try {
                await crawl(input);
                break;
            } catch (err) {
                console.error(err);
                console.error(`   Error during execution of ${name} scenario, sleep for a while and retrying ${tries+1}/${maxRetries}...`);
                await new Promise(resolve => setTimeout(resolve, retryDelay));
                tries++;
            }
        }
    }
    console.log('// All scenarios executed.');
    process.exit(0);
}

Запуск​

Bash:
❯ git add .
❯ git commit -m "Crawler module"

❯ bun run crawler/cli.ts current
// Scenarios  [ "anon.ts", "manifest.ts" ]
// Importing /home/nipaah/df-js-monitor/crawler/scenarios/anon.ts...
// Start execution of anon scenario...
- Running custom scenario...
- Visiting URLs...
* 1/2 - https://www.udemy.com
   Execution time: 9s
* 2/2 - https://www.udemy.com/join/passwordless-auth/?locale=en_US&next=https%3A%2F%2Fwww.udemy.com%2F&response_type=html&action=login&mode
   Execution time: 4s
// Importing /home/nipaah/df-js-monitor/crawler/scenarios/manifest.ts...
// Start execution of manifest scenario...
- Running custom scenario...
customScenario
Visiting main page to download manifest file
Downloaded manifest file https://www.udemy.com/staticx/udemy/js/webpack/entry-main-manifest.e080d99058059da9d5f0.js
Found 230 URLs to download
- Visiting URLs...
*   1/230 - https://www.udemy.com/staticx/udemy/js/webpack/common-app-css.6aba0217b0532ace1dce.js
   [404] https://www.udemy.com/staticx/udemy/js/webpack/common-app-css.6aba0217b0532ace1dce.js
   Execution time: 316ms
*   2/230 - https://www.udemy.com/staticx/udemy/js/webpack/activity-notifications-udlite-app.36183e97e8a8a3d357be.js
   Execution time: 263ms
<SNIP>
* 230/230 - https://www.udemy.com/staticx/udemy/js/webpack/node_modules_storybook_components_dist_WithTooltip-4HIR6TLV_mjs.d0cb52092f343ab43026.js
   Execution time: 459ms
// All scenarios executed.
Теперь выведем список urls, перехваченных в сценарии anon:
Bash:
❯ cat ./revisions/current/anon.har | jq -r '.log.entries.[].request.url' | grep -P 'udemy(cdn)?\.com' | grep '\.js' | head
Не удалось прогрузить медиа

и сценарий manifest:​

Bash:
❯ cat ./revisions/current/manifest.har | jq -r '.log.entries.[].request.url' | grep -P 'udemy(cdn)?\.com' | grep '\.js' | head
Не удалось прогрузить медиа

И видео-демонстрация, покажу запуск с отключенным headless режимом, чтобы можно было увидеть, что происходит в это время внутри camoufox.
Не удалось прогрузить медиа

Заключение​

Таким образом, с помощью анти-детект браузера, фреймворка для тестирования Playwright и парсинга манифест-файла от Webpack мы (потенциально) собрали максимально возможное количество JS-файлов с нашего таргета за Cloudflare и сохранили в формате HAR. И хотя может показаться, что можно обойтись только манифест-файлом, полагаться только на него не стоит — в зависимости от настроек сборки некоторые файлы могут отсутствовать, а на многих таргетах такого файла может не быть вовсе. Например, на Udemy используется архитектура микрофронтендов: то есть в рамках одного домена на разных страницах будут подгружаться совершенно самостоятельные Next.JS/React проекты, со своими бандлами, манифестами, чанками и разными вариантами конфигурации сборки. Так же динамические чанки/модули могут быть описаны и без манифеста, а прямо внутри бандла, но метод их извлечения будет точно таким же; изменится только алгоритм поиска в бандле объекта с ID'шниками и формирование итогового списка URL.

Так же интересный кейс из моего опыта, как даже на таком этапе данный краулер может помочь найти баги: на Udemy в манифест-файле есть ссылки на модули, начинающиеся со слова tapen. Долгое время они выдавали 403 при попытке скачивания, а потом в один из запусков смотрю логи - а они скачались. Открыл, посмотрел, а это код внутренней админки, да еще и вместе с source maps! Впрочем, беглый осмотр ничего интересного не показал, а глубоко копаться в коде у меня уже желания не было из-за игнора со стороны компании. Но это уже может быть валидный баг low-severity из категории Information Disclosure / Misconfiguration, а то и более серьезное что-то там найти получится, если хорошо изучить исходный код. Как правило, к внутренним порталам, доступным только для сотрудников, внимание к безопасности и в целом качеству кода гораздо ниже.

В следующей части рассмотрим работу с HAR-файлами для извлечения из них JS, их форматирование, извлечение source maps и подготовку к работе (или даже саму работу) с JS extractors, для вытаскивания из кода всего самого интересного. А в финале выложу код всего проекта на GitHub, для тех кому лень повторять вручную написанное в статье! 

Stay tuned~
👍47 🔥12 🤔9 😢6 💯6

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.