Можно ли найти IDOR статическим анализом? Пишем ядро для Python

Всем привет. Пару месяцев ломал себе голову, почему никто в коде не ищет IDOR-уязвимости. Ответы получал разные: что в большинстве случаев мы будем получать FP/FN в коде, да и зачем их искать, если у нас есть DAST? Но базовый DAST может не находить, если приложение написано на жёстком SPA или же у него фиговый краулер. И несколько дней назад дописал модуль (или ядро) для детекта уязвимости IDOR для ЯП Python.
Главный вопрос, на который я хотел ответить этой работой, звучит так:
Можно ли искать IDOR статическим анализом не эвристиками, а через анализ связи между
пользовательским идентификатором, объектом и проверкой авторизации?Дальше вся статья последовательно отвечает на этот вопрос: сначала показываю, что именно надо поймать, потом почему существующие подходы это не ловят, потом как устроено ядро, и в конце - что оно реально даёт на живом коде.
Содержание
Что такое IDOR и примеры уязвимого кода на Python
Ресерч на эту тему, какие есть решения и тесты
Как вообще написан модуль и почему его можно использовать как базовый Taint-модуль
Замер на продуктах
Выводы
1. Что такое IDOR?
Давайте начнём с того, что такое IDOR? IDOR (Insecure Direct Object Reference) - уязвимость в веб-приложениях, которая позволяет получить несанкционированный доступ к конфиденциальным данным или чужим аккаунтам из-за отсутствия проверки прав на стороне сервера. Ну из простых примеров взять, что у вас есть две квартиры. Одна ваша, вторая друга. По сути, вы в квартиру друга не можете зайти без ключа, но вы нашли обход и получили доступ.
Давай теперь посмотрим, где уязвимость IDOR в коде. Базовый пример на Flask:
from flask import Flask, g, jsonify
from models import Invoice
app = Flask(__name__)
@app.route("/invoice/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = Invoice.query.get(invoice_id)
return jsonify(invoice.to_dict())@app.route("/invoice/<int:invoice_id>") - это декоратор, связывающий URL с функцией. <int:invoice_id> - динамический URL-параметр. Он означает, что часть URL после /invoice/ будет передана в функцию как переменная invoice_id, причём Flask автоматически преобразовывает её в целое число, поэтому если вы введёте /invoice/abc, то вам вернётся 404.
login_required - декоратор защиты, блокирует доступ к эндпоинту для неавторизованных пользователей и автоматически перенаправляет на страницу логина.
def get_invoice(invoice_id): - объявление функции, обрабатывающей запрос. Она принимает invoice_id из URL.
invoice = Invoice.query.get(invoice_id) - запрос к базе данных с помощью ORM. Метод .get() ищет запись в таблице Invoice по её первичному ключу (ID).
return jsonify(invoice.to_dict()) - разберём по порядку. invoice.to_dict() - это метод модели, который превращает объект БД в обычный Python-словарь (т.к. объекты БД нельзя напрямую превратить в JSON). jsonify(...) - функция Flask, которая конвертирует словарь в строку формата JSON и добавляет правильный HTTP-заголовок Content-Type: application/json.
Что же здесь не так?
Отсутствие проверки прав доступа. Код только проверяет, что пользователь просто залогинился (с помощью @login_required), но не проверяет, что принадлежит этот счёт именно ему. Любой авторизованный может перебирать invoice_id в URL и видеть чужие счета.
Вообще статья не про то, как защитить код, но я всё-таки покажу на примере, который я бы реализовал:
@app.route("/invoice/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = Invoice.query.filter_by(
id=invoice_id,
owner_id=current_user.id, # вот это и есть защита от IDOR
).first_or_404()
return jsonify(invoice.to_dict())Главную мысль донести: от IDOR защищает только проверка прав. Либо сужаем выборку владельцем, как здесь, либо достаём объект и сравниваем invoice.owner_id с текущим пользователем, а иначе отказ.
UUID вместо инкрементного id и rate-limit тоже стоит поставить, но не путайте их с защитой. Они усложняют перебор, а саму дыру не закрывают: если атакующий узнал чужой UUID (из логов, из ссылки, из выдачи другого эндпоинта) - он всё так же получит чужой счёт. Перебор - это способ эксплуатации, а уязвимость в том, что сервер не спросил «а это твоё?».
2. Research на реализацию у других и просмотр статей
Потыкав всемирную паутину о том, как ловят IDOR-уязвимости, натыкался на пару статей и репозиториев, но так таковую логику никто не реализовал. Давайте разберём пару статей/репозиториев. Возьмём приближенную тему к моей теме.
Guardmarly - свежий проект на GitHub, позиционирующий себя как детектирование IDOR-уязвимостей в коде, аж на 5 языках программирования. Проанализировав ядро по ловле IDOR, нашёл несколько проблем: их супрессор ast.walk делает проверку по всей функции с булевым флагом. Нет проверки по ветвям, порядка и сопоставления объекта. Проверка в мёртвой ветке, после использования, не того объекта, с проглоченным исключением - всё засчитывается как защита. Полезнее всего оказалась находка при написании ядра. Я решил сравнить своё ядро и ядро Guardmarly, появилась одна находка, которую я не покрывал - благодарю их). По моему мнению, ядро по IDOR не особо хорошо работает, т.к. у них широкая сетка по многим языкам и классам, где IDOR ловится эвристикой, а мне хотелось доказать, что поймать IDOR можно математической логикой.
Статья Барабанова, Дергунова, Макрушина и Теплова. Вход у них только OpenAPI-спека. Ни источников, ни трафика. Здесь есть два этапа: разметить спеку свойствами, связанными с BOLA, затем сопоставить размеченное с каталогом паттернов атак. Фундамент состоит из 14 групп атак, отчётов по bug bounty и академических работ. Также сопоставив некоторые моменты в коде, понял, что нет охвата одной штуки: параметр производится другим эндпоинтом сервиса: /buckets создаёт ресурс и возвращает его ID, а /buckets/{bucketID} его потребляет.
Почему меня нельзя сравнивать с этой статьёй? В спеке GET /orders/{order_id} выглядит абсолютно одинаково независимо от того, есть в реализации проверка владения или нет. Спека физически не содержит ответа на наш вопрос, поэтому не наш выход. Эта статья дала мне только кейсы и выявление недостатков ядра.
Semgrep и CodeQL. Semgrep выносит IDOR в LLM-продукт, а не в правила. Их опубликованная цифра: «Точность 61% у агента с Semgrep как инструментом против 22% у чистого LLM, где 88% находок оказались ложными». Т.е. сам Semgrep признаёт, что детерминированными правилами задачу не закрыть.
CodeQL - мощная штука, и я не говорю, что он такое не может в принципе: у него есть и межпроцедурный анализ, и возможность писать свои предикаты. Проблема в другом. Классическая постановка source -> sink сама по себе не выражает семантику IDOR. Она отвечает на вопрос «дошли ли данные от источника до приёмника», а IDOR - это вопрос об отсутствии: «дошёл ли подконтрольный атакующему идентификатор до выборки объекта, и не было ли по пути проверки, которая доминирует эту выборку и сравнивает именно этот объект с текущим пользователем». Поток тут есть всегда, и в уязвимом коде, и в защищённом. Различает их не поток, а проверка.
Сведу в табличку, чтобы было видно, чего не хватает каждому подходу:
Подход | Что анализирует | Чего не хватает для IDOR |
|---|---|---|
OpenAPI-спека | контракт API | не видит реализацию авторизации: защищённый и дырявый эндпоинт в спеке одинаковые |
AST-эвристики | структуру кода | «где-то в функции есть слово permission» - много FP и FN |
Taint analysis | поток данных | находит путь |
Для IDOR недостаточно найти путь source -> sink. Нужно понять, относится ли проверка авторизации именно к тому объекту, который выбрал атакующий, и стоит ли она на том пути исполнения, по которому пойдёт запрос.
3. Как вообще написан модуль и почему его можно использовать как базовый Taint-модуль
Ну давайте начнём с того, что модуль делает? По сути читает исходник Python-приложения и отвечает на один вопрос про каждый HTTP-обработчик: «Какие данные в нём пришли от пользователя, куда они утекли и что успело их проверить по дороге».
Модуль разрезан на три части, которые общаются через данные, а не через вызовы друг друга.
Слой первый. Ядро разбирает файлы проекта и находит, где вообще начинается обработка запроса. На самом деле это не так просто, как казалось. Обработчик может быть функцией с декоратором маршрута, методом класса-вьюхи, методом вьюсета, функцией внутри фабрики blueprint'ов или вообще ничем не помеченной функцией, на которую ссылается urls.py. Сейчас поддержаны Django (и функции, и классы), DRF, Flask и FastAPI.
Слой второй. Для каждого обработчика собирается протокол: какие значения пришли из запроса, какие обращения к данным произошли, какие проверки выполнились и в какой ветке кода, кто такой «текущий пользователь» и был ли он вообще установлен.
Слой третий. Читает факты и выносит вердикт. Сейчас 44 гипотезы (что может быть не так) и 23 подавителя (почему на самом деле всё в порядке).
Как помечаются данные?
Модуль различает пять видов происхождения значения. Кратко систему я назвал SIAOD.
Метка | Что это | Пример |
|---|---|---|
| текущий пользователь, установленный аутентификацией |
|
| что-то, пришедшее от клиента | тело запроса, заголовок |
| идентификатор, которым клиент выбирает конкретный объект |
|
| то, что достали из хранилища | запись заказа |
| значение, происхождение которого статически не определить |
|
Самое неочевидное здесь - зачем INPUT и ATTACKER_SELECTED разделены, если оба пришли от клиента. Разберу на примере, потому что на этом различии держится всё остальное:
@app.post("/invoice/<int:invoice_id>/comment")
@login_required
def add_comment(invoice_id):
text = request.form["text"] # INPUT
invoice = Invoice.query.get(invoice_id) # invoice_id - ATTACKER_SELECTED
db.session.add(Comment(invoice_id=invoice.id, text=text))
db.session.commit()
return "", 201text - это INPUT. Клиент прислал строку, она поедет в поле комментария. Испортить ей можно многое: XSS, инъекцию, переполнение - но выбрать чужой объект ею нельзя. Она не участвует в том, какую запись мы достаём
invoice_id - это ATTACKER_SELECTED. Клиент этим значением указывает, к какой строке в базе обратиться. Поменял единицу на двойку - обратился к чужому счёту.
Если не разделять эти две метки, то любой обработчик, который принимает хоть что-то от пользователя, выглядит подозрительным, и вы утонете в ложных срабатываниях. А если разделять - вопрос сужается до одной проверки: дошло ли ATTACKER_SELECTED до выборки объекта, и связал ли кто-нибудь по дороге этот объект с SUBJECT.
OBJECT нужен, чтобы отличить «достали запись» от «посчитали число». DYNAMIC - честная метка незнания: когда имя поля собирается в рантайме.
Как ядро принимает решение: полный пример
Давайте прогоним тот самый уязвимый обработчик из начала статьи по шагам, чтобы было видно не архитектуру, а именно решение.
@app.route("/invoice/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = Invoice.query.get(invoice_id)
return jsonify(invoice.to_dict())Что видит ядро:
invoice_id (параметр маршрута)
↓
ATTACKER_SELECTED
↓
Invoice.query.get(invoice_id) # обращение к данным, селектор помечен
↓
OBJECT (invoice)
↓
проверок, связывающих invoice с SUBJECT, нет
↓
invoice уходит в ответ
↓
ВЕРДИКТ: подконтрольный id ведёт к данным,
запрос не сужен субъектом и проверки владения нетА теперь защищённый вариант:
@app.route("/invoice/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = Invoice.query.get(invoice_id)
if invoice.owner_id != current_user.id:
abort(403)
return jsonify(invoice.to_dict())invoice_id → ATTACKER_SELECTED → Invoice.query.get(...) → OBJECT
↓
if invoice.owner_id != current_user.id: abort(403)
↓
проверка сравнивает ЭТОТ объект с SUBJECT
↓
ветка проверки доминирует использование объекта
↓
ВЕРДИКТ: чисто (сработал подавитель)Ключевых слов тут два: этот объект и доминирует. Проверка if other_invoice.owner_id != current_user.id не считается - она про другой объект. Проверка, стоящая после jsonify(...), не считается - объект уже уехал. Проверка в соседней ветке if, через которую запрос не проходит, тоже не считается
Есть и третий вариант, который ядро тоже обязано понимать - когда проверки как таковой нет, потому что она не нужна:
@app.route("/invoices")
@login_required
def my_invoices():
invoices = Invoice.query.filter_by(owner_id=current_user.id).all()
return jsonify([i.to_dict() for i in invoices])Здесь запрос сужен субъектом прямо в выборке: чужую запись он физически не вернёт. Отдельной проверки владения быть и не должно.
Три вещи, которые отличают это от поиска по шаблонам
Метки переживают вызовы функций. Если обработчик зовёт вспомогательную функцию, а та читает запрос и возвращает прочитанное, вызывающая сторона получает помеченное значение. Без этого любая программа с парой хелперов выглядит чистой: помеченные данные исчезают на первом же def.
Проверка засчитывается только если она управляет доступом. Проверка, которая стоит в соседней ветке if и до обращения к данным не доходит, проверкой не считается. Формально: ветка проверки должна быть началом ветки доступа. Это отсекает целый класс ложных «тут же есть проверка» - проверка-то есть, но не на том пути.
Аутентификация - это граница, а не источник. Значения, которые пришли от аутентификации, помечаются как субъект и не наследуют пометку ввода, даже если технически пришли из того же запроса. Иначе после логина всё приложение выглядит заражённым, и модуль теряет смысл.
4. Сколько это даёт на живом коде
Я взял два направления:
небольшие проекты - 150 репозиториев, разобраны все находки до единой;
продуктовый код компаний - 15 репозиториев, 12 млн строк кода, тоже разобраны все находки до единой.
Небольшие проекты
Показатель | Значение |
|---|---|
Всего находок | 112 |
Истинные находки (TP) | 48 |
Ложные находки (FP) | 64 |
Точность (Precision) | 42.9% |
95% доверительный интервал (ДИ) | [34%, 52%] |
Количество находок на одну дыру | 2.3 |
Продуктовый код компаний
Показатель | Значение |
|---|---|
Всего находок | 731 |
Истинные находки (TP) | 5 |
Ложные находки (FP) | 726 |
Точность (Precision) | 0.68% |
95% доверительный интервал (ДИ) | [0.3%, 1.6%] |
Количество находок на одну дыру | 146 |
Да, 0.68%. Причина у всех 726 ложных одна: охрана стоит не в теле обработчика. Продуктовый код защищается слоями - правами на маршруте, зависимостями роутера, собственными RBAC-фреймворками. Ядро читает тело и говорит «проверки нет», хотя она есть, просто в двух файлах отсюда.
Все пять - в одной системе из пятнадцати, в commcare-hq. Четыре одного вида, и они хорошо показывают, что ядро всё-таки ловит.
@login_and_domain_required
def openmrs_raw_api(request, domain, repeater_id, rest_uri):
repeater = OpenmrsRepeater.objects.get(id=repeater_id)
assert repeater.domain == domainЗапись достаётся по id без сужения, а единственная привязка к тенанту - assert, который исчезает при запуске с python -O. Признаков -O в проекте я не нашёл, то есть в штатной конфигурации это не эксплуатируется - но авторизация действительно реализована конструкцией, которую положено выключать в проде.
Пятая - CustomerInvoicePdfView: PDF счёта отдаётся без единой проверки, dispatch() переопределен и только вызывает super(). Соседний BillingStatementPdfView ту же операцию закрывает @method_decorator(require_permission(HqPermissions.edit_billing)). Контраст указывает на упущение, а не на замысел.
И сразу контрпример, чтобы не создалось впечатления, что любой assert - находка. В zulip такой же assert не является находкой: выше по коду стоит настоящая проверка с raise JsonableError, а ассерт её дублирует
Синтетические правила
У ядра есть 426 размеченных случаев, и на нём те же правила дают полноту 100% и точность 99.5%. Эту цифру легко можно принять за оценку качества. Однако ею она не является, т.к. набор синтетических данных написан мною вручную, и каждый случай добавлен после того, как я разобрал соответствующую форму. Отсюда простое: синтетика показывает базовый детект, а правду про качество показывает поле. Ни одну из этих цифр нельзя приводить без другой.
5. Так можно или нет?
Вернусь к вопросу, с которого начал: можно ли искать IDOR не эвристиками, а через связь «идентификатор - объект - проверка»?
Можно, и это уже работает. Ядро не ищет слово permission рядом с запросом - оно проверяет формальное условие: подконтрольный атакующему идентификатор дошёл до выборки объекта, и не существует проверки, которая сравнивает этот же объект с текущим пользователем и стоит на том пути исполнения, по которому пойдёт запрос. Ровно три вещи: тот же объект, тот же путь, тот же субъект.
И это даёт результат. На 426 размеченных случаях - ни одного пропуска. На 150 живых репозиториях - 48 настоящих находок и 2.3 находки на одну дыру, то есть ревьюеру надо прочитать две-три штуки, чтобы найти одну реальную. Для сравнения, Semgrep публикует для своих Python-правил 10-29 находок на kLOC.
Где модель не вытягивает - это продуктовый код с собственным слоем прав. Дело не в том, что математическая модель неверна, а в том, что ядро читает недостаточно кода. Условие «проверки не существует» ядро проверяет только внутри обработчика, а в больших системах проверка живёт этажом выше. Модель права, входные данные неполны. Это чинится чтением.
Так что ответ на исходный вопрос: да, формальная модель работает - и ровно настолько, насколько полно она видит код.
Что дальше?
Дальше этот проект я буду допиливать, чтобы процентное соотношение было хорошим - задачи я себе поставил из разбора находок. Этот «пет-проект» нужен для моего стартап-проекта Auditmind (делаю со своим коллегой по цеху). Для тех, кто хочет потестить и указать мне на проблемы (а я их с удовольствием приму) - можете на idor.kovachvl.pro скинуть код, который у нас не ловит, или же написать мне в ТГ: @raqwee.
На этом пока всё. Если интересна тема AppSec, статического анализа и дальнейшая разработка этого ядра - я пишу об этом в ТГ-канале @kovachvl_sec, чем публикую статьи на Хабре.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.