Вместо введения
В данной статье расскажу о своем опыте участия в баг-баунти и анализе клиентского кода веб-сервисов, а так же об автоматизации работы с ним. Понимание того, как работает сервис изнутри, его логика, является одним из важнейших аспектов поиска уязвимостей в 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. WAF вроде Cloudflare — не позволит просто запустить краулер и получить JS-файлы. Скорее всего, эту проблему можно решить и с помощью katana, т.к. она поддерживает краулинг через браузер, но зачастую проще и быстрее написать специфичное решение под конкретный таргет, чем пытаться обходить недостаточную гибкость универсальных инструментов.
- 2. Сборщики вроде Webpack — упаковывают код так, что он подгружается только при необходимости (так называемые chunks)
- 3. Изменчивые имена файлов — от сборки к сборке имена чанков могут различаться, появляются новые файлы, а нам необходимо сравнивать версии из предыдущего и текущего запуска
- 4. Минификация кода — если повезёт, будут включены source maps (разработчики часто забывают их отключить на Next.js проектах — хантерам на заметку), а если нет — придётся иметь дело с малопонятным минифицированным кодом
- 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
~/.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
}
Первой выполняется функция 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();
}
JavaScript:
if (keyValue.includes(KEY_MATCH)) {
JavaScript:
[some_var]: "string",
"asdf": "string" + "2",
JavaScript:
const objectCode = generator(path.node, {
compact: false,
minified: false
}).code;
const jsObject = eval(`(${objectCode})`);
JavaScript:
for (const k of Object.keys(jsObject)) {
if (ignorePatterns.some(p => k.match(new RegExp(p)))) {
delete jsObject[k]
}
}
Спойлер: Полный код парсера:
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.
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
LIMB