AWS IAM privilege escalation: почему список политик — это ещё не аудит прав
Граф достижимости: от низкодоверенного принципала до admin через PassRole и AssumeRole.
Коротко: IAM privilege escalation — это не одна ошибка конфигурации, а цепочка разрешённых по отдельности действий. Аудит, который проверяет политики построчно, а не reachability между принципалами, пропускает именно те пути, которыми реально пользуются при компрометации облака.

Проблема

Стандартный чек-лист аудита звучит как «проверить, что у пользователей нет AdministratorAccess». На практике почти никто не выдаёт AdministratorAccess напрямую — а путь до тех же прав всё равно есть, просто он собран из нескольких формально безопасных разрешений.

Известно больше двадцати задокументированных техник эскалации привилегий в AWS IAM. Самые частые в реальных инфраструктурах: iam:PassRole в сочетании с сервисом, который выполняет код от имени роли (Lambda, EC2, CloudFormation, Glue); iam:CreatePolicyVersion и iam:SetDefaultPolicyVersion, позволяющие принципалу переписать собственные эффективные права; sts:AssumeRole на роль с trust policy без ограничивающего Condition; забытые access key без ротации у сервисных аккаунтов с широкими правами.

Каждое из этих разрешений по отдельности почти всегда проходит ручной review — оно нужно для легитимной задачи (CI/CD должен уметь деплоить Lambda, сервис должен уметь ассюмить роль). Проблема появляется на пересечении: у кого есть путь от «может задеплоить функцию» до «может стать той ролью, которую задеплоил».

Решение

Аудит должен строить граф «кто может стать кем», а не таблицу «у кого какие политики привязаны». Для этого не нужно писать анализатор с нуля — AWS IAM Access Analyzer нативно ищет unintended access, а open-source инструменты вроде PMapper, Prowler и ScoutSuite целенаправленно ищут пути эскалации, а не отдельные misconfig.

Для каждого privileged-принципала фиксируется: какие роли достижимы через PassRole/AssumeRole, есть ли MFA, возраст access key, есть ли wildcard в Action или Resource. Дальше пути приоритизируются по длине от низкодоверенной точки входа (например, CI/CD service account, у которого есть только доступ на чтение репозитория) до admin-эквивалентных прав.

Что проверить руками

  • Принципалы с iam:PassRole + правом вызывать Lambda/EC2/CloudFormation/Glue.
  • Trust policy ролей без Condition (aws:SourceArn, sts:ExternalId) — кто угодно с правом assume может ей воспользоваться.
  • Wildcard Action или Resource в inline и managed policies, особенно на service-ролях.
  • MFA на IAM-пользователях с доступом в консоль и на root-аккаунте.
  • Access key старше 90 дней без ротации, особенно у сервисных аккаунтов.
  • CloudTrail: включён ли на все регионы, защищён ли лог от удаления самим же скомпрометированным принципалом.

Безопасный пример кода

Read-only аудит через boto3: ищет principals без MFA при наличии console access, старые access key и wildcard-разрешения в managed policies. Скрипт ничего не меняет и не эксплуатирует — только собирает сигналы для дальнейшего ручного review.

import boto3
from datetime import datetime, timezone

iam = boto3.client("iam")
MAX_KEY_AGE_DAYS = 90

def audit_users():
    findings = []
    for user in iam.list_users()["Users"]:
        name = user["UserName"]
        mfa = iam.list_mfa_devices(UserName=name)["MFADevices"]

        has_console_access = True
        try:
            iam.get_login_profile(UserName=name)
        except iam.exceptions.NoSuchEntityException:
            has_console_access = False

        if has_console_access and not mfa:
            findings.append((name, "console access without MFA"))

        for key in iam.list_access_keys(UserName=name)["AccessKeyMetadata"]:
            age_days = (datetime.now(timezone.utc) - key["CreateDate"]).days
            if age_days > MAX_KEY_AGE_DAYS:
                findings.append((name, f"access key {key['AccessKeyId']} is {age_days}d old"))
    return findings

def audit_wildcard_policies():
    findings = []
    for policy in iam.list_policies(Scope="Local")["Policies"]:
        doc = iam.get_policy_version(
            PolicyArn=policy["Arn"], VersionId=policy["DefaultVersionId"]
        )["PolicyVersion"]["Document"]
        for stmt in doc.get("Statement", []):
            if stmt.get("Effect") != "Allow":
                continue
            actions = stmt.get("Action", [])
            actions = [actions] if isinstance(actions, str) else actions
            if "*" in actions:
                findings.append((policy["PolicyName"], "wildcard Action in Allow statement"))
    return findings

for name, issue in audit_users() + audit_wildcard_policies():
    print(name, "-", issue)

Как это должно попасть в отчёт

  • Signals: отсутствие MFA на привилегированных принципалах, wildcard-политики, устаревшие access key, trust policy без Condition.
  • Controls: обязательный MFA для консольного доступа, автоматическая ротация ключей, least-privilege review вместо ручного, SCP-guardrails на уровне организации.
  • Retest: те же запросы после исправления не должны находить сигналы — путь от низкодоверенного принципала до admin должен быть разорван, а не просто задокументирован.
Этическая рамка: материал предназначен для defensive security, аудита собственной облачной инфраструктуры и подготовки remediation. Он не содержит готовых payload или инструкций для несанкционированного повышения привилегий в чужой среде.