Сломанная аутентификация API и BFLA
Разбираем, как ломают вход в API и как обычный пользователь дотягивается до админских функций (BFLA), и как закрыть это проверкой роли на каждый эндпоинт — на стенде.
BFLA (Broken Function Level Authorization) — уязвимость, при которой API не проверяет, что у пользователя есть право вызывать саму функцию (например, удаление пользователей или смену роли), и обычная учётная запись получает доступ к привилегированным операциям.
Если BOLA — это «доступ к чужому объекту», то BFLA — это «доступ к запретной функции». Рядом с ней почти всегда идёт сломанная аутентификация: слабые токены, бесконечные сессии, угадываемые коды. Изучаем это на стенде (crAPI, juice-shop, своё демо в ВМ), чтобы научиться защищать вход и разграничивать права. Подбор чужих учёток и доступ к админке реального сервиса — это статьи 272 и 273 УК РФ.
Зачем это знать защитнику
Аутентификация — это «кто ты», авторизация — «что тебе можно». Сломать можно и то, и другое, и обе ошибки часто соседствуют в одном API. Понимание этих категорий нужно, чтобы строить вход, который нельзя обойти, и разграничение прав, которое работает на каждом эндпоинте, а не «в среднем по сервису». Для защитника тут важна одна мысль: токен лишь подтверждает личность, но сам по себе не говорит, что этой личности можно делать. Эти два вопроса нужно решать раздельно и оба — на сервере.
Сломанная аутентификация: где обычно слабое звено
Типичные дефекты входа в API: пароли без ограничений на сложность и без защиты от перебора; коды подтверждения (OTP) из 4 цифр без лимита попыток — их перебирают за минуты; JWT, подписанный слабым или зашитым в код секретом; токены без срока жизни; refresh-токены, которые нельзя отозвать. Отдельная классическая ошибка — приём заголовка alg: none в JWT, когда сервер соглашается на «токен без подписи». Уязвимая проверка токена может выглядеть так:
def verify(token):
# ОПАСНО: доверяем полю alg из самого токена и принимаем 'none'
header = decode_header(token)
if header["alg"] == "none":
return decode_payload(token) # подпись не проверяется вовсе
return jwt.decode(token, SECRET, algorithms=[header["alg"]])
Здесь злоумышленник на стенде подменяет полезную нагрузку токена, ставит alg: none — и сервер принимает его как валидный. Корень тот же, что и везде: сервер доверяет данным, пришедшим от клиента.
BFLA: функция без проверки роли
Вторая беда — отсутствие проверки прав на саму операцию. Часто администраторские эндпоинты просто «не показаны» в обычном интерфейсе, но физически доступны. Уязвимый код раздаёт админскую функцию любому, кто прислал валидный токен:
@app.post("/api/admin/users/{uid}/role")
def set_role(uid: int, body: RoleIn, user = Depends(current_user)):
# есть current_user (аутентификация есть),
# но НЕТ проверки, что это администратор
db.users.update(uid, role=body.role)
return {"status": "ok"}
Исследователь на стенде логинится обычным пользователем, копирует из документации или из трафика путь /api/admin/users/{uid}/role и выставляет себе роль admin. Пример того же класса — смена HTTP-метода: эндпоинт показывает данные по GET, но тот же путь принимает DELETE без отдельной проверки прав. Сюда же относится подмена идентификатора роли или организации в теле запроса: если сервер берёт «целевого пользователя» или «целевую организацию» из запроса и не сверяет, входит ли вызывающий в эту область видимости, обычная учётка управляет чужими ресурсами. Общая черта всех этих случаев — проверка прав либо отсутствует, либо выполнена один раз «на входе» и не повторяется для конкретной операции.
Как это работает под капотом
Ключевая ошибка мышления — «security through obscurity»: раз кнопки нет в UI и путь нигде не написан, его «никто не найдёт». Но пути перечисляют по документации (Swagger/OpenAPI), по JS-бандлу фронтенда, по логам и простым перебором. Авторизация, которой нет на сервере, не появится оттого, что функция спрятана. Право на операцию должно проверяться на сервере при каждом вызове, отдельно от факта аутентификации.
Как защититься
1. Проверяйте роль на каждый привилегированный эндпоинт. Сделайте это явной зависимостью/декоратором, а не строчкой, которую легко забыть:
def require_role(role):
def checker(user = Depends(current_user)):
if user.role != role:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return checker
@app.post("/api/admin/users/{uid}/role",
dependencies=[Depends(require_role("admin"))])
def set_role(uid: int, body: RoleIn):
db.users.update(uid, role=body.role)
return {"status": "ok"}
2. Закрывайте по умолчанию (deny by default). Лучшая архитектура — когда новый эндпоинт без явного разрешения недоступен никому, а не открыт всем. Тогда забытая проверка не превращается в дыру: разработчик обязан осознанно открыть доступ.
3. Чините аутентификацию. Проверяйте JWT строго одним ожидаемым алгоритмом, не доверяя полю alg из токена; none запрещён всегда:
def verify(token):
# алгоритм задаём МЫ, а не клиент; 'none' невозможен
return jwt.decode(token, SECRET, algorithms=["HS256"])
Давайте access-токену короткий срок жизни, держите отзываемый список refresh-токенов, ограничивайте попытки входа и OTP (лимит + блокировка), храните пароли в виде современного хеша (bcrypt/argon2). Включите многофакторную аутентификацию для привилегированных учёток.
4. Обнаружение. Логируйте отказы авторизации (403) на админских путях и всплески неудачных входов. Серия 403 по разным /api/admin/... от обычной учётки — явный признак прощупывания BFLA; всплеск 401 с разными OTP — перебор кода.
Итоги
- Аутентификация («кто ты») и авторизация («что можно») ломаются по-разному и обычно соседствуют в одном API.
- BFLA — доступ к привилегированной функции без проверки роли; спрятать эндпоинт ≠ защитить его.
- Проверяйте роль на каждый чувствительный эндпоинт явно (декоратор/зависимость) и закрывайте доступ по умолчанию.
- Аутентификация: фиксированный алгоритм JWT (никакого
alg: none), короткий TTL, отзыв refresh, лимиты на вход и OTP, MFA для админов. - Логируйте отказы и всплески неудачных входов. Практика — только на стенде и своих системах (ст. 272/273 УК РФ).