Всем привет! Я Владимир Гринчуков, в Positive Technologies мы с командой PT BlackBox разрабатываем сканер безопасности. На вход он получает URL приложения, а после сканирования возвращает список уязвимостей. Как и любой анализ чёрным ящиком, он работает по простому принципу: сначала собирает поверхность атаки — список всех эндпойнтов приложения, их методов и параметров, — а потом ищет на них уязвимости. Сбор поверхности — это непростая задача, в решении которой мы набили много шишек.

Компонент, который обходит приложение и фиксирует все входные точки, называется краулер — это неотъемлемая часть DAST‑инструментов. Концептуально он работает очень просто: открыть страницу, найти все ссылки, перейти по ним и повторить, пока ссылки не закончатся. Чтобы быть пригодным для сканера уязвимостей, он, помимо основной функциональности, должен ещё отвечать требованиям воспроизводимости, автономности, конечности и скорости.

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

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

В чём сложность?

Давайте посмотрим на личный кабинет в приложении на React или Vue. Жмём на портрет в углу — появляется подменю, в нём заходим в «Настройки», внутри видим вкладки. Кликаем на одну из них, потом на фотографию профиля и, наконец, отправляем очень ценный для сканера запрос на изменение профиля.

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

Пример взаимодействия с одностраничным приложением (SPA, single page application)
Пример взаимодействия с одностраничным приложением (SPA, single page application)

Статический краулер до запроса на смену аватарки не дойдёт не потому, что плохо парсит ссылки, а потому, что в DOM (Document Object Model), который браузер рендерит по адресу личного кабинета, нет ни кнопок, ни вкладок, ни меню. Узнать, что в приложении вообще есть смена аватарки и главное — как выглядит запрос на обновление и какие в нём правильные значения параметров, можно двумя способами: пройти весь этот путь взаимодействий со страницей или проанализировать весь JS‑код приложения (включая зависимости!).

Договоримся о терминах. Статическим мы будем называть приложение, все эндпойнты которого можно собрать без взаимодействия с интерфейсом. Если после открытия адреса и рендеринга страницы в браузере из DOM можно рекурсивно достать все ссылки и формы без единого клика — это статическое приложение. Отсюда и статический краулер — он ни с чем не взаимодействует, только рендерит и парсит DOM. Приложение, в котором информацию о каких‑то эндпойнтах и их параметрах можно получить только после взаимодействия (кликов, скроллов, ввода текста), мы будем называть динамическим.

Личный кабинет из примера выше — это динамическое приложение, в нём запрос на URL отдаёт лишь одно начальное состояние страницы: меню закрыто и интересные кнопки ещё не загружены. Остальные состояния, которых может быть десятки и сотни, придётся получать эмпирически: кликать и смотреть, что получилось. Причём каждое нажатие на очередную кнопку добавляет новые кнопки, которые тоже надо нажать. Таким образом, чтобы покрыть динамические приложения, краулер должен работать непосредственно с состоянием в браузере.

С чем мы работаем

Динамический краулер должен уметь отвечать на два вопроса. Первый: с чем на странице можно повзаимодействовать (кликнуть, ввести текст, навести мышку, выполнить drag‑n-drop и так далее). Второй: что меняется после такого взаимодействия. Кажется, что оба ответа можно получить из HTML, но это не так.

Куда бы кликнуть

Вернёмся к смене аватарки. Элемент для выбора файла может выглядеть как <div class="avatar"> с картинкой внутри — ничто не указывает на его интерактивность. Кликабельным он стал, когда на странице выполнился JS, а в HTML об этом нет ни слова: обработчик может добавляться из кода через addEventListener, существовать только во внутренностях фреймворка (React, например, вешает большинство обработчиков не на сами элементы, а на корневой контейнер приложения) или находиться в closed shadow DOM, недоступном снаружи by design. Обратное тоже верно: элемент может выглядеть как кнопка, но клик по нему ничего не даст — он неактивен, перекрыт оверлеем или его обработчик кликов ждёт, пока мы выполним какие‑то предварительные действия.

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

Что изменилось

Допустим, мы нашли все кнопки и на одну из них нажали. Что дальше? URL не изменился, запросов не было. Единственный способ понять, в каком состоянии мы оказались, — посмотреть на страницу и сравнить с тем, что было до нажатия.

Но сначала нам надо договориться, что именно мы сравниваем. Если открылось модальное окно, то тут, пожалуй, все согласятся, что это новый экран. Вот только в разметке ничего нового не появилось: окно всё это время лежало в HTML скрытым, а клик всего лишь поменял ему один атрибут —hidden или, например, aria-expanded. Ровно такого же размера изменение мы увидим, если на странице просто обновится таймстамп или цифра в счётчике уведомлений, хотя там никакого нового экрана нет. Величина различий в HTML сама по себе ничего не говорит.

Другой пример: мы на странице со списком товаров, нажали «Показать еще», и под старыми карточками появилось ещё двадцать таких же. Это новое состояние? Экран выглядит иначе, но ничего принципиально нового в нем нет: те же карточки, те же кнопки и обрабатывает их тот же код — вся разница в тексте и картинках. Если не понять, что это примерно одно и то же, то есть риск потратить все ресурсы на добавление в корзину всего каталога приложения.

Однотипные товары в магазине
Однотипные товары в магазине

И при всём при этом спросить текущее состояние у приложения нельзя: такого универсального интерфейса просто не существует. Наружу торчит только страница в браузере. У человека тоже не уточнить: см. требование автономности. Значит границу между одинаковыми и разными состояниями краулер проводит сам.

Это и есть первое важное решение, которое придётся принять: что мы называем состоянием. Дальше на нём будет держаться всё остальное: как отличать новое от уже просмотренного, в каком порядке это всё обходить и когда остановиться.

Комбинаторика

Кажется, что объем работы понятен: нашли на странице n интерактивных элементов, с каждым повзаимодействовали один раз и записали, что получилось. Но это так не работает: элементы не независимые.

Например, откроем список писем в почтовом клиенте и поставим галочку на одном из них. Появилась панель действий и кнопки «Удалить», «В архив», «В спам» — это, очевидно, новое состояние. Но новые элементы в нём не одни: никуда не делись сами письма, папки и поиск, которые мы уже нажимали. Однако в этом состоянии элемент с письмом изменил своё поведение: интерфейс переключился в режим выделения, и клик по письму больше не открывает его содержимое, а добавляет его к выделенным.

Заметить это заранее нельзя: выглядит письмо точно так же, а что оно сделает по клику — решает обработчик событий. Единственный способ проверить, помимо анализа всего JS (включая зависимости!), — кликнуть еще раз, уже из нового состояния.

Поведение элементов зависит от состояния
Поведение элементов зависит от состояния

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

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

Количество действий растёт очень быстро, если они могут принадлежать только одному состоянию
Количество действий растёт очень быстро, если они могут принадлежать только одному состоянию

Количество состояний m, кстати, тоже растёт мультипликативно: наблюдаемое состояние страницы — это вектор, где каждая координата — это положение одного независимого переключателя. Включить или выключить режим выделения, открыть или скрыть меню профиля — действия независимые и общее количество комбинаций может достигать 2^k.

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

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

Повторяющиеся элементы

Вторая половина проблемы — однотипные элементы, порожденные данными, а не логикой. Каталог из 500 товаров по 4 интерактивных элемента в карточке — это 2000 действий, ведущих в 2000 формально различных состояний, хотя все они исполняют один и тот же код; при типичном бюджете в несколько тысяч действий одна такая страница сжигает этот бюджет целиком. А бесконечные приложения вроде календаря и вовсе производят бесконечное количество действий и состояний.

Что из этого следует

Вывод, на котором мы будем строить дальнейшее решение: для краулера приложение — это размеченная система переходов (S, A, →): множество состояний S, множество действий A и отношение переходов →⊆ S × A × S. Переходы размечены действиями: краулеру важно не только то, что из s достижимо s', но и каким именно образом. Отдельный переход записывается как s --a--> s', а множество действий, доступных в состоянии s, обозначим enabled(s) ⊆ A.

Выше мы установили про S две вещи. Первая: в худшем случае его размер растёт экспоненциально с числом независимых фрагментов интерфейса. Вторая: для некоторого множества приложений размер вообще не ограничен сверху. Это значит, что исчерпывающий обход в общем случае невозможен. Обойти пришлось бы все пары {(s, a) | s ∈ S, a ∈ enabled(s)} — то есть выполнить каждое доступное действие в каждом состоянии. Снизу их число ограничено связностью: чтобы каждое состояние было достижимо от стартового, переходов нужно как минимум |S| - 1. А сверху граница упирается в размер S, который сам может быть ничем не ограничен.

Решение, которое мы нашли для себя, — обходить не S, а фактор‑множество S/~: разбить состояния на классы и посещать по одному представителю от класса. Тогда задача краулера формулируется так: построить отношение ~ и обойти множество S/~. К самим ~ и S/~ предъявим пять требований:

  1. Это должно быть отношение эквивалентности: рефлексивное, симметричное и транзитивное. Отсюда следует главное: то, какие состояния окажутся в одном классе, не зависит от порядка, в котором краулер их встретил. Привлекательный на первый взгляд подход со сравнением через пороговую схожесть (simhash с расстоянием Хэмминга, tree‑edit distance) этому не удовлетворяет: из d(a,b) < τ и d(b,c) < τ не следует d(a,c) < τ, а классифицировать очередной снапшот нам нужно сразу, по первому совпадению с уже известными классами, — значит, два одинаковых прогона легко могут дать разный результат.

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

  3. Два класса должно быть дёшево сравнивать, в идеале по уникальному ключу: классифицировать снапшот приходится после каждого действия, тысячи раз за скан. Если можно посчитать ключ класса состояния, то можно найти его в хеш‑таблице графа за O(1). В наивных решениях через расстояние Левенштейна новый снапшот приходится сравнивать с каждым известным классом: O(|S/~|). А сравнение, выполняемое за секунду, делает обход невозможным ровно так же, как и его отсутствие.

  4. S/~ должно быть конечным и расти медленнее, чем число выполненных действий: чем дольше идёт скан, тем реже попадаются новые классы состояний. Плохой выбор ~ дает новый класс на каждое действие и это практически ничем не отличается от исходного S. Конечность S/~ может (но не обязана, об этом расскажу ближе к концу) быть условием остановки: краулинг заканчивается, когда мы посетили все классы состояний.

  5. ~ должно быть согласовано с переходами. Если s ~ s', то из них должны быть доступны одни и те же действия (enabled(s) = enabled(s')), а одинаковые действия — вести в эквивалентные состояния: из s --a--> t и s' --a--> t' следует t ~ t'. Это, равно как и детерминированность переходов, принимается краулером за рабочую гипотезу, потому что проверить её заранее невозможно из‑за того, что граф он самостоятельно строит с нуля.

Как видно, в поставленной задаче выбор ~ критично важен.

Определение состояния

Итак, теорию пережили, теперь о том, какое решение получилось у нас.

Состоянием мы называем множество отпечатков интерактивных элементов, доступных на странице. Отпечаток у нас — это 64-битное число, посчитанное по элементу. Ключ состояния — хэш этого множества, а два состояния эквивалентны тогда и только тогда, когда множества совпали. Обратите внимание, что нам не важно, насколько сильно совпадает контент страницы. Если в обоих состояниях нашлась одна и та же единственная кнопка, то их DOM, текст и картинки могут отличаться сколь угодно сильно — состояния всё равно эквивалентны.

Четыре разные страницы, но ключ состояния совпадает
Четыре разные страницы, но ключ состояния совпадает

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

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

Обе половины операции вычисления хэша состояния устроены достаточно сложно.

Первая — отбор. Мы собираем все потенциально интерактивные элементы. Каким именно образом — длинная тема, которую мы опустим, но одного красивого решения там нет: приходится комбинировать статический анализ, перехват функций JS, и специальные подходы под отдельные фреймворки. Однако в состояние найденные элементы попадают не все, а только те, с которыми можно повзаимодействовать прямо сейчас. Кнопки, перекрытые выскочившим поп‑апом, никуда не делись из DOM, но они недоступны для клика; что‑то спрятано стилями, а до чего‑то мы просто не доскроллили — признаков, по которым элемент отсеивается, огромное количество. Именно этот отбор и делает состояние состоянием: одно и то же дерево DOM с закрытым и с открытым модальным окном даёт два разных множества доступных элементов, а значит — два разных состояния.

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

  • Считается по одному элементу в одном снапшоте. Это чистая функция от элемента и его окрестности в DOM — ничего из истории обхода в отпечаток не попадает.

  • Не зависит от признаков, которые меняются при каждом рендере. Сгенерированные фреймворком идентификаторы, числа в тексте, точные координаты элементов — всё это вносит энтропию между двумя рендерами, и любой такой признак, попавший в отпечаток, гарантирует ненужное расщепление состояний. Отбор признаков — это кропотливая работа: признак годится, только если он не меняется при повторном рендере того же элемента, но при этом отличает его от соседей. Это нужно для удовлетворения требования 2: наблюдение должно быть детерминированным.

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

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

  • счетчик уведомлений с изменяющимися цифрами нового состояния не порождает;

  • смена однотипных карточек товаров — тоже;

  • а открывшееся модальное окно — порождает (несмотря на разницу в один атрибут на весь DOM), потому что в множестве появились элементы, которых в нём не было.

Произвольное блуждание

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

Доступ к вершине не бесплатный: вспомним, что состояние — это не URL, а положение, в котором приложение оказалось после последовательности действий. Нельзя загрузить состояние некоторой функцией loadState(hash): единственный способ снова оказаться в нужном состоянии — заново проиграть действия, которые к нему привели.

Эту стоимость нельзя игнорировать: выполнить действие, до которого от текущего положения браузера d шагов, стоит всех d шагов, а каждый из них — это выполнить действие, дождаться, пока страница перестанет меняться (сам по себе непростой вопрос: поторопишься — получишь неправильный класс; подождешь слишком долго — потеряешь время на каждом из тысяч шагов), и снять с неё состояние. Если действие провоцирует навигацию, сверху добавляется полная загрузка страницы со всей статикой.

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

Время одного шага, кстати, случайное: оно зависит от сети, кэша, нагрузки и того, что именно происходит на странице в этот момент. Поэтому мерить путь по времени нельзя: измерение шумит от прогона к прогону, а нам нужна воспроизводимость. Число шагов — грубая но стабильная оценка.

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

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

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

Граф

Мы много экспериментировали с тем, как именно может выглядеть граф краулера, и пришли к тому, что он должен быть двудольным: вершины‑состояния и вершины‑действия строго чередуются. Очередное контринтуитивное решение: действие — это не ребро, а узел графа. Мы знаем, что любое действие приведёт нас в какой‑то класс состояний (иногда в исходный). Когда мы попадаем в очередное состояние, мы связываем его со всеми обнаруженными в нем действиями. Но так как нам нужно выбрать и выполнить лишь одно из них, остальные действия нужно каким‑то образом оставить в графе. С рёбрами так не получится — им нужны оба конца, а у нас пока нет второго. Кроме того, множество действий не зависит от множества состояний. Одно и то же действие, как мы уже выяснили, может встречаться в разных состояниях, и именно представление в виде узлов позволяет нам отразить это в графе.

Как выглядит граф через пару секунд после начала краулинга
Как выглядит граф через пару секунд после начала краулинга

Такой подход также позволяет нам не поддерживать отдельную очередь обхода. Главную цель нашего краулера мы формулируем, как «выполнить все доступные действия», исходя из того, что именно действия порождают запросы, которые нужны нам для анализа в PT BlackBox. Это и есть happy‑path условие остановки: краулинг заканчивается, когда в графе не остаётся доступных действий. Сами запросы краулер при этом не отслеживает: их собирает прокси, через который работает его браузер. Краулеру важно только состояние фронтенда.

А найти «доступные действия» очень просто — это узлы с нулевой исходящей степенью. Если клик по элементу ничего не делает, мы связываем его с исходным состоянием: получается маленький цикл, но главное, что этот клик теперь имеет исходящее ребро и мы больше не будем тратить на него время.

Выбор следующего действия из доступных работает просто: среди кандидатов мы ищем самое близкое, считая от текущего состояния и от состояний, куда можно попасть переходом по URL. При равной длине побеждает более приоритетный тип действия (ввод, клик и так далее), а дальше — действие, которое в различных состояниях встречается реже — у него выше потенциальная ценность. Последним идёт отпечаток элемента: так два одинаковых прогона выбирают одно и то же.

Узел-состояние в графе
Узел‑состояние в графе

Если же во время обхода какого‑то пути мы столкнулись с неожиданным узлом — например, изменилось поведение кнопки, — нам достаточно перестроить в графе одно ребро. Само действие и сами исходное и ожидаемое состояния мы уже видели и точно знаем про их существование, поэтому они из графа не исчезают. Меняется переход: у каждого узла‑действия может быть лишь одно исходящее ребро, и это всегда последний наблюдавшийся исход. Так граф описывает самое свежее известное нам поведение приложения, но забывает предыдущее. Если какие‑то интересные узлы при этом остались недостижимыми, не страшно — мы уже сформулировали невозможность исчерпывающего обхода и готовы пойти на такой компромисс ради конечности скана. К тому же, нельзя исключать возможности, что однажды связь с подвисшей частью графа восстановится.

Узел-действие в графе
Узел‑действие в графе

Также такой двудольный граф — это наша попытка удовлетворить требование согласованности состояний и переходов. Мы исходим из гипотезы, что одно и то же действие всегда ведёт в один и тот же класс состояний независимо от того, откуда его выполнили: если до нужного состояния мы однажды добрались десятью шагами, последним из которых была кнопка логина, то мы ожидаем, что и из любого другого места та же кнопка приведет нас туда же. Если при проверке окажется, что это не так — мы перестроим граф.

Как это выглядит в реальном времени
Как это выглядит в реальном времени

Итоги

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

Стоило ли оно того? В PT BlackBox мы добываем поверхность атаки разными способами, не только краулингом: по API‑схеме, по записанному трафику, через перебор директорий. У каждого, конечно, свои ограничения: API‑схему нужно, во‑первых, иметь, а во‑вторых, держать в актуальном состоянии; чтобы записать трафик, нужно самостоятельно обойти приложение или хотя бы его интересные эндпойнты; а перебор директорий полностью зависит от словаря. Краулинг — самый универсальный, но одновременно с этим самый хрупкий подход. Универсальный — потому что не требует ни схемы, ни подготовки, а хрупкий — потому что огромное разнообразие веб‑приложений не позволяет создать идеальный алгоритм, всегда дающий 100% покрытия.

До сих пор краулер у нас был только статический, и это создавало серьезные препятствия для скана приложений с динамическим фронтендом: на SPA без API‑схемы и записанного трафика мы находили только навигационные GET‑запросы и почти ничего из того, что появляется после взаимодействия с интерфейсом. Динамический краулер исследует приложение так, как это делал бы обычный пользователь. На ранее неприступных приложениях мы теперь получаем от 65% до 97% покрытия. Покрытием мы называем долю эндпойнтов эталона (метод, путь, параметры), которые краулер нашёл сам; эталон собирали по реальным API‑схемам и вручную записанным HAR. Разброс зависит от таргета: где‑то краулер исчерпывает доступные действия, где‑то упирается в дефолтный часовой лимит (можно поднять!).

Сравнивать эти числа с прежними я не буду: статический краулер работает по‑другому и находит другие эндпойнты, так что считать, во сколько раз стало лучше, бессмысленно. Работают они вместе — статический быстро снимает то, что доступно без взаимодействия, динамический добирает остальное.