Pular para o conteúdo

Segurança

Um cliente FiveM é um programa rodando no computador de outra pessoa. Registradores de eventos (event loggers) e executores Lua são ferramentas comuns, disponíveis gratuitamente, e um servidor que só está seguro enquanto a própria página do telefone é quem conversa com ele não está seguro de verdade. Veja o que isso significa na prática aqui e onde vivem as regras.

Tudo abaixo foi revisado manipulador por manipulador (handler por handler), e onze pontos surgiram dessa revisão. Eles estão listados no final, não porque a lista em si seja curiosa, mas pelo formato dela: quatro deles eram exatamente o mesmo erro em quatro lugares diferentes.

Depois, a mesma pergunta foi feita na direção contrária — não o que um cliente pode enviar, mas no que o telefone acredita sobre o que recebe — e essa é a regra seis.


1. Quem você é vem do servidor, nunca da mensagem.

Phone.Handler(name, fn) chama fn(src, data, player), onde src é a conexão e player é buscado a partir dela. Nenhum manipulador aceita um identificador, um número de telefone ou um personagem vindo do cliente para decidir de quem são os dados que serão alterados. O destinatário é resolvido a partir de um número de telefone no banco de dados; o remetente é sempre quem fez a chamada.

Na única vez em que isso foi quebrado, mic_phone:boot foi registrado diretamente como Phone.Boot — e o segundo argumento de Phone.Boot é o registro a partir do qual o telefone deve ser construído, enquanto um callback entrega ao manipulador o que quer que o cliente tenha enviado como seu segundo argumento. Qualquer um podia pedir o telefone de qualquer pessoa pelo nome. server/tests.lua tem um teste que chama o callback registrado passando um personagem forjado e falha se receber um telefone de volta.

2. Os eventos internos de uma framework não são portas de entrada.

RegisterNetEvent abre um nome de evento para a rede neste recurso. O ESX dispara esx:playerLoaded e esx:playerLogout localmente, com TriggerEvent, e nunca os abre para a rede — portanto, registrá-los aqui como eventos de rede fazia o mic_phone aceitá-los de qualquer cliente, com qualquer ID de jogador dentro. Isso entrava direto em Phone.OnDropped: encerrava a chamada daquele jogador, tirava-o do rádio e esquecia o telefone dele.

Use AddEventHandler para qualquer coisa que a própria framework dispare. Um linter vai lhe dizer o contrário; o linter está errado, e segui-lo reabre a brecha.

3. Nada do que o cliente envia é repassado como chegou.

Qualquer coisa que vá ser armazenada ou mostrada para outra pessoa é reconstruída a partir dos campos que conhecemos, em vez de ser retransmitida inteira: Nearby.Shared para o que um telefone entrega a outro, attachment() para o que uma mensagem carrega, a solicitação em services:request, o item em citylist:save. Um campo que ninguém especificou não sobrevive à viagem.

4. Um endereço é verificado contra os hosts que este servidor usa.

Photos.AllowedUrl — exige https, menos de 600 caracteres e um host presente em ServerConfig.Media.Hosts. Toda imagem que chega a uma linha do banco de dados ou à tela de outro jogador passa por essa verificação: a câmera, compartilhamentos por proximidade, fotos de anúncios, anexos de mensagens, publicações e perfis em redes sociais.

Sem isso, um cliente modificado pode colocar um endereço de sua escolha na frente de cada jogador que rolar o feed — o que é uma forma de coletar o endereço IP de todo mundo no servidor — ou, como essas colunas são LONGTEXT, carregar uma imagem inteira embutida no próprio link.

5. Tudo tem um tamanho máximo e um limite de frequência.

DB.Fit(value, width)uma string, cortada na largura da sua coluna
DB.Json(value, limit)uma tabela codificada em JSON, ou nada se não couber
DB.SetJson128 KB por bloco; o maior bloco real já visto tem 929 bytes
Phone.TooFast100 chamadas a cada 10 segundos, por jogador
Photos.Chunk3 uploads simultâneos, 24 MB, limpos após um minuto de silêncio
Contacts.Most200
Calls.MostRecents100
MOST_ATTACHMENTS8

Os tamanhos importam mais do que parece. O banco de dados roda em modo estrito (strict mode), então um valor apenas um caractere mais largo do que a coluna não é aparado — a inserção falha no meio do que quer que aquele manipulador estivesse fazendo. Em uma transferência bancária, essa linha vem depois que o dinheiro já se moveu.

6. O que outro jogador escreveu é texto, e a página é construída para que nunca deixe de ser texto.

As cinco regras acima tratam do que chega ao servidor. Esta trata do que chega ao telefone, e é a direção mais perigosa: a página que desenha o perfil de outra pessoa é a mesma página que pode chamar todos os manipuladores em nome do dono do telefone. Uma tag HTML/script que consiga rodar ali pode ler as mensagens daquele jogador e transferir o dinheiro dele. Não precisa de um executor — só precisa de um nome.

O telefone desenha a interface usando template literals, portanto qualquer valor vira marcação HTML a menos que algo faça o escape dele. escapeHtml em core/app.js faz esse papel, e rpEsc nos aplicativos da cidade é a mesma função com um nome local. Isso se divide em duas metades:

Fazer o escape no ponto em que é escrito. Toda interpolação que carrega palavras de alguém — um nome, um @handle, uma legenda, uma nota, um bairro, um endereço — passa por uma dessas duas funções. Isso foi verificado analisando (parsing) todos os template literals em html/ em vez de apenas lê-los no olho, porque existem 2.918 deles.

E não depender apenas disso. html/index.html traz uma Content-Security-Policy sem 'unsafe-inline' em script-src. Nada neste telefone é um script inline ou um atributo de evento inline — todo script é um arquivo —, então a política não custa nada e garante que um onerror que uma página tenha sido enganada para escrever simplesmente não seja executado. É a metade da proteção que não depende de a próxima pessoa que escrever uma função de renderização se lembrar de tudo. server/web.js adiciona as duas coisas que uma tag <meta> não pode definir: frame-ancestors 'none', porque o link de um telefone vinculado é o segredo inteiro e esse telefone pode enviar dinheiro, e nosniff.

tests/verify-injection.js entrega a cada aplicativo uma string que seria inconfundível se deixasse de ser texto puro, e depois procura por ela na página renderizada.

E o que o servidor envia sem ser perguntado. Os arquivos em shared/config/*.lua são shared_scripts: cada linha deles chega ao cliente de cada jogador e pode ser lida de dentro do jogo com um executor. Config.Web guardava o domínio pelo qual este servidor responde, o endereço para o qual o QR Code aponta e — duas vezes — o endereço de e-mail do dono do servidor, informações que nenhum cliente jamais precisou ler. Agora eles vivem em shared/server_config.lua, que o fxmanifest.lua lista em server_scripts e que já vem acompanhado de um arquivo de exemplo em branco. Um arquivo de configuração compartilhado não é um lugar privado, e os ajustes que descrevem este servidor específico devem ficar fora dele.


O caminho cuidadoso e o caminho ao lado dele. Quatro dos onze problemas foram exatamente o mesmo erro: uma função que tratava o caso que alguém tinha em mente e deixava o outro passar direto. data: passava pelo upload e url não, em quatro lugares distintos, cada um escrito em um momento diferente por alguém que tinha acabado de tomar cuidado três linhas acima. Se você adicionar uma ramificação (if/else) a qualquer um deles, pergunte-se o que o outro lado faz.

Um limite generoso ainda é um limite. A lista de contatos aguarda uma consulta ao banco por contato, e uma inserção leva cerca de um terço de segundo no servidor em que isto foi escrito: quinhentos contatos significavam quase três minutos de banco de dados preso por uma única requisição. O número não estava errado por ser grande — estava errado porque ninguém o havia multiplicado pelo tempo de execução.

As regras vivem em uma única função, não espalhadas em cada manipulador. Tudo na tabela acima fica centralizado em um só lugar. Essa é a única maneira de uma regra sobreviver quando a próxima pessoa adicionar um manipulador novo.

Uma verificação que para cedo demais não é uma verificação. Photos.AllowedUrl lia o host e parava na primeira barra (/) — e tudo o que vem depois dessa barra é justamente a parte que o cliente escreve. https:// + um host permitido + /x.jpg" onerror="… passava no teste, e o telefone colocava essa string dentro do src de uma tag img para cada jogador que abrisse o anúncio. A regra estava certa; mas só era perguntada sobre um terço do endereço.


O que permitia que alguém fizesseOnde
Encerrar a chamada de qualquer jogador e derrubar seu rádio à vontadeesx:playerLogout registrado como evento de rede
Encher a memória do servidor até ele cairuploads sem limite e sem rotina de limpeza
Mover dinheiro sem registro e receber mensagem de que a operação falhouuma nota mais larga que a coluna, em modo estrito
Colocar qualquer endereço na galeria de fotos de outro jogadorcompartilhamentos por proximidade
Colocar qualquer endereço na frente de todos os jogadores da cidadefotos de anúncios
Colocar qualquer endereço na frente de todos em uma conversaanexos de mensagens
Colocar qualquer endereço na frente de todos em um aplicativopublicações e perfis de redes sociais
Chamar qualquer manipulador na velocidade em que conseguisse enviarfalta de limite de frequência (rate limit) no jogo
Armazenar o quanto quisesse, repetidamenteconfigurações, lugares, álbuns, preferências
Adicionar o que quisesse a uma requisição de saída do servidortime colado em uma URL sem escape
Ler o telefone de qualquer personagemmic_phone:boot, corrigido anteriormente

Todos os onze têm um teste em server/tests.lua agora, subindo dos cinco originais.

Dois deles merecem destaque. Fotos de anúncios e publicações sociais só são alcançadas passando por Photos.AllowedUrl — portanto, um teste apenas dessa função diz que a regra está certa, mas não diz nada sobre se aqueles manipuladores continuam chamando a função. O teste the handlers that take a photo still ask whether it is one entra pela porta da frente, como faz um telefone vinculado, e verifica a recusa: quando a verificação é removida para teste, o endereço forjado é aceito, o anúncio é gravado e transmitido para todos os telefones da cidade, a publicação é gravada e mostrada para todos no aplicativo, e o perfil responde com:

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

que é exatamente a string que teria entrado em uma tag img. A execução que passa no teste não grava nada no banco; a rotina de limpeza ao lado dela existe para quando o teste falha, que é quando realmente importa.

O teste do evento de framework é o único diferente: não há como perguntar ao runtime quais nomes de eventos um recurso abriu para a rede, então ele lê o arquivo bridge/framework/server.lua diretamente e procura pelas palavras. É mais simples que os outros, mas está lá mesmo assim — porque o erro que ele evita é justamente um que os linters recomendam ativamente.

O que permitia que alguém fizesseOnde
Executar código no telefone de todo mundo que olhasse seu perfilnome, @handle, bairro, emprego ou interesse colados diretamente no HTML
Executar código no telefone de todo mundo que abrisse um anúncioendereço de foto colado no src e uma validação de endereço que permitia aspas
Executar código no telefone de qualquer um lendo uma fatura, garagem ou empresaemissores, títulos, placas, localizações e detalhes da empresa colados sem tratamento
Permitir que qualquer um dos itens acima realmente fosse executadoausência total de Content-Security-Policy na página
Colocar o telefone dentro da página de outro siteausência de frame-ancestors na porta web

Os três primeiros são um erro cada; os dois últimos são o motivo pelo qual os três primeiros tinham impacto crítico. Todos os cinco são cobertos por tests/verify-injection.js, que inspeciona a página que um navegador teria construído em vez de olhar apenas para as strings que entraram nela.

Ambos os conjuntos de testes foram observados falhando primeiro, removendo-se a correção: o teste de endereços aponta as três recusas ausentes, e o de injeção conta quatro elementos que escaparam de seus atributos — e não diz nada sobre algum script ter executado, porque àquela altura a política de segurança (CSP) já estava ativa.