Sicurezza
Un client FiveM è un programma in esecuzione sul computer di qualcun altro. Gli event logger e gli esecutori Lua sono strumenti comuni, liberamente accessibili, e un server che è sicuro solo finché a parlargli è la pagina ufficiale del telefono non è affatto sicuro. Ecco cosa significa questo in pratica qui, e dove risiedono le regole.
Tutto ciò che segue è stato passato al setaccio gestore per gestore (handler per handler), e ne sono emerse undici questioni. Sono elencate in fondo, non perché l’elenco in sé sia curioso, ma per la sua forma: quattro di esse erano lo stesso identico errore ripetuto in quattro punti diversi.
Poi ci siamo posti la stessa domanda nella direzione opposta — non cosa può inviare un client, ma a cosa crede il telefono rispetto a ciò che riceve — e questa è la sesta regola.
Le sei regole
Sezione intitolata “Le sei regole”1. Chi sei viene stabilito dal server, mai dal messaggio.
Phone.Handler(name, fn) chiama fn(src, data, player), dove src è la connessione e player viene ricavato da essa. Nessun gestore accetta dal client un identificatore, un numero di telefono o un personaggio per decidere quali dati toccare. Il destinatario viene risolto a partire dal numero di telefono tramite il database; il mittente è sempre chi effettua la chiamata.
L’unica volta in cui questa regola è stata violata, mic_phone:boot era stato registrato direttamente come Phone.Boot — e il secondo argomento di Phone.Boot è il record da cui costruire il telefono, mentre una callback passa al gestore qualsiasi cosa il client abbia inviato come suo secondo argomento. Chiunque poteva chiedere il telefono di chiunque altro per nome. server/tests.lua include un test che chiama la callback registrata passando un personaggio costruito ad arte e fallisce se ottiene indietro un telefono.
2. Gli eventi interni di un framework non sono porte d’ingresso.
RegisterNetEvent apre il nome di un evento alla rete per questa risorsa. ESX scatena esx:playerLoaded ed esx:playerLogout localmente, tramite TriggerEvent, e non li espone mai alla rete — quindi registrarli qui come eventi di rete faceva sì che mic_phone li accettasse da qualsiasi client, con dentro qualsiasi ID giocatore. Questo portava dritto a Phone.OnDropped: chiudere la chiamata di quel giocatore, scollegarlo dalla radio, dimenticare il suo telefono.
Usa AddEventHandler per qualsiasi evento generato internamente dal framework. Un linter ti dirà il contrario; ha torto, e dargli retta riapre la falla.
3. Nulla di ciò che invia il client viene inoltrato così com’è arrivato.
Qualsiasi dato destinato a essere salvato o mostrato a qualcun altro viene ricostruito a partire dai soli campi previsti, anziché inoltrato per intero: Nearby.Shared per ciò che un telefono passa a un altro, attachment() per gli allegati dei messaggi, la richiesta in services:request, l’elemento in citylist:save. Un campo non previsto semplicemente non sopravvive al viaggio.
4. Ogni indirizzo viene verificato rispetto agli host utilizzati da questo server.
Photos.AllowedUrl — richiede https, meno di 600 caratteri e un host presente in ServerConfig.Media.Hosts. Ogni immagine destinata a una riga del database o allo schermo di un altro giocatore passa da qui: fotocamera, condivisioni nelle vicinanze, foto degli annunci, allegati dei messaggi, post e profili social.
Senza questo controllo, un client manomesso può piazzare un indirizzo a sua scelta davanti a ogni giocatore che scorre la pagina — un ottimo modo per raccogliere gli indirizzi IP di chiunque sia sul server — oppure, dato che quelle colonne sono LONGTEXT, infilare un’intera immagine direttamente dentro il link stesso.
5. Tutto ha una dimensione massima e un limite di frequenza.
DB.Fit(value, width) | una stringa, tagliata alla larghezza della sua colonna |
DB.Json(value, limit) | una tabella codificata in JSON, oppure nulla se non entra nel limite |
DB.SetJson | 128 KB per blocco; il più grande blocco reale mai registrato è di 929 byte |
Phone.TooFast | 100 chiamate ogni 10 secondi, per giocatore |
Photos.Chunk | 3 caricamenti simultanei, 24 MB, ripuliti dopo un minuto di silenzio |
Contacts.Most | 200 |
Calls.MostRecents | 100 |
MOST_ATTACHMENTS | 8 |
Le dimensioni contano più di quanto sembri. Il database gira in strict mode, quindi un valore più lungo anche di un solo carattere rispetto alla colonna non viene troncato: l’inserimento fallisce nel bel mezzo di qualsiasi altra operazione stesse compiendo quel gestore. In un bonifico, quella riga viene eseguita dopo che i soldi sono già stati spostati.
6. Ciò che ha scritto un altro giocatore è testo, e la pagina è costruita affinché non possa mai smettere di essere testo.
Le prime cinque regole riguardano ciò che raggiunge il server. Questa riguarda ciò che raggiunge il telefono, ed è la direzione più pericolosa: la pagina che disegna il profilo di qualcun altro è la stessa pagina che può chiamare ogni singolo gestore a nome del proprietario del telefono. Un tag che riesca a essere eseguito lì dentro può leggere i messaggi di quel giocatore e spostare i suoi soldi. Non gli serve un executor: gli basta un nome.
Il telefono disegna la propria interfaccia con i template literal, quindi ogni valore viene interpretato come markup HTML a meno che qualcosa non ne faccia l’escape. Quel qualcosa è escapeHtml in core/app.js, mentre rpEsc nelle app cittadine è la stessa funzione con il nome locale. Il sistema si regge su due metà:
Fare l’escape nel punto esatto in cui si scrive. Ogni interpolazione che trasporta parole altrui — un nome, un handle, una didascalia, una nota, un quartiere, un indirizzo — passa attraverso una di quelle due funzioni. La verifica è stata fatta analizzando (parsing) tutti i template literal in html/ anziché leggendoli a occhio, perché ce ne sono 2.918.
E non dipendere solo da questo. html/index.html applica una Content-Security-Policy priva di 'unsafe-inline' in script-src. Nulla in questo telefono è uno script inline o un gestore di eventi inline — ogni script è un file — quindi la policy non costa nulla e garantisce che un attributo onerror scritto con l’inganno nella pagina semplicemente non venga eseguito. È la metà che non si affida alla memoria della prossima persona che scriverà una funzione di rendering. server/web.js aggiunge le due direttive che un tag <meta> non può esprimere: frame-ancestors 'none', perché il link a un telefono collegato via web è l’intero segreto e quel telefono può inviare denaro, e nosniff.
tests/verify-injection.js passa a ciascuna app una stringa che sarebbe inconfondibile se mai smettesse di essere semplice testo, e poi la cerca nella pagina renderizzata.
E ciò che il server invia senza che gli venga chiesto. I file in shared/config/*.lua sono shared_scripts: ogni singola riga raggiunge il client di ogni giocatore e può essere letta dal gioco con un executor. Config.Web conteneva il dominio su cui risponde il server, l’indirizzo a cui punta il codice QR e — in due punti — l’indirizzo email del proprietario del server, tutte informazioni che nessun client ha mai avuto bisogno di leggere. Ora vivono in shared/server_config.lua, che fxmanifest.lua elenca sotto server_scripts e che viene fornito con accanto un file di esempio vuoto. Un file di configurazione condiviso non è un luogo privato, e le impostazioni che descrivono questo specifico server devono restarne fuori.
Dove è facile sbagliare
Sezione intitolata “Dove è facile sbagliare”Il ramo prudente e quello subito accanto. Quattro degli undici problemi erano lo stesso identico errore: una funzione che gestiva con cura il caso che l’autore aveva in mente e lasciava passare l’altro indisturbato. data: veniva caricato mentre url passava dritto, in quattro punti diversi, ciascuno scritto in un momento diverso da qualcuno che era stato attentissimo appena tre righe più su. Se aggiungi una diramazione a uno qualsiasi di questi controlli, chiediti sempre cosa fa l’altro ramo.
Un limite generoso è pur sempre un limite. L’elenco dei contatti attende una query per ciascun contatto, e un inserimento richiede circa un terzo di secondo sul server su cui è stato scritto il codice: cinquecento contatti significavano quasi tre minuti di database bloccato da una singola richiesta. Il numero non era sbagliato perché grande, ma perché nessuno lo aveva moltiplicato per il tempo impiegato.
Le regole vivono in una sola funzione, non sparse in ogni gestore. Tutto ciò che compare nella tabella qui sopra risiede in un unico posto. È l’unico modo perché una regola sopravviva quando qualcuno aggiungerà il prossimo gestore.
Un controllo che si ferma troppo presto non è un controllo. Photos.AllowedUrl leggeva l’host e si fermava alla prima barra (/) — ma tutto ciò che viene dopo quella barra è proprio la parte scritta dal client. https:// + un host consentito + /x.jpg" onerror="… superava il controllo, e il telefono inseriva quella stringa nell’attributo src di un tag img per ogni giocatore che apriva l’annuncio. La regola era giusta, ma veniva applicata solo a un terzo dell’indirizzo.
Gli undici problemi lato server
Sezione intitolata “Gli undici problemi lato server”| Cosa permetteva di fare | Dove |
|---|---|
| Chiudere la chiamata di chiunque e scollegarlo dalla radio a piacimento | esx:playerLogout registrato come evento di rete |
| Riempire la memoria del server fino a farlo crollare | caricamenti senza tetto massimo e senza pulizia |
| Spostare denaro senza traccia, ricevendo un messaggio di errore | una nota più larga della colonna, in strict mode |
| Inserire qualsiasi indirizzo nella galleria foto di un altro giocatore | condivisioni nelle vicinanze |
| Mostrare qualsiasi indirizzo a tutti i giocatori della città | foto degli annunci |
| Mostrare qualsiasi indirizzo a tutti i partecipanti di una chat | allegati dei messaggi |
| Mostrare qualsiasi indirizzo a chiunque apra un’app social | post e profili social |
| Chiamare qualsiasi gestore alla massima velocità di invio | nessun rate limit sul canale di gioco |
| Salvare dati a volontà, ripetutamente | impostazioni, luoghi, album, preferenze |
| Aggiungere parametri arbitrari a una richiesta in uscita | time incollato in un URL senza escape |
| Leggere il telefono di qualsiasi personaggio | mic_phone:boot, risolto in precedenza |
Tutti e undici hanno ora un test dedicato in server/tests.lua, rispetto ai cinque iniziali.
Due di essi meritano una nota a parte. Le foto degli annunci e i post social passano esclusivamente attraverso Photos.AllowedUrl: quindi un test su quella singola funzione dice che la regola è corretta, ma non garantisce che quei gestori continuino a chiamarla. Il test the handlers that take a photo still ask whether it is one entra dalla porta principale, proprio come farebbe un telefono collegato, e verifica il rifiuto: togliendo il controllo, l’indirizzo malevolo viene accettato, l’annuncio viene salvato e trasmesso a ogni telefono della città, il post viene salvato e mostrato a tutti nell’app, e il profilo risponde con:
without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)che è esattamente la stringa che sarebbe finita dentro un tag img. Quando il test passa non viene scritto assolutamente nulla; il codice di pulizia lì accanto serve per quando il test fallisce, che è il caso in cui conta davvero.
Il test sugli eventi del framework è l’eccezione: non c’è modo di chiedere al runtime quali nomi di eventi una risorsa abbia aperto alla rete, quindi il test legge direttamente bridge/framework/server.lua e cerca quelle parole nel testo. È più semplice degli altri, ma c’è comunque, perché l’errore da cui protegge è uno di quelli che i linter consigliano attivamente di fare.
E i cinque nella direzione opposta
Sezione intitolata “E i cinque nella direzione opposta”| Cosa permetteva di fare | Dove |
|---|---|
| Eseguire codice sul telefono di chiunque guardasse un profilo | nome, handle, quartiere, lavoro o interessi incollati direttamente nel markup |
| Eseguire codice sul telefono di chiunque aprisse un annuncio | indirizzo foto incollato in src e un controllo sull’indirizzo che ammetteva " |
| Eseguire codice sul telefono di chi leggeva una fattura, un garage o azienda | emittenti, titoli, targhe, posizioni e dettagli aziendali incollati senza escape |
| Permettere che uno qualsiasi dei punti sopra venisse effettivamente eseguito | nessuna Content-Security-Policy sulla pagina |
| Incorporare il telefono dentro la pagina di un altro sito web | nessun header frame-ancestors sulla porta web |
I primi tre sono un errore ciascuno; gli ultimi due sono il motivo per cui i primi tre erano pericolosi. Tutti e cinque sono coperti da tests/verify-injection.js, che controlla la pagina effettivamente costruita dal browser anziché limitarsi alle stringhe in ingresso.
Entrambi i test sono stati prima osservati fallire rimuovendo la correzione: il test sugli indirizzi elenca i tre rifiuti mancanti, mentre quello sull’iniezione conta quattro elementi usciti dal proprio attributo — e non segnala alcuna esecuzione di codice, perché a quel punto era già attiva anche la Content-Security-Policy.