Sécurité
Un client FiveM est un programme s’exécutant sur l’ordinateur d’un tiers. Les enregistreurs d’événements et les exécuteurs Lua sont des outils courants, librement accessibles, et un serveur qui n’est sécurisé que tant que l’interface du téléphone est la seule à lui parler n’est pas un serveur sécurisé. Voici ce que cela implique concrètement ici, et où sont définies les règles.
Chaque élément ci-dessous a été audité handler par handler, ce qui a permis de mettre en évidence onze vulnérabilités potentielles. Elles sont répertoriées à la fin, non pas pour l’intérêt de la liste elle-même, mais pour ce qu’elle révèle : quatre d’entre elles découlaient de la même erreur répétée à quatre endroits différents.
La même question a ensuite été posée dans l’autre sens — non pas ce qu’un client peut envoyer, mais ce que le téléphone accepte comme vérité parmi ce qui lui est transmis — et c’est l’objet de la sixième règle.
Les six règles
Section intitulée « Les six règles »1. Votre identité provient du serveur, jamais du message.
Phone.Handler(name, fn) appelle fn(src, data, player), où src identifie la connexion et player est déduit à partir de celle-ci. Aucun gestionnaire ne prend un identifiant, un numéro de téléphone ou un personnage transmis par le client pour déterminer de qui proviennent les données modifiées. Le destinataire est résolu à partir d’un numéro de téléphone via la base de données ; l’expéditeur est systématiquement l’appelant.
L’unique fois où cette règle a été enfreinte, mic_phone:boot avait été enregistré directement comme Phone.Boot — or le second argument de Phone.Boot est l’enregistrement servant à construire le téléphone, tandis qu’un callback transmet au gestionnaire ce que le client a envoyé comme son second argument. N’importe qui pouvait réclamer le téléphone de n’importe qui par son nom. Le fichier server/tests.lua intègre désormais un test qui invoque ce callback enregistré avec un personnage forgé de toutes pièces et échoue s’il reçoit un téléphone en retour.
2. Les événements internes d’un framework ne sont pas des portes d’entrée.
RegisterNetEvent ouvre un nom d’événement au réseau pour cette ressource. ESX déclenche esx:playerLoaded et esx:playerLogout localement avec TriggerEvent, et ne les expose jamais sur le réseau — ainsi, les déclarer ici comme événements réseau conduisait mic_phone à les accepter de la part de n’importe quel client, avec n’importe quel ID joueur à l’intérieur. Cela menait tout droit à Phone.OnDropped : couper l’appel de ce joueur, l’éjecter de sa radio, oublier son téléphone.
Utilisez AddEventHandler pour tout événement déclenché localement par un framework. Un linter prétendra souvent l’inverse ; il a tort, et suivre son conseil rouvre immédiatement la brèche.
3. Rien de ce qu’envoie le client n’est relayé tel quel.
Tout élément destiné à être stocké ou affiché à un tiers est reconstruit à partir des champs connus plutôt que retransmis d’un bloc : Nearby.Shared pour les échanges de proximité, attachment() pour le contenu d’un message, la demande dans services:request, l’item dans citylist:save. Un champ non explicitement attendu ne survit pas au transfert.
4. Une adresse est systématiquement validée par rapport aux hébergeurs autorisés.
Photos.AllowedUrl — HTTPS, moins de 600 caractères et un domaine issu de ServerConfig.Media.Hosts. Chaque image atteignant une ligne de base de données ou l’écran d’un autre joueur y est soumise : appareil photo, partages de proximité, photos d’annonces, pièces jointes de messages, publications et profils sur les réseaux sociaux.
Sans ce filtre, un client malveillant pourrait afficher l’adresse de son choix devant chaque joueur faisant défiler l’application — ce qui permet de collecter les adresses IP de tous les utilisateurs d’un serveur — ou, dans la mesure où ces colonnes sont de type LONGTEXT, encapsuler une image entière directement dans le lien lui-même.
5. Tout possède une taille maximale et un débit limite.
DB.Fit(value, width) | une chaîne de caractères, tronquée à la taille de sa colonne |
DB.Json(value, limit) | une table, encodée, ou rien si elle dépasse la limite |
DB.SetJson | 128 Ko par blob ; le plus volumineux constaté en pratique est de 929 octets |
Phone.TooFast | 100 requêtes toutes les 10 secondes, par joueur |
Photos.Chunk | 3 téléversements simultanés, 24 Mo, nettoyés après 1 min d’inactivité |
Contacts.Most | 200 |
Calls.MostRecents | 100 |
MOST_ATTACHMENTS | 8 |
Les tailles ont une importance bien plus critique qu’il n’y paraît. La base de données tourne en mode strict, donc une valeur dépassant sa colonne d’un seul caractère n’est pas rabotée : l’insertion échoue, en plein milieu de l’action entreprise par le gestionnaire. Lors d’un virement bancaire, cette insertion survient après le déplacement des fonds.
6. Ce qu’un autre joueur écrit est du texte brut, et la page est construite pour qu’il soit impossible qu’il devienne autre chose.
Les cinq premières règles concernent ce qui parvient au serveur. Celle-ci traite de ce qui parvient au téléphone, et c’est le vecteur le plus sensible : la page qui affiche le profil d’un joueur tiers est la même que celle qui peut appeler chaque gestionnaire en tant que propriétaire du téléphone. Une balise qui s’exécuterait dans ce contexte pourrait lire les messages du joueur et transférer son argent. Nul besoin d’exécuteur — un nom suffit.
Le téléphone assure son rendu via des template literals JavaScript, donc chaque variable injectée devient du balisage HTML à moins d’être échappée. escapeHtml dans core/app.js joue ce rôle, et rpEsc dans les applications de la ville désigne la même fonction sous une appellation locale. La sécurité repose sur deux piliers :
Échapper au point exact d’injection. Chaque interpolation transportant les propos d’autrui — un nom, un identifiant, une légende, une note, un quartier, une adresse — passe obligatoirement par l’une de ces deux fonctions. Cela a été vérifié par analyse syntaxique de chaque template literal dans html/ plutôt que par relecture humaine, le code en dénombrant 2 918.
Et ne pas dépendre uniquement de cette vigilance. Le fichier html/index.html intègre une Content-Security-Policy dépourvue de 'unsafe-inline' dans script-src. Rien dans ce téléphone n’est un script ou gestionnaire d’événement inline — chaque script est un fichier dédié — la politique de sécurité ne coûte donc rien et garantit qu’un attribut onerror inséré par ruse dans le DOM sera purement et simplement neutralisé par le navigateur. C’est le garant qui ne repose pas sur la mémoire du prochain développeur écrivant une fonction de rendu. Le fichier server/web.js y adjoint les deux directives impossibles à renseigner dans une balise <meta> : frame-ancestors 'none', car l’URL d’un téléphone lié constitue l’ensemble du secret et ce téléphone a le pouvoir d’envoyer de l’argent, ainsi que nosniff.
Le script tests/verify-injection.js transmet à chaque application une chaîne qui ne passerait pas inaperçue si elle venait à s’exécuter, puis scrute le code HTML généré pour la repérer.
Ce que le serveur envoie sans qu’on le lui demande. Les fichiers shared/config/*.lua sont déclarés en shared_scripts : chaque ligne est envoyée au client de chaque joueur et peut être lue depuis le jeu à l’aide d’un exécuteur. Auparavant, Config.Web contenait le nom de domaine sur lequel ce serveur répond, l’adresse vers laquelle pointe son QR code et — par deux fois — l’adresse e-mail de son propriétaire, aucune de ces données n’ayant jamais été utile au client. Elles sont désormais isolées dans shared/server_config.lua, que le fxmanifest.lua place dans server_scripts et dont un fichier d’exemple vierge est fourni à la livraison. Un fichier de configuration partagé n’est pas un lieu confidentiel, et les données décrivant ce serveur spécifique doivent en être rigoureusement exclues.
Les pièges fréquents
Section intitulée « Les pièges fréquents »La branche traitée avec précaution et celle d’à côté. Quatre des onze anomalies découlaient du même écueil : une fonction gérant avec rigueur le cas prévu à l’esprit, tout en laissant passer l’autre branche sans contrôle. Le format data: était téléversé et url ne l’était pas, à quatre endroits distincts, écrits à des moments différents par une personne qui venait pourtant d’appliquer des filtres stricts trois lignes plus haut. Lorsque vous ajoutez une condition, demandez-vous toujours ce que fait la branche alternative.
Une limite généreuse reste une limite indispensable. Le chargement des contacts attend une requête par contact, et une insertion SQL prend environ un tiers de seconde sur le serveur de test : cinq cents contacts représentaient près de trois minutes de blocage de base de données mobilisées par une seule requête. La limite n’était pas déraisonnable en soi — elle posait problème parce que personne ne l’avait multipliée par le volume réel.
Les règles résident dans une fonction centralisée, pas dans chaque gestionnaire. Tout ce qui figure dans le tableau ci-dessus est centralisé en un point unique. C’est la seule façon pour qu’une règle de sécurité survive à l’ajout d’un nouveau gestionnaire par un tiers.
Un contrôle qui s’arrête en cours de route n’est pas un contrôle. Photos.AllowedUrl lisait le nom d’hôte et s’arrêtait au premier slash — or tout ce qui suit ce slash est précisément la partie fournie par le client. Une chaîne comme https:// + domaine autorisé + /x.jpg" onerror="… franchissait le test, et le téléphone injectait cette chaîne dans l’attribut src d’une balise img pour chaque joueur consultant l’annonce. La règle de validation était correcte, mais elle n’interrogeait qu’un tiers de l’URL.
Les onze cas traités
Section intitulée « Les onze cas traités »| Ce que cela permettait de faire | Emplacement |
|---|---|
| Couper l’appel de tout joueur et l’éjecter de la radio | esx:playerLogout enregistré en net event |
| Saturer la mémoire du serveur jusqu’au plantage | téléversements sans quota ni nettoyage |
| Transférer de l’argent sans trace et être notifié d’un échec | note plus large que sa colonne en mode strict |
| Injecter une adresse arbitraire dans la galerie d’autrui | partages de proximité |
| Afficher une adresse arbitraire à toute la ville | photos d’annonces |
| Afficher une adresse arbitraire à tous les membres d’un chat | pièces jointes de messages |
| Afficher une adresse arbitraire à toute une application | publications et profils sociaux |
| Invoquer n’importe quel gestionnaire sans retenue | absence de rate-limiting sur le flux en jeu |
| Enregistrer des volumes arbitraires indéfiniment | paramètres, lieux, albums, préférences |
| Ajouter des paramètres arbitraires à une requête sortante | variable time injectée brute dans une URL |
| Lire le téléphone de n’importe quel personnage | mic_phone:boot, corrigé précédemment |
Toutes ces onze failles bénéficient désormais d’un test automatisé dans server/tests.lua, contre cinq à l’origine.
Deux d’entre elles méritent une attention particulière. Les photos d’annonces et les posts sociaux ne sont accessibles qu’à travers Photos.AllowedUrl — tester cette seule fonction prouve que la règle est saine mais n’assure en rien que les gestionnaires continuent de l’interroger. Le test the handlers that take a photo still ask whether it is one entre par la porte publique, comme le ferait un téléphone relié au web, et vérifie le refus : lorsque la vérification est retirée, l’adresse forgée est acceptée, l’annonce est rédigée et diffusée à chaque téléphone de la ville, le post est publié dans l’application, et le profil répond par :
without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)qui correspond exactement à la chaîne qui aurait fini dans une balise img. Le test lorsqu’il réussit n’écrit rien du tout ; le nettoyage associé est prévu pour le scénario où il échoue, qui est celui où l’impact importe.
Le cas de l’événement de framework est le test atypique : FiveM ne fournissant aucun moyen d’interroger le moteur sur les événements ouverts au réseau par une ressource, le test parcourt le fichier bridge/framework/server.lua et y recherche les mentions textuelles. Moins élégant que le reste, mais indispensable — l’erreur qu’il prévient étant activement préconisée par certains linters.
Et les cinq failles côté client
Section intitulée « Et les cinq failles côté client »| Ce que cela permettait de faire | Emplacement |
|---|---|
| Exécuter du code sur le téléphone de quiconque ouvrait un profil | nom, identifiant, quartier, emploi ou centre d’intérêt injecté dans le markup |
| Exécuter du code sur le téléphone de quiconque ouvrait une annonce | URL de photo injectée dans src, avec un contrôle autorisant les guillemets |
| Exécuter du code sur le téléphone lisant une facture, un garage ou une société | émetteurs, intitulés, plaques, emplacements et données d’entreprise bruts |
| Obtenir l’exécution réelle de l’une des failles ci-dessus | absence totale de Content-Security-Policy sur la page |
| Intégrer le téléphone dans la page d’un site tiers | absence de directive frame-ancestors sur la porte web |
Les trois premières représentent chacune une erreur d’échappement ; les deux dernières expliquent pourquoi les trois premières étaient si critiques. L’ensemble des cinq est couvert par tests/verify-injection.js, qui évalue la page telle qu’un navigateur la construirait réellement plutôt que les simples chaînes injectées.
Les deux suites de tests ont d’abord été observées en situation d’échec en retirant les correctifs : le test d’adresses identifie ses trois refus manquants, et le test d’injection dénombre quatre éléments s’étant échappés de leur attribut HTML — sans mentionner qu’un seul a pu s’exécuter, la politique CSP veillant déjà au grain.