Skip to content

Escalade de privilèges IAM (la chaîne en trois maillons)

En une phrase. Le compte de la CI a besoin de créer des rôles pour monter le cluster. Or qui peut créer un rôle peut se fabriquer un rôle administrateur, puis l'emprunter. On ne peut pas retirer ces droits : on les encadre.

Issue #203, sortie de #185 parce qu'elle ne se traite pas de la même façon.


1. Le constat, mesuré

Le 2026-09-05, aws iam simulate-principal-policy sur le compte fastapi-eks-gitlab-ci-infra :

iam:AttachUserPolicy   allowed     <- sur son PROPRE compte
iam:CreateUser         allowed
iam:CreateAccessKey    allowed

Il pouvait donc s'attacher AdministratorAccess en un appel d'API.

Ce que ça rend cosmétique

Tant que c'est vrai, réduire ec2:* en quarante actions nommées ne sert à rien. Une clé volée ne s'arrête pas à la liste des actions autorisées : elle s'en donne d'autres.

La moitié « utilisateur » a été fermée par !357 : les neuf actions d'écriture sur les utilisateurs IAM sont retirées, la stack ephemeral n'en déclarant aucun.

C'est la moitié « rôle » qui est difficile.


2. La chaîne, et pourquoi on ne peut pas la couper à la source

flowchart LR
    A["1. iam:CreateRole<br/>créer un rôle vide"] --> B["2. iam:AttachRolePolicy<br/>y coller AdministratorAccess"]
    B --> C["3. iam:PassRole<br/>donner ce rôle à une<br/>ressource qu'on démarre"]
    C --> D["Administrateur du compte,<br/>sans jamais toucher<br/>à sa propre politique"]

    style D fill:#ffdddd,stroke:#cc0000

Chaque droit pris seul est banal. C'est la chaîne qui ouvre la porte.

Et aucun des trois ne peut être supprimé : la stack ephemeral déclare cinq rôles IAM, dont ceux d'IRSA sur lesquels repose ESO. Sans iam:CreateRole, le montage échoue.

La différence avec les actions utilisateur

Les actions sur les utilisateurs ne servaient à rien, donc on les a retirées. Celles-ci servent. C'est toute la difficulté, et la raison d'une issue séparée : !357 réduisait une liste d'actions, ce qui est sûr. Ici on ajoute des conditions, ce qui peut casser un montage.


3. Casser un seul maillon suffit

Pas besoin de fermer les trois. Si AttachRolePolicy ne peut plus coller qu'une liste blanche, créer un rôle et le passer n'apporte plus aucun privilège.

Trois conditions ont été posées, une par maillon.

Maillon 1 — CreateRole borné par le nom

Resource = "arn:aws:iam::<compte>:role/fastapi-eks-*"

Les cinq rôles de la stack portent tous ce préfixe. La CI ne touche plus aucun autre rôle du compte.

Maillon 2 — AttachRolePolicy borné par liste blanche

Condition = {
  ArnLike = {
    "iam:PolicyARN" = [
      "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy",
      "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
      "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
      "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
      "arn:aws:iam::<compte>:policy/fastapi-eks-*"
    ]
  }
}

iam:PolicyARN est la seule clé de condition acceptée par AttachRolePolicy. C'est pour ça que ces deux actions sont dans un bloc à part : les autres actions de rôle ne la comprennent pas.

AdministratorAccess n'est pas dans la liste. Il devient impossible à coller.

Maillon 3 — PassRole borné par service destinataire

Condition = {
  StringEquals = {
    "iam:PassedToService" = [
      "eks.amazonaws.com",
      "ec2.amazonaws.com",
      "vpc-flow-logs.amazonaws.com"
    ]
  }
}

Un rôle du projet ne peut plus être donné à n'importe quel service : ni à une Lambda, ni à un job Glue.


4. Avant / après, sur le scénario d'attaque

flowchart TB
    subgraph AV["AVANT"]
        A1["clé CI volée"] --> A2["CreateRole<br/>n'importe quel nom"]
        A2 --> A3["AttachRolePolicy<br/>AdministratorAccess"]
        A3 --> A4["PassRole<br/>vers n'importe quoi"]
        A4 --> A5["admin du compte"]
    end

    subgraph AP["APRÈS"]
        B1["clé CI volée"] --> B2["CreateRole<br/>seulement fastapi-eks-*"]
        B2 --> B3["AttachRolePolicy<br/>REFUSÉ hors liste blanche"]
        B3 --> B4["chaîne rompue"]
    end

    style A5 fill:#ffdddd,stroke:#cc0000
    style B3 fill:#d4edda,stroke:#155724
    style B4 fill:#d4edda,stroke:#155724

5. Le trou qui reste, écrit pour que personne ne le croie fermé

Une policy inline est un jeu de droits écrit directement dans le rôle, au lieu d'être une policy nommée référencée par son ARN.

iam:CreateRole plus iam:PutRolePolicy permettent toujours de fabriquer un rôle à policy inline large. Aucune condition sur un ARN de policy ne mord là : le document n'a pas d'ARN.

flowchart LR
    X["CreateRole<br/>fastapi-eks-quelquechose"] --> Y["PutRolePolicy<br/>policy INLINE : tout autoriser"]
    Y --> Z["le rôle a tous les droits<br/>sans qu'aucun ARN<br/>n'ait été attaché"]

    style Z fill:#fff3cd,stroke:#856404

La seule parade est une permissions boundary : un plafond de droits imposé au rôle à sa création, par une condition iam:PermissionsBoundary sur CreateRole.

Elle n'est pas encore posée, et ce n'est pas un oubli. Elle exige d'ajouter permissions_boundary sur les cinq aws_iam_role de la stack ephemeral, avec un ARN construit par convention puisqu'il n'existe aucun terraform_remote_state dans ce dépôt (ADR 033). Un ARN faux, et tous les CreateRole échouent. Ça mérite son propre cycle de validation.

Ce que #193 apporte en attendant

Un rôle fabriqué par ce chemin aurait secretsmanager:GetSecretValue. Mais depuis l'extension de la politique de ressource aux sept secrets, un principal non nommé est refusé au niveau du secret. Les deux barrières se complètent : voir Politique de ressource sur les secrets.


6. Le piège de la clé iam:PassedToService

C'est le point le plus subtil de cette issue, et il vaut au-delà d'elle.

On ne peut pas observer cette clé avant l'appel. Trois valeurs s'en approchent, aucune n'est elle :

Source Ce que c'est Exemple mesuré le 2026-09-06
CloudTrail eventSource le service appelé eks.amazonaws.com pour CreateNodegroup
Trust policy Principal.Service le service qui assume ec2.amazonaws.com pour eks-nodes-role
iam:PassedToService ni l'un ni l'autre renseigné par l'API, et pas par toutes

Les trois coïncident souvent. Pas toujours. Sur eks-nodes-role, les deux premières divergent déjà.

La liste des trois services est donc une hypothèse, exactement comme ec2:TerminateInstances dans INC-076. Elle est écrite comme telle dans le code.

Pourquoi pas StringEqualsIfExists

Il existe un opérateur qui laisse passer quand la clé est absente. Il n'a pas été retenu, délibérément.

StringEquals          clé absente -> REFUS      -> le montage casse, on le VOIT
StringEqualsIfExists  clé absente -> autorisé   -> la condition existe et ne mord pas

Le second transforme un risque de casse en risque de fausse sécurité : une condition posée, visible dans le code, lue comme une protection, et inopérante sur le chemin qui compte.

Le principe

Un garde-fou muet est pire qu'un garde-fou absent. Mieux vaut échouer bruyamment que protéger silencieusement.


7. Vérifier

La seule validation possible est un montage complet, et rien d'autre.

Ni simulate-principal-policy, qui évalue sur un moteur distinct de celui de l'API réelle. Ni une relecture de CloudTrail, qui ne montre que ce qui a déjà été appelé sous l'ancienne politique.

C'est pour ça que le done-when de #203 dit « un montage complet passe avec les conditions en place », et c'est le protocole joué le 2026-09-06 :

1. apply de la condition sur terraform/persistent
2. relire la policy côté AWS (get-policy-version), pas le succès de l'apply
3. teardown complet
4. REMONTAGE            <- le test
5. le cluster remonte, ou la condition est fausse et on le sait tout de suite

L'étape 5 est le point : jouer le cycle le jour où on écrit la condition, plutôt que de léguer un montage cassé à la session suivante.


Voir aussi

  • Politique de ressource sur les secrets — la barrière complémentaire, au niveau du secret
  • docs/incidents/sprints/sprint7.md — INC-076, le montage cassé par une politique réduite sur un motif faux
  • ADR 034 — Terraform ne fabrique plus les clés d'accès de la CI, ce qui rend les actions iam:*AccessKey* retirables