Всем привет. Пару месяцев ломал себе голову, почему никто в коде не ищет IDOR‑уязвимости. Ответы получал разные: что в большинстве случаев мы будем получать FP/FN в коде, да и зачем их искать, если у нас есть DAST? Но базовый DAST может не находить, если приложение написано на жёстком SPA или же у него фиговый краулер. И несколько дней назад дописал модуль (или ядро) для детекта уязвимости IDOR для ЯП Python.

Главный вопрос, на который я хотел ответить этой работой, звучит так:

Можно ли искать IDOR статическим анализом не эвристиками, а через анализ связи между 
пользовательским идентификатором, объектом и проверкой авторизации?

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

Содержание

  1. Что такое IDOR и примеры уязвимого кода на Python

  2. Ресерч на эту тему, какие есть решения и тесты

  3. Как вообще написан модуль и почему его можно использовать как базовый Taint‑модуль

  4. Замер на продуктах

  5. Выводы

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

поток данных

находит путь source -> sink, но не понимает, относится ли проверка к тому же объекту

Для IDOR недостаточно найти путь source -> sink. Нужно понять, относится ли проверка авторизации именно к тому объекту, который выбрал атакующий, и стоит ли она на том пути исполнения, по которому пойдёт запрос.

3. Как вообще написан модуль и почему его можно использовать как базовый Taint‑модуль

Ну давайте начнём с того, что модуль делает? По сути читает исходник Python‑приложения и отвечает на один вопрос про каждый HTTP‑обработчик: «Какие данные в нём пришли от пользователя, куда они утекли и что успело их проверить по дороге».

Модуль разрезан на три части, которые общаются через данные, а не через вызовы друг друга.

Слой первый. Ядро разбирает файлы проекта и находит, где вообще начинается обработка запроса. На самом деле это не так просто, как казалось. Обработчик может быть функцией с декоратором маршрута, методом класса‑вьюхи, методом вьюсета, функцией внутри фабрики blueprint'ов или вообще ничем не помеченной функцией, на которую ссылается urls.py. Сейчас поддержаны Django (и функции, и классы), DRF, Flask и FastAPI.

Слой второй. Для каждого обработчика собирается протокол: какие значения пришли из запроса, какие обращения к данным произошли, какие проверки выполнились и в какой ветке кода, кто такой «текущий пользователь» и был ли он вообще установлен.

Слой третий. Читает факты и выносит вердикт. Сейчас 44 гипотезы (что может быть не так) и 23 подавителя (почему на самом деле всё в порядке).

Как помечаются данные?

Модуль различает пять видов происхождения значения. Кратко систему я назвал SIAOD.

Метка

Что это

Пример

SUBJECT

текущий пользователь, установленный аутентификацией

request.user

INPUT

что‑то, пришедшее от клиента

тело запроса, заголовок

ATTACKER_SELECTED

идентификатор, которым клиент выбирает конкретный объект

/orders/<id>

OBJECT

то, что достали из хранилища

запись заказа

DYNAMIC

значение, происхождение которого статически не определить

getattr(model, name)

Самое неочевидное здесь — зачем 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 "", 201

text — это 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, чем публикую статьи на Хабре.