Seguridad
Un cliente de FiveM es un programa en el ordenador de otra persona. Los event loggers y los ejecutores de Lua son herramientas corrientes, disponibles libremente, y un servidor que solo es seguro mientras quien le habla es la propia página del teléfono no es seguro. Esto es lo que eso significa en la práctica aquí, y dónde viven las reglas.
Todo lo de abajo se revisó handler por handler, y salieron once cosas. Están listadas al final, no porque la lista sea interesante, sino porque su forma sí lo es: cuatro de ellas eran el mismo error en cuatro sitios distintos.
Después se hizo la misma pregunta al revés (no qué puede enviar un cliente, sino qué se cree el teléfono de lo que le envían), y esa es la regla seis.
Las seis reglas
Sección titulada «Las seis reglas»1. Quién eres lo dice el servidor, nunca el mensaje.
Phone.Handler(name, fn) llama a fn(src, data, player), donde src es la conexión y player se busca a partir de ella. Ningún handler toma un identificador, un número de teléfono o un personaje del cliente para decidir de quién son los datos que toca. Un destinatario se resuelve a partir de un número de teléfono a través de la base de datos; un remitente siempre es quien llama.
La única vez que esto se rompió, mic_phone:boot estaba registrado como el propio Phone.Boot, y el segundo argumento de Phone.Boot es el registro a partir del cual construir un teléfono, mientras que un callback le pasa al handler lo que el cliente haya enviado como su segundo argumento. Cualquiera podía pedir el teléfono de cualquiera por su nombre. server/tests.lua tiene un test que llama al callback registrado con un personaje fabricado y falla si recibe un teléfono.
2. Los eventos propios de un framework no son puertas.
RegisterNetEvent abre un nombre de evento a la red para este recurso. ESX lanza esx:playerLoaded y esx:playerLogout en local, con TriggerEvent, y nunca los abre, así que registrarlos aquí como net events hacía que mic_phone los aceptara desde cualquier cliente, con cualquier id de jugador dentro. Eso lleva directo a Phone.OnDropped: terminar la llamada de ese jugador, sacarlo de su radio, olvidar su teléfono.
AddEventHandler para todo lo que lanza el propio framework. Un linter te dirá lo contrario; se equivoca, y hacerle caso vuelve a abrir el agujero.
3. Nada de lo que envía el cliente se reenvía tal como llegó.
Todo lo que se va a guardar, o a mostrar a otra persona, se reconstruye a partir de los campos que conocemos en lugar de retransmitirse entero: Nearby.Shared para lo que un teléfono le pasa a otro, attachment() para lo que lleva un mensaje, la petición en services:request, el elemento en citylist:save. Un campo que nadie ha nombrado no sobrevive al viaje.
4. Una dirección se comprueba contra los hosts que usa este servidor.
Photos.AllowedUrl: https, menos de 600 caracteres y un host de ServerConfig.Media.Hosts. Toda imagen que llega a una fila de la base de datos o a la pantalla de otro jugador pasa por ahí: la cámara, lo compartido con jugadores cercanos, las fotos de anuncios, los adjuntos de mensajes, las publicaciones y perfiles sociales.
Sin ella, un cliente manipulado puede poner una dirección de su elección delante de cada jugador que pase por ahí (que es una forma de recoger las direcciones de todos los que están en un servidor) o, como estas columnas son LONGTEXT, meter una imagen entera dentro del propio enlace.
5. Todo tiene un tamaño y un ritmo.
DB.Fit(value, width) | un string, recortado a su columna |
DB.Json(value, limit) | una tabla, codificada, o nada si no cabe |
DB.SetJson | 128 KB por blob; el más grande real que se ha visto es de 929 bytes |
Phone.TooFast | 100 llamadas cada 10 segundos, por jugador |
Photos.Chunk | 3 subidas en curso, 24 MB, limpiadas tras un minuto de silencio |
Contacts.Most | 200 |
Calls.MostRecents | 100 |
MOST_ATTACHMENTS | 8 |
Los tamaños importan más de lo que parece. La base de datos funciona en modo estricto, así que un valor un carácter más ancho que su columna no se recorta: el insert falla, en mitad de lo que estuviera haciendo ese handler. En una transferencia, esa línea va después de que el dinero se haya movido.
6. Lo que ha escrito otro jugador es texto, y la página está construida para que no pueda dejar de serlo.
Las cinco de arriba tratan de lo que llega al servidor. Esta trata de lo que llega al teléfono, y es la dirección más peligrosa: la página que dibuja el perfil de otra persona es la misma página que puede llamar a cualquier handler como su dueño. Una etiqueta que se ejecute ahí puede leer los mensajes de ese jugador y mover su dinero. No necesita un ejecutor: necesita un nombre.
El teléfono se dibuja con template literals, así que cada valor es markup salvo que algo lo escape. escapeHtml en core/app.js es ese algo, y rpEsc en las apps de la ciudad es la misma función con el nombre local. Dos mitades:
Escapar en el punto en que se escribe. Cada interpolación que lleva las palabras de alguien (un nombre, un handle, un pie de foto, una nota, un barrio, una dirección) pasa por una de esas dos. Esto se comprobó parseando cada template literal de html/ en lugar de leyéndolos, porque hay 2.918.
Y no depender de ello. html/index.html lleva una Content-Security-Policy sin 'unsafe-inline' en script-src. Nada en este teléfono es un script inline ni un handler inline (cada script es un archivo), así que la política no cuesta nada y significa que un onerror que se haya conseguido colar en una página simplemente no se ejecuta. Es la mitad que no depende de que la siguiente persona que escriba una función de render se acuerde de nada. server/web.js añade las dos cosas que un <meta> no puede decir: frame-ancestors 'none', porque el enlace a un teléfono vinculado es todo el secreto y ese teléfono puede enviar dinero, y nosniff.
tests/verify-injection.js le pasa a cada app un string que sería inconfundible si alguna vez dejara de ser texto, y luego lo busca en la página renderizada.
Y lo que el servidor envía sin que se lo pidan. Los shared/config/*.lua son shared_scripts: cada línea llega al cliente de cada jugador y se puede leer desde el juego con un ejecutor. Config.Web contenía el nombre al que responde este servidor, la dirección a la que apunta su QR y, dos veces, el email de su dueño, nada de lo cual ha leído nunca un cliente. Ahora viven en shared/server_config.lua, que fxmanifest.lua lista en server_scripts y que ya tenía al lado un ejemplo en blanco para distribuir. Un archivo de config no es un lugar privado, y los ajustes que describen este servidor son los que hay que mantener fuera de él.
Dónde es fácil equivocarse
Sección titulada «Dónde es fácil equivocarse»La rama cuidadosa y la de al lado. Cuatro de las once eran el mismo error: una función que gestionaba el caso que alguien tenía en mente y dejaba pasar el otro. data: se subía y url no, en cuatro sitios distintos, cada uno escrito en un momento diferente por alguien que acababa de tener cuidado tres líneas más arriba. Si añades una rama a cualquiera de estos, pregúntate qué hace la otra rama.
Un límite generoso sigue siendo un límite. La lista de contactos espera a una consulta por contacto, y un insert tarda alrededor de un tercio de segundo en el servidor donde se escribió esto: quinientos contactos eran casi tres minutos de base de datos ocupados por una sola petición. El número no estaba mal por ser grande: estaba mal porque nadie lo había multiplicado por nada.
Las reglas viven en una función, no en cada handler. Todo lo de la tabla de arriba está en un solo sitio. Es la única forma de que una regla sobreviva a que la siguiente persona añada un handler.
Una comprobación que se detiene antes de tiempo no es una comprobación. Photos.AllowedUrl leía el host y se detenía en la primera barra, y todo lo que va después de esa barra es la parte que escribe el cliente. https:// + un host que permitimos + /x.jpg" onerror="… la pasaba, y el teléfono ponía ese string en el src de un img para cada jugador que abría el anuncio. La regla era correcta; se le preguntaba por un tercio de la dirección.
Las once
Sección titulada «Las once»| Qué permitía hacer a alguien | Dónde |
|---|---|
| Terminar la llamada de cualquier jugador y sacarlo de su radio, a voluntad | esx:playerLogout registrado como net event |
| Llenar la memoria del servidor hasta tumbarlo | subidas sin límite y sin limpieza |
| Mover dinero sin registro, y que le dijeran que había fallado | una nota más ancha que su columna, en modo estricto |
| Poner cualquier dirección en la fototeca de otro jugador | lo compartido con jugadores cercanos |
| Poner cualquier dirección delante de todos los jugadores de la ciudad | fotos de anuncios |
| Poner cualquier dirección delante de todos los de un chat | adjuntos de mensajes |
| Poner cualquier dirección delante de todos los de una app | publicaciones y perfiles sociales |
| Llamar a cualquier handler tan rápido como pudiera enviar | sin límite de ritmo en la ruta dentro del juego |
| Guardar todo lo que quisiera, una y otra vez | ajustes, lugares, álbumes, preferencias |
| Añadir lo que quisiera a una petición saliente | time pegado en una URL sin escapar |
| Leer el teléfono de cualquier personaje | mic_phone:boot, corregido antes |
Las once tienen ahora un test en server/tests.lua; antes eran cinco.
Dos de ellas merecen ir aparte. A las fotos de anuncios y a las publicaciones sociales solo se llega a través de Photos.AllowedUrl, así que un test de esa función dice que la regla es correcta y no dice nada sobre si esos handlers siguen preguntándole. the handlers that take a photo still ask whether it is one entra por la puerta, como lo hace un teléfono vinculado, y lee la negativa: si se vuelve a quitar la comprobación, la dirección manipulada se acepta, el anuncio se escribe y se difunde a todos los teléfonos de la ciudad, la publicación se escribe y se muestra a todos en la app, y el perfil responde con
without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)que es el string que habría acabado en un img. La ejecución que pasa no escribe nada en absoluto; la limpieza que tiene al lado es para la que falla, que es la ejecución en la que importa.
La del evento del framework es el test raro: no hay forma de preguntarle al runtime qué nombres de evento ha abierto un recurso a la red, así que lee bridge/framework/server.lua y busca las palabras. Más débil que el resto, y está ahí de todos modos: el error que vigila es uno que un linter recomienda activamente.
Y las cinco en la otra dirección
Sección titulada «Y las cinco en la otra dirección»| Qué permitía hacer a alguien | Dónde |
|---|---|
| Ejecutar código en el teléfono de todos los que miraban su perfil | un nombre, handle, barrio, trabajo o interés pegado en el markup |
| Ejecutar código en el teléfono de todos los que abrían un anuncio | una dirección de foto pegada en src, y una comprobación de dirección que permitía una comilla |
| Ejecutar código en el teléfono de cualquiera que leyera una factura, un garaje o un negocio | emisores, títulos, matrículas, ubicaciones y datos de negocios, pegados en crudo |
| Que cualquiera de lo anterior se ejecutara de verdad | ninguna política de contenido en la página |
| Meter el teléfono dentro de la página de otro sitio | sin frame-ancestors desde la puerta web |
Las tres primeras son un error cada una; las dos últimas son la razón de que las tres primeras importaran. Las cinco están cubiertas por tests/verify-injection.js, que comprueba la página que habría construido un navegador en lugar de los strings que entraron en ella.
Los dos tests se vieron fallar primero, con el arreglo quitado de nuevo: el de la dirección nombra sus tres negativas que faltan, y el de inyección cuenta cuatro elementos que escaparon de su atributo, y no dice nada de que alguno se ejecutara, porque para entonces la política ya estaba ahí.