Relwarc: третья часть рассказа про алгоритм анализа клиентского JS для майнинга HTTP-запросов

August 31, 2024 • Даниил Сигалов (@asterite3)

В этом посте (продолжающем эти: 1, 2) расскажу про поддержку библиотек, трансформацию вызовов в HTTP-запросы, о том, что в итоге получилось, какие выводы мы сделали, и кое-что ещё.

Но обо всём по порядку.

Как он работает — ещё про алгоритм

Алгоритм ищет в коде вызовы функций, отправляющие запросы на сервер. Но как, глядя на вызов, понять, что он отправляет на сервер запрос? И как понять, какой именно запрос отправляется? По идее, если вызываемая функция определена где-то в коде, можно было бы проанализировать её код, и всё понять. Но в реальности это может быть не так-то просто — многие современные JS-библиотеки устроены довольно сложно, а JavaScript-код тяжело анализировать статически. Как упоминалось в первом посте, даже с библиотекой jQuery, самой популярной в интернете, у существующих статических анализаторов есть проблемы. Поэтому мы используем сигнатуры библиотечных вызовов и модели библиотек, встроенные в анализатор.

Сигнатуры библиотечных вызовов

В анализатор встроен набор сигнатур, по которым он понимает, что вызов в коде — это вызов отправляющей запрос на сервер функции. Самые простые сигнатуры — те, которые мы добавили изначально, и которые активно используем до сих пор — работают просто по названиям функций. То есть, в анализатор просто встроено знание о том, что, если вызывается метод $.ajax, с именем объекта $ и именем метода ajax, то он отправляет запрос на сервер. Точно так же, как анализатор знает, что отправкой запросов занимается функция fetch и методы экземпляров класса XMLHttpRequest. Мы добавили такие сигнатуры для ряда популярных библиотек: jQuery, Angular, Axios. Благодаря ним анализатор, увидев $http.post(...), axios(...) или jQuery.post(...), поймёт, что это места отправки запросов на сервер. В него заложен список имён. Могут ли эти сигнатуры не сработать? Конечно. Достаточно было бы переименовать библиотечный объект или функцию, и вызов не найдётся. И всё же, нередко их достаточно. Разработчики навряд ли сами будут переименовывать библиотечные объекты: для читаемости кода лучше, чтобы вызов имел привычный вид. Тем не менее, функции и объекты библиотек могут быть переименованы при упаковке бандлерами, особенно в сочетании с минификацией. А некоторые библиотеки вообще не рассчитаны на то, чтобы ими пользовались через какой-то глобальный объект или функцию со стандартным названием. Поэтому в анализатор встроены и более сложные виды сигнатур — например, такие, которые распознают функцию по виду её кода (AST-сигнатуре на тело функции), и дальше, в местах использования, матчат её по значению, а не по имени.

Модели библиотек

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

Потому что мы его выложили.

Relwarc

Мы выложили код нашего анализатора в open source. А также сделали публично-доступный сервис relwarc.solidpoint.net, позволяющий запустить анализатор на JS-коде. Мы решили назвать анализатор Relwarc — то есть «сrawler» наоборот. Потому что он решает ту же задачу, что и краулер, но наоборот: пытается определить, какие запросы клиентский код посылает на сервер, но при этом не имитирует действия пользователя на странице, а смотрит на сам код. Работает не динамически, а статически.

Версию кода, которая примерно соответствует тому, что описано в этом и предыдущих постах, можно найти на GitHub, вот по такой ссылке:

https://github.com/seclab-msu/relwarc/tree/vanilla-algorithm

Она же соответствует описанию алгоритма из нашей научной статьи 2021 года.

Сервис relwarc.solidpoint.net умеет анализировать как отдельные JS-файлы, так и веб-страницы. Он предоставляет API и веб-UI, позволяющий вручную позапускать анализатор на чём-нибудь, поиграться с ним. Попробуйте им воспользоваться! Может быть он найдёт для вас новые серверные ручки в каком-то коде, который нет времени или сил разбирать вручную. Если найдёте баг или будет идея что улучшить — смело создавайте issue в репозитории на GitHub (или сразу pull-реквест)!

Что в итоге

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

Метрика качества

Ну хорошо, вот мы делали этот алгоритм, реализовали там анализ цепочек вызвовов, добавляли такие улучшения и эдакие — и что же в итоге? Что мы получили? Этот анализатор работает вообще? Он нормально работает? Находит что-то? Чтобы ответить на этот вопрос, нам хорошо было бы иметь какой-то бенчмарк, позволяющий мерить качество работы алгоритма. Скажем, набор приложений или хотя бы веб-страниц, реальных или похожих на современные реальные, где из клиентского JS отправлялись бы запросы, и для которых был бы известен правильный ответ — какие именно запросы отправляются. Тогда мы могли бы сравнивать набор правильных запросов с тем, что выдал наш анализатор, и считать метрику качества. Мы не нашли такого бенчмарка (из похожего нашёлся WIVET, но он всё-таки немного про другое). Поэтому мы решили сделать свой! Мы создали датасет из страниц реальных приложений, для которых мы вручную разметили, к каким серверным эндпоинтам отправляет запросы их JS-код (точнее, там есть страница тестового приложения, но только одна — это главная страница Juice Shop). Почти все страницы датасета это страницы случайных сайтов из интернета — с сайтов из списка Alexa Top 1 Million или из публичных Bug Bounty программ. Этот датасет мы тоже решили опубликовать, вот он:

https://github.com/seclab-msu/ajax-page-dataset

Кроме самих страниц и разметки (наборов «правильных» запросов, которые должны найтись) в этом репо есть скрипты для прогона по нему анализатора и подсчёта метрик. Главная метрика качества, которую мы используем — это среднее покрытие. То есть считаем для каждой страницы процент найденных запросов и берём среднее значение этих процентов между всеми страницами. Сейчас для последней версии анализатора среднее покрытие около 40%.

В заключение

Задача статического анализа в общем случае неразрешима, а анализировать JavaScript-код особенно тяжело. Так что сделать анализатор, который работал бы идеально, полно и точно, в 100% случаев, невозможно. Но мы можем постараться сделать алгоритм, который будет работать в большинстве реальных случаев. Стараться дотянуть процент реальных приложений, на которых он работает, до 80% или 90%. Всегда можно будет сделать хитрый пример кода, на котором алгоритм сломается — ну и что с того, если такой код не будет встречаться почти никогда в реальной жизни? Чтобы понять, как сделать такой анализатор, который будет работать в реальности, нам пришлось не только программировать, но и заниматься исследованием реального кода. Куча времени ушла на изучение того, как устроена отправка запросов и потоки данных, от которых отправляемые запросы зависят.

Сейчас среднее покрытие на нашем датасете составляет 40% — это много или мало? Конечно, было бы классно иметь скор в те самые 80% или 90%. Но всё-таки JavaScript анализировать статически непросто, а мы старались сделать наш датасет более-менее представительным — то есть, чтобы, глядя на скор на нём, можно было составлять какое-то представление о качестве на случайно взятом случайном сайте из интернета. Ни один из существующих академических статических анализаторов JS не справляется с реальными современными веб-страницами. Следуя принципу, упомянутому в первом посте, мы решили начать с чего-то простого, чтобы не сделать сложную махину, которая так никогда и не сработает на чём-то реальном (или которую мы даже не допишем до конца). Начать с чего-то простого, и затем итеративно это улучшать, повышая скор и повышая сложность. Поддерживая новые фичи, новые особенности кода. В результате такой, более простой и учитывающий особенности реального кода анализатор (заточенный под решаемую задачу) способен справляться с реальными страницами, и часто у него получается обработать их довольно быстро — меньше чем за минуту, иногда за несколько минут.

Значит ли это, что этот анализатор всегда теперь надо развивать только итеративно, понемногу добавляя или меняя что-то? На самом деле нет — иногда надо всё-таки переписывать софт заново, передылывая его существенно. Быть может, построив алгоритм на немного других принципах. Но ценными останутся показатели на датасете, то, какие запросы находил прошлый алгоритм, и за какое время. То, какие тесты он проходил. То есть полученные знания. Если при разработке нового алгоритма он в чём-то будет уступать старому, всегда можно будет глянуть — а почему старому это удавалось? За счёт каких принципов работы он справлялся с какой-то конструкцией кода.

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