Zum Inhalt springen

Sicherheit

Ein FiveM-Client ist ein Programm auf dem Rechner einer fremden Person. Event-Logger und Lua-Executoren sind alltägliche, frei verfügbare Werkzeuge, und ein Server, der nur so lange sicher ist, wie die offizielle Weboberfläche des Telefons mit ihm spricht, ist nicht sicher. Hier wird erläutert, was das in der Praxis bedeutet und wo die Regeln dafür verankert sind.

Jeder untenstehende Aspekt wurde Handler für Handler durchleuchtet, woraus elf Schwachstellen hervorgingen. Sie sind am Ende aufgeführt — nicht weil die Liste an sich spannend wäre, sondern wegen ihres Musters: Vier davon beruhten auf demselben Denkfehler an vier verschiedenen Stellen.

Anschließend wurde dieselbe Frage umgekehrt gestellt — nicht was ein Client senden kann, sondern was das Telefon über das glaubt, was ihm gesendet wird —, und das ist Gegenstand von Regel sechs.


1. Wer du bist, bestimmt der Server, niemals die Nachricht.

Phone.Handler(name, fn) ruft fn(src, data, player) auf, wobei src die Verbindung ist und player daraus ermittelt wird. Kein Handler übernimmt einen Identifikator, eine Rufnummer oder einen Charakter vom Client, um zu entscheiden, wessen Daten manipuliert werden. Ein Empfänger wird über die Rufnummer in der Datenbank aufgelöst; ein Absender ist grundsätzlich der Anrufer.

Das einzige Mal, als diese Regel verletzt wurde, war mic_phone:boot direkt als Phone.Boot registriert — und das zweite Argument von Phone.Boot ist der Datensatz zum Aufbau eines Telefons, während ein Callback dem Handler das übergibt, was der Client als sein zweites Argument gesendet hat. Jeder konnte jedes beliebige Telefon nach Namen anfordern. server/tests.lua enthält einen Test, der den registrierten Callback mit einem manipulierten Charakter aufruft und fehlschlägt, falls ein Telefon zurückgeliefert wird.

2. Framework-eigene Events sind keine offenen Türen.

RegisterNetEvent öffnet einen Event-Namen für diese Ressource für das gesamte Netzwerk. ESX feuert esx:playerLoaded und esx:playerLogout lokal mittels TriggerEvent und öffnet sie niemals — die Registrierung als Net-Events verleitete mic_phone daher dazu, sie von jedem Client mit beliebiger Player-ID entgegenzunehmen. Das führte schnurstracks in Phone.OnDropped: das Gespräch des Spielers beenden, ihn aus dem Funk werfen, sein Telefon vergessen.

Nutze AddEventHandler für alles, was ein Framework selbst auslöst. Ein Linter wird oft das Gegenteil behaupten; er liegt falsch, und seinem Rat zu folgen, reißt die Sicherheitslücke wieder auf.

3. Nichts, was der Client sendet, wird eins zu eins durchgereicht.

Alles, was gespeichert oder anderen Spielern angezeigt wird, wird aus bekannten Feldern rekonstruiert statt als Ganzes weitergeleitet: Nearby.Shared für Daten zwischen zwei Telefonen, attachment() für Nachrichtenanhänge, die Anfrage in services:request, der Artikel in citylist:save. Ein nicht explizit definiertes Feld übersteht den Transfer nicht.

4. Eine Webadresse wird gegen die erlaubten Hosts dieses Servers geprüft.

Photos.AllowedUrl — HTTPS, unter 600 Zeichen und ein Host aus ServerConfig.Media.Hosts. Jedes Bild, das eine Datenbankzeile oder den Bildschirm eines anderen Spielers erreicht, durchläuft diese Prüfung: Kamera, Nearby-Shares, Marktplatz-Fotos, Nachrichtenanhänge, Social-Media-Beiträge und Profile.

Ohne diese Barriere könnte ein manipulierter Client jedem vorbeiscrollenden Spieler eine beliebige Adresse unterschieben (ein Weg, um IP-Adressen aller Serverteilnehmer abzugreifen) oder — da diese Spalten vom Typ LONGTEXT sind — ein gesamtes Bild direkt in den Link einbetten.

5. Alles hat ein Größenlimit und eine Ratenbegrenzung.

DB.Fit(value, width)ein String, passgenau auf die Spaltenbreite gekürzt
DB.Json(value, limit)eine Tabelle, JSON-kodiert, oder nichts, falls zu groß
DB.SetJson128 KB pro Blob; der größte in der Praxis beobachtete war 929 Bytes
Phone.TooFast100 Aufrufe pro 10 Sekunden pro Spieler
Photos.Chunk3 aktive Uploads, 24 MB, bereinigt nach einer Minute Stille
Contacts.Most200
Calls.MostRecents100
MOST_ATTACHMENTS8

Die Größenlimits sind kritischer, als es den Anschein hat. Die Datenbank arbeitet im Strict Mode, sodass ein um ein einziges Zeichen zu langer Wert nicht abgeschnitten wird: Das INSERT schlägt mitten in der Bearbeitung des Handlers fehl. Bei einer Überweisung erfolgt dieser Schreibvorgang erst, nachdem das Geld bereits bewegt wurde.

6. Was ein anderer Spieler geschrieben hat, ist reiner Text — und die Seite verhindert aktiv, dass daraus Markup wird.

Die fünf vorherigen Regeln betreffen Daten, die den Server erreichen. Diese hier betrifft Daten, die das Telefon erreichen, und das ist die gefährlichere Richtung: Die Seite, die das Profil eines anderen Spielers rendert, ist dieselbe Seite, die jeden Handler als dessen Eigentümer aufrufen kann. Ein dort ausgeführtes HTML-Tag könnte die privaten Nachrichten mitlesen und das Bankkonto leerräumen. Dazu braucht es keinen Executor — ein Name genügt.

Das Telefon rendert mit Template-Literals, weshalb jeder injizierte Wert potenzielles Markup ist, sofern er nicht escaped wird. escapeHtml in core/app.js übernimmt diese Aufgabe, und rpEsc in den Stadt-Apps ist dieselbe Funktion unter lokalem Namen. Das Prinzip steht auf zwei Beinen:

Am Punkt des Einfügens escapen. Jede Interpolation mit Wörtern von Spielern — Name, Handle, Bildunterschrift, Notiz, Viertel, Adresse — durchläuft eine dieser beiden Funktionen. Dies wurde durch statisches Parsen aller Template-Literals in html/ verifiziert statt durch manuelles Lesen, da es 2.918 Stück sind.

Und sich nicht allein darauf verlassen. html/index.html erzwingt eine Content-Security-Policy ohne 'unsafe-inline' in script-src. Nichts in diesem Telefon ist ein Inline-Skript oder ein Inline-Handler — jedes Skript liegt als Datei vor —, wodurch die Policy keine Performance kostet und sicherstellt, dass ein eingeschleustes onerror schlichtweg nicht ausgeführt wird. Das ist der doppelter Boden, der nicht davon abhängt, dass der nächste Entwickler einer Render-Funktion an alles gedacht hat. server/web.js ergänzt zwei Header, die ein <meta>-Tag nicht setzen kann: frame-ancestors 'none' (da die URL zu einem verknüpften Telefon das gesamte Geheimnis darstellt und Geld überweisen kann) sowie nosniff.

tests/verify-injection.js übergibt jeder App einen Test-String, der unmissverständlich auffällt, falls er jemals aufhört, reiner Text zu sein, und durchsucht die gerenderte Seite danach.

Was der Server unaufgefordert versendet. Dateien in shared/config/*.lua sind shared_scripts: Jede Zeile landet auf dem Rechner jedes Spielers und lässt sich im Spiel mit einem Executor auslesen. Config.Web enthielt früher die Domain des Servers, die Zieladresse des QR-Codes und — gleich zweimal — die E-Mail-Adresse des Serverbesitzers, von denen ein Client keine einzige jemals benötigte. Sie liegen nun in shared/server_config.lua, das fxmanifest.lua unter server_scripts führt und dem eine leere Beispieldatei beiliegt. Eine Konfigurationsdatei ist kein privater Raum, und serverspezifische Geheimnisse gehören dort nicht hinein.


Der sorgfältige Zweig und sein Nachbar. Vier der elf Schwachstellen basierten auf demselben Versehen: Eine Funktion behandelte den erwarteten Fall penibel und ließ den Alternativfall ungeprüft passieren. data: wurde hochgeladen und url nicht, an vier verschiedenen Stellen, zu unterschiedlichen Zeiten geschrieben von jemandem, der drei Zeilen darüber noch extrem vorsichtig war. Wenn du eine Verzweigung ergänzt, frage dich immer, was der andere Zweig tut.

Ein großzügiges Limit ist immer noch ein Limit. Das Laden von Kontakten wartet auf eine Abfrage pro Kontakt, und ein Insert benötigte auf dem Entwicklungs-Server etwa ein Drittel einer Sekunde: Fünfhundert Kontakte blockierten die Datenbank fast drei Minuten lang mit einem einzigen Request. Die Zahl war nicht falsch, weil sie groß war — sie war falsch, weil niemand sie hochgerechnet hatte.

Regeln gehören in eine zentrale Funktion, nicht in jeden einzelnen Handler. Alles in der obigen Tabelle ist an einem einzigen Ort gebündelt. Nur so überlebt eine Regel das Hinzufügen eines neuen Handlers durch Dritte.

Eine Prüfung, die vorzeitig abbricht, ist keine Prüfung. Photos.AllowedUrl las den Host aus und stoppte beim ersten Schrägstrich — und alles hinter diesem Slash ist der Teil, den der Client kontrolliert. https:// + erlaubter Host + /x.jpg" onerror="… passierte die Prüfung, und das Telefon setzte diesen String in das src-Attribut eines img-Tags für jeden Spieler, der das Inserat öffnete. Die Regel an sich stimmte; sie prüfte nur leider lediglich ein Drittel der URL.


Was jemand tun konnteWo
Das Telefonat jedes Spielers beenden und Funk kappenesx:playerLogout als Net-Event registriert
Den Arbeitsspeicher des Servers bis zum Absturz füllenUploads ohne Obergrenze und ohne Aufräumer
Geld spurlos transferieren und Fehler gemeldet bekommenNotiz länger als Tabellenspalte im Strict Mode
Beliebige Adressen in die Fotogalerie anderer einschleusenNearby-Shares
Beliebige Adressen allen Spielern der Stadt anzeigenInserat-Fotos
Beliebige Adressen in einem Chatverlauf einblendenNachrichtenanhänge
Beliebige Adressen in einer App anzeigenSocial-Media-Posts und Profile
Beliebige Handler ungebremst mit Anfragen überflutenFehlendes Rate-Limiting auf Ingame-Ebene
Unbegrenzt Daten wiederholt ablegenEinstellungen, Orte, Alben, Vorlieben
Beliebige Parameter an ausgehende Requests anhängentime unescaped in URL eingefügt
Das Telefon jedes Charakters auslesenmic_phone:boot, zuvor behoben

Alle elf verfügen nun über einen automatisierten Test in server/tests.lua (zuvor waren es fünf).

Zwei davon verdienen gesonderte Erwähnung. Inserat-Fotos und Social-Media-Posts laufen ausschließlich über Photos.AllowedUrl — ein Test dieser Funktion bestätigt zwar die Regel, sagt aber nichts darüber aus, ob die Handler sie auch tatsächlich aufrufen. Der Test the handlers that take a photo still ask whether it is one betritt das System von außen wie ein verknüpftes Telefon und prüft die Abweisung: Wird die Prüfung testweise entfernt, wird die manipulierte Adresse akzeptiert, das Inserat an jedes Telefon der Stadt gesendet, der Post in der App publiziert und das Profil antwortet mit:

without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)

was exakt der String ist, der im img-Tag gelandet wäre. Der erfolgreiche Testlauf schreibt rein gar nichts; die Aufräumroutine greift nur im Fehlerfall, wo es darauf ankommt.

Der Framework-Event-Test ist eine Besonderheit: FiveM bietet keine native Möglichkeit abzufragen, welche Event-Namen eine Ressource für das Netzwerk geöffnet hat. Der Test liest daher bridge/framework/server.lua und sucht nach den entsprechenden Begriffen. Pragmatischer als der Rest, aber unverzichtbar — denn der Fehler, vor dem er schützt, wird von Lintern aktiv empfohlen.

Was jemand tun konnteWo
Code auf dem Telefon jedes Profilbesuchers ausführenName, Handle, Viertel, Job oder Interesse ungefiltert im Markup
Code auf dem Telefon jedes Inserat-Betrachters ausführenBild-URL in src eingefügt mit einer Prüfung, die Anführungszeichen erlaubte
Code auf dem Telefon beim Lesen von Rechnungen, Garagen oder Firmen ausführenAussteller, Titel, Kennzeichen, Orte und Firmendaten roh eingefügt
Dass einer der obigen Punkte tatsächlich zur Ausführung gelangtVollständiges Fehlen einer Content-Security-Policy auf der Seite
Das Telefon in eine fremde Website einbettenFehlendes frame-ancestors an der Web-Schnittstelle

Die ersten drei sind jeweils ein Escaping-Fehler; die letzten beiden erklären, warum die ersten drei so gravierend waren. Alle fünf werden von tests/verify-injection.js abgedeckt, das die Seite so prüft, wie sie ein Browser gerendert hätte, anstatt nur Strings zu vergleichen.

Beide Testreihen wurden zunächst ohne Fix beobachtet: Der Adresstest meldet seine drei fehlenden Abweisungen, und der Injection-Test zählt vier Elemente, die aus ihrem HTML-Attribut ausgebrochen sind — ohne dass davon eines ausgeführt werden konnte, da zu diesem Zeitpunkt die CSP-Richtlinie bereits griff.