Plugin4Shell: een gepinde plugin is pas gepind als iets controleert wat er echt is uitgecheckt
7m leestijd

Plugin4Shell: een gepinde plugin is pas gepind als iets controleert wat er echt is uitgecheckt

Air liet zien dat Claude Code, Codex, Copilot en Gemini CLI git om de commit vroegen die een marketplace had gepind, zonder na te gaan of ze die ook echt kregen. Ik bouwde het na in git, zag Claude Code het weigeren en vond hetzelfde gat in mijn eigen CI-runner.

Je reviewt een plugin, je pint hem op precies de commit die je hebt gelezen, en vanaf dat moment draait die commit. Dat belooft SHA-pinning je, en het is een goede belofte.

Op 17 september lieten Or Nevo, Dor Granat en Niv Hoffman van Air zien dat Claude Code, Codex, GitHub Copilot en Gemini CLI zich op papier aan de pin hielden. Ze vroegen git om de gepinde commit, en geen van de vier controleerde wat die checkout werkelijk opleverde.

Air noemt het Plugin4Shell: zero-click remote code execution via een plugin die je al vertrouwde.

Eén gepinde hash, twee uitkomsten: rev-parse vindt de commit, checkout pakt de branch met dezelfde naam

Waarom pinnen het goede idee was

Pinnen is het antwoord op de rug pull. Een plugin komt door de review, mensen installeren hem, en later vervangt de auteur, of wie zijn repository heeft overgenomen, hem door iets anders. Een versietag kun je verplaatsen. Een branch schuift per definitie mee. Een commit-hash van 40 tekens wijst voor altijd naar één commit en precies die verzameling bestanden.

Daarom pinnen marketplaces. De officiële marketplace van Anthropic pint zijn git-bronnen op een commit, en ik heb je eerder aangeraden om de versie te pinnen van alles wat je aan een agent geeft. Het idee klopt. Het ging mis bij de stap erna, en wel bij alle vier.

De regel die ontbrak

Een agent installeert een gepinde plugin ongeveer zo:

bash
git clone <plugin repo> .
git checkout <pinned-sha>

De aanvaller beheert de repository van de plugin. Hij maakt een branch waarvan de naam de gepinde hash is, laat die naar zijn eigen code wijzen en stelt hem in als default branch, zodat de clone hem meeneemt. Nu heeft git checkout <pinned-sha> twee kandidaten met dezelfde naam: de commit en de branch. Git kiest de branch.

Ik bouwde dat na in een scratch-repository met git 2.43, om het met eigen ogen te zien:

text
$ git checkout 30db431387e58014d7bcdf4b9d3af73414d25921
warning: refname '30db431387e58014d7bcdf4b9d3af73414d25921' is ambiguous.
Git normally never creates a ref that ends with 40 hex characters
because it will be ignored when you just specify 40-hex. [...]
Already on '30db431387e58014d7bcdf4b9d3af73414d25921'

$ git rev-parse HEAD
57ec4c8f51b04d7d33ec7e422a3953381c83171c

$ cat run.sh
echo "attacker code"

Lees die waarschuwing nog eens. Git zegt dat zo'n ref "will be ignored when you just specify 40-hex", en checkt direct daarna precies die ref uit.

Voor één commando klopt de waarschuwing wel. git rev-parse 30db431… geeft de commit terug, omdat rev-parse het object voorrang geeft. git checkout geeft de branch voorrang. De controle waar je als eerste aan denkt, de pin opzoeken vóór de checkout, slaagt dus altijd en bewijst niets. Alleen een controle achteraf vangt het, omdat die vraagt wat er werkelijk is uitgecheckt:

bash
test "$(git rev-parse HEAD)" = "$PINNED_SHA" || abort

Die regel is de fix van Air, en het ontbreken ervan is de hele bug. De variant in Gemini CLI komt via een andere route op hetzelfde uit: die haalt de pin op met een fetch en draait daarna git checkout FETCH_HEAD, en ook daar wint een default branch die FETCH_HEAD heet. Die heb ik ook nagebouwd.

Er zijn twee voorwaarden. De branch met de hash als naam moet de default zijn, anders haalt een gewone clone hem alleen binnen als remote-tracking ref en valt de checkout terug op de commit. En de host moet een branch met zo'n naam accepteren. GitHub weigert zulke namen, liet een woordvoerder weten aan The Register. Volgens Air accepteren Bitbucket en self-hosted git-servers ze wel, en dat zijn ondersteunde backends voor een marketplace.

Door auto-update wordt het zero-click. De aanvaller brengt een gewone, onschuldige update uit, de marketplace verzet de pin naar die nieuwe commit, en pas daarna maakt de aanvaller een branch met die hash als naam. Claude Code en Codex werken geïnstalleerde plugins standaard op de achtergrond bij, dus de verwisselde code komt vanzelf binnen bij iedereen die de plugin al heeft, zonder dat iemand iets installeert.

Wie het repareerde

Air vond het in mei en meldde het in juni bij alle vier de leveranciers.

  • Claude Code repareerde het in 2.1.179. Anthropic bevestigde de fix op 17 juni aan Air.
  • Codex repareerde het in 0.146.0.
  • Gemini CLI krijgt geen fix. Google heeft hem uitgefaseerd en verwijst gebruikers naar Antigravity.
  • GitHub Copilot heeft geen fix. Air vertelde The Register nooit iets van Microsoft te hebben gehoord, en ook The Register kreeg niet meteen antwoord.

Ik testte de fix in Claude Code 2.1.276, met een afgeschermde config-map en een marketplace-entry die gepind was op een commit in een lokale repository waarvan de default branch de hash als naam had:

text
✘ Failed to install plugin "demo@gitpin": SHA pin verification failed:
expected HEAD to be ae578a9f72594f3b82f572671b6fa45ac3044476,
got 5f056ba526ee39cc352f122706bcf6105cb8a592. The pinned commit may
have been removed upstream, or a ref with the same name exists.
Refusing to install.

Met dezelfde pin uit een schone kopie van de repository ging de installatie gewoon door. Zo hoort de controle te werken, en hij stelt dezelfde vraag als de fix van één regel die Air voorstelde.

Controleer wat er binnenkomt

Claude Code accepteert plugins ook als zip-archief, gepind met een sha256. Dat ontwerp had dit probleem nooit, want die hash gaat over de bytes die je werkelijk binnenkreeg, en daar kan een branch-naam niets aan veranderen. Ik liet een afgeschermde config een echt archief installeren met een verkeerde hash:

text
✘ Failed to install plugin "decompose@pintest-bad": Plugin archive
integrity check failed [...]: expected sha256 0000[...]0000,
got 9c648375659df3ba5003c07425e728d2cab77f522311af7a604825ce53d993d3.
The archive was not installed.

Beide controles stellen nu dezelfde vraag: wat is er binnengekomen? Alles wat je pint, plugin of niet, is maar zo veilig als het antwoord op die vraag.

Het zat ook in mijn CI

Ik draai mijn eigen Forgejo met een eigen runner, en mijn workflows pinnen elke action op een commit-hash. Daarom bouwde ik de aanval na op die opzet, lokaal, met dezelfde Forgejo-versie die ik in productie draai.

Forgejo 16.0.4 accepteert een branch met een hash als naam en laat je die als default instellen. Via de API kostte dat twee pogingen: de eerste liet de HEAD van de repository op main staan, de tweede verzette hem wel. Dat is een eigenaardigheid, en als bescherming moet je er niet op rekenen. Toen die branch eenmaal bestond, deed een workflow-stap uses: <action>@<pinned-sha> dit:

runnerresultaat
Gitea act_runner 0.3.1draaide de action van de aanvaller, job geslaagd, geen waarschuwing
forgejo-runner 13.1.0weigerde: "an ambiguous git reference", job mislukt
beide, zonder de hash-branchdraaiden de gereviewde action

De eerste rij was mijn productie-runner. Ik had de Forgejo-server bijgewerkt en nam aan dat de runner was meegegaan. Dat was niet zo: de runner is een apart project met een eigen versienummer, en stond nog op 0.3.1. Sinds vandaag draait productie op forgejo-runner 13.1.0.

Niemand heeft me aangevallen. Daarvoor moet je eerst de repositories in handen krijgen waar die actions vandaan komen. Maar mijn pins zouden het niet hebben tegengehouden, en dat wist ik pas toen ik het testte.

Een niveau hoger ontbraken sommige pins helemaal. Mijn eigen plugin-marketplace pinde de skill-archieven met een sha256, maar de plugins met een eigen GitHub-repository hadden geen enkele pin, dus elke update volgde wat de default branch op dat moment aanwees. Die staan nu gepind op hun release-commits. Toen ik controleerde of de update goed was doorgekomen, vond ik nog iets om te wantrouwen: Claude Code werkte de gitCommitSha die het per plugin bijhoudt na de update niet bij, dus dat veld noemde nog de oude commit. Daarom heb ik de geïnstalleerde bestanden zelf vergeleken met de gepinde commits.

Wat je doet

  • Werk je agent bij. Claude Code 2.1.179 of later, Codex 0.146.0 of later. Voor Copilot bestaat nog geen versie met de fix. Voor Gemini CLI komt die er nooit.
  • Weet welke plugins überhaupt gepind zijn. Een git-bron zonder pin volgt bij elke update de default branch. Dat is precies de rug pull die pinnen moet voorkomen, en daar komt geen slimme truc aan te pas.
  • Controleer je runner apart van je forge. Het zijn aparte installaties met aparte versies. forgejo-runner 13.1.0 weigert de branch met de hash-naam. De Gitea act_runner 0.3.1 die ik had, deed dat niet.
  • Controleer in je eigen scripts ná de checkout. Gebruik git checkout --detach "$SHA^{commit}" en vergelijk daarna git rev-parse HEAD met de pin. Het verschil zit in ^{commit}: met alleen --detach pakt git nog steeds de branch, en dat gebeurt ook als je de uitvoer van rev-parse weer aan checkout geeft. Een controle vóór de checkout is de controle die altijd slaagt.
  • Pin liever de hash van de bytes. Waar je het formaat zelf bepaalt, pin je de hash van het bestand dat je downloadt.

Het pinnen zelf hield stand. Om een commit vragen en hem krijgen zijn twee stappen, en vier agents stopten na de eerste. De fix is één regel. Er waren drie onderzoekers en een branch met een hash als naam voor nodig voordat hij er kwam, en twee van de vier agents hebben hem nog steeds niet.

(8 van 8)