ESPN DeportesNecaxa anuncia salida de DT Martín VariniThe Jerusalem PostUS Rep. Tom Tiffany says he sustained minor injuries after plane makes emergency landingESPNNFL Week 1's fashionable arrivals, featuring Joe Burrow and Lamar JacksonRTP DesportoFC Barcelona continua imparável e soma quinta vitória na Liga espanholaDaily MaverickANALYSIS: Where is Paul Mashatile, and does the vanishing deputy act threaten his presidential ambitions?וואלהבמהלך מתואם: מנהיגי סקוטלנד, ויילס וצפון אירלנד ידרשו להתפרק מבריטניהХабрМир — пугающе странныйInquirer EntertainmentLove Knots, September 14, 2026Premium TimesNigeria’s fastest man Kanyinsola Ajayi reflects on breakthrough 2026 seasonSportstarMan United vs Man City LIVE — Premier League updates: Haaland scores to put 10-man City aheadColliderNearly 60 Years Later, One ‘Peanuts’ Character Feels More Relevant Than EverGMA NewsRussia hits passenger train on Ukraine's border with Poland, just misses 'diplomatic train'
The Daily Newsstand · Free, Always
Sunday, September 13, 2026

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

Translate

Всем привет. Пару месяцев ломал себе голову, почему никто в коде не ищет 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, чем публикую статьи на Хабре.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.