Quando a Realidade Não é Suficiente: Adicionando Hysteria2 + Salamander a um Mensageiro iOS e os Obstáculos no Caminho (Parte 2)

Quando a Realidade Não é Suficiente: Adicionando Hysteria2 + Salamander a um Mensageiro iOS e os Obstáculos no Caminho (Parte 2)

Este artigo detalha a implementação de Hysteria2 e Salamander para contornar restrições de rede em um aplicativo de mensagens iOS, explorando os desafios de contornar listas brancas de DPI e a importância de uma abordagem de transporte dupla.

MundiX News·27 de maio de 2026·9 min de leitura·👁 58 views

rcq 37 minutos atrás Quando a Realidade Não é Suficiente: Adicionando Hysteria2 + Salamander a um Mensageiro iOS e os Obstáculos no Caminho (Parte 2) Médio 9 min 1.6K Segurança da Informação * Tecnologias de Rede * iOS * Swift * Go * Caso

Este artigo eu preparei desde a semana passada, e enquanto preparava, o TSPC lançou novas regras de filtragem, visando exatamente o Reality-handshake, sobre o qual se fala aqui. Ou seja, o artigo tornou-se mais relevante do que quando comecei a escrevê-lo.

No artigo anterior, contei como integramos VLESS + Reality diretamente em nosso aplicativo iOS através do sing-box, para que a superação de bloqueios não fosse uma tarefa do usuário, mas um detalhe da implementação. Resumindo: o handshake TLS é proxyado para um grande site de terceiros, a sondagem ativa se concentra nesse site, o IP é tratado como um consumível, a configuração é entregue separadamente da compilação. A abordagem funciona, e para a grande maioria das conexões da Rússia funciona agora mesmo.

Exceto por uma classe de redes em que não funcionava.

Dentro dessa classe estavam, inclusive, sub-redes corporativas, Wi-Fi de convidados em alguns aeroportos e parte da cobertura regional de uma das operadoras. A imagem nos logs é a mesma. O túnel é estabelecido, a conexão TCP no relay é aberta, o handshake TLS começa, e um segundo depois o sing-box no servidor escreve no log:

REALITY: processed invalid connection . Interrupção imediata, sem tentativas que mudem algo.

Este artigo é sobre o que vimos nessas redes, por que o Reality sozinho não as atravessa, e o que colocamos ao lado para que atravesse. Se você leu a parte anterior, continue daqui. Se não leu, um ponto é importante: o túnel vive dentro do aplicativo, através do sing-box, compilado em um framework nativo, sem VPN do sistema.

O que é uma “lista branca” em DPI

O modelo usual de censura é a lista negra: a operadora conhece os hosts ruins, os corta, o resto passa. Os métodos comuns de contorno (Reality, proxy com mascaramento) funcionam bem contra a lista negra, porque parecem “o resto”.

A lista branca é uma inversão. A operadora permite o tráfego para um pequeno conjunto de domínios e IPs pré-aprovados, e tudo que não se encaixa nessa lista é cortado com diferentes graus de precisão. No nível SNI, isso é trivial: ClientHello aberto, nele é visível para onde você está indo, e se esse domínio não estiver no conjunto permitido, a conexão é cortada. No nível IP, a mesma coisa: os pacotes para endereços fora do subconjunto permitido simplesmente não chegam. É a mesma infraestrutura DPI de sempre, apenas configurada no princípio oposto.

Aqui surge uma questão interessante. Se a lista branca segue SNI, então o Reality, que durante o handshake TLS proxyia o tráfego para um grande site de terceiros (por exemplo, microsoft.com), formalmente deveria passar: o censor vê no SNI um domínio permitido. Mas não passa. E aqui começa o interessante.

O que exatamente a lista branca DPI intercepta

Reality esconde o conteúdo do túnel proxy dentro de um handshake TLS válido para um domínio permitido. ClientHello é real, as impressões digitais TLS coincidem com o Chrome real (através do uTLS), o certificado e a cadeia são válidos. Um observador passivo e um sondador ativo não distinguiriam isso de um navegador normal indo para microsoft.com.

Mas “não distinguirá” isso é sobre o DPI comum, que observa um conjunto de assinaturas e toma a decisão “permitir ou não”. A lista branca DPI se comporta de forma diferente. Ela não tenta identificar o tráfego ruim, ela tenta se certificar do bom. E este é outro algoritmo.

Aqui farei uma ressalva imediatamente: eu não vasculhei as fontes de um determinado fornecedor de DPI, e a formulação abaixo é minha reconstrução do comportamento, e não uma verdade confirmada. Paralelamente, fiz perguntas a conhecidos da camada de engenharia de Tel Aviv (do lado dos fornecedores comerciais de DPI, historicamente, há uma forte ligação), e a formulação que ouvi com mais frequência soa mais ou menos assim: “os produtos modernos não identificam o ruim por uma única assinatura, eles reúnem a certeza de que o tráfego é bom a partir de vários sinais de uma vez”. Isso concorda com o que observamos, mas ainda é uma conclusão heurística de fora.

Em seguida, vou simplesmente listar o que, nesta imagem, na minha observação, influencia. Se sua “solicitação para microsoft.com” difere da real em qualquer conjunto de micro-parâmetros, a heurística levanta a mão. E existem muitos desses parâmetros: a ordem das extensões TLS no ClientHello, o padrão dos valores GREASE, os tempos de resposta após o handshake, os tamanhos dos registros. uTLS copia cuidadosamente o ClientHello do Chrome versão N, mas o Chrome real também navega na rede como o Chrome: a conexão de um navegador normal acarreta TCP fast open, quadros HTTP/2 PING em momentos característicos, verificações OCSP-staple, negociações ALPN e dezenas de outras pequenas coisas.

Reality não reproduz tudo isso. Reality saiu impecavelmente do handshake TLS e mudou para o túnel, e então seu tráfego proxy passa pelo canal. Do ponto de vista do DPI, isso é TLS em microsoft.com, no qual, imediatamente após o handshake, começa um fluxo estranho de dados, que não se assemelha a HTTPS. Para a lista negra clássica, isso passará, porque as assinaturas da “estranheza” não estão explicitamente prescritas nela. Para a lista branca, onde a heurística por padrão diz “não permitir, a menos que esteja convencido”, isso é suficiente para cortar.

Tentamos várias configurações: diferentes SNI para mascaramento, diferentes impressões digitais uTLS (chrome 120, chrome 131, firefox), diferentes comportamentos xtls-rprx-vision. A imagem não mudou. Não é uma questão de escolher a impressão digital correta, é uma questão da própria paradigma.

Então, nessas redes, é necessária outra abordagem.

Por que UDP, e por que exatamente Hysteria2

No mundo TCP, DPI em lista branca é uma tarefa resolvida com uma resposta clara: olhamos para TLS, comparamos com a lista, cortamos o inadequado. No mundo UDP, o DPI tem uma tarefa mais difícil, porque o próprio UDP é diferente. QUIC para youtube.com e QUIC para Cloudflare e tráfego de jogos e videochamadas e apenas algum protocolo caseiro parecem bastante diferentes para que uma única heurística “este é UDP permitido” funcione mal. E cortar UDP de forma muito agressiva é perigoso para a própria rede: metade do tráfego móvel agora passa por QUIC, quebrar QUIC para grandes sites é um tiro no pé.

Portanto, os transportes UDP no contorno prático de bloqueios se comportam melhor. Mas apenas QUIC “como está” também não é uma solução: o handshake QUIC também é marcado, e o DPI, que é treinado em versões QUIC públicas específicas (Chrome, Cloudflare, Google QUIC v1), pode identificá-lo também.

Hysteria2 é um protocolo próprio sobre QUIC, focado precisamente no contorno de bloqueios. E o principal que nos interessou é seu plugin obfs chamado Salamander. Salamander impõe a cada pacote UDP uma camada XOR externa com uma chave, que é derivada da senha. Ou seja, o DPI, que tentará olhar no primeiro pacote hy2 e identificar o handshake QUIC por bytes, vê apenas um fluxo sem sentido. Nenhuma assinatura reconhecível sobrevive dentro do pacote.

As partes (cliente e servidor) conhecem a senha, derivam dela a chave e escrevem/leem da mesma forma. Do lado de fora, nada útil é visível.

Isso é suficiente para o DPI que precisávamos contornar. Enfatizo “suficiente para isso” conscientemente: não sei como essa mesma abordagem se comportará em outras implementações de DPI, e a suposição “UDP+obfs é sempre melhor” em geral provavelmente é incorreta. Especificamente em nossos cenários, funcionou.

Como isso funciona

A arquitetura é simples. O mesmo servidor relay, no qual já vivia VLESS + Reality em TCP/443, agora adicionalmente escuta hy2 em UDP/443. Uma porta, diferentes transportes, sem conflito (TCP e UDP são diferentes pilhas no kernel).

Configuração do servidor sing-box, kernel:

json
{
  "inbounds": [{
    "type": "hysteria2",
    "listen": "::",
    "listen_port": 443,
    "users": [{ "password": "..." }],
    "obfs": {
      "type": "salamander",
      "password": "..."
    },
    "tls": {
      "enabled": true,
      "server_name": "www.apple.com",
      "certificate_path": "/etc/sing-box/hy2_cert.pem",
      "key_path": "/etc/sing-box/hy2_key.pem"
    }
  }]
}

Duas senhas, não uma. A primeira é a autenticação do usuário em hy2. A segunda é a senha para Salamander, separada, porque obfs funciona na camada antes de sequer analisarmos o pacote hy2. Se a senha obfs não corresponder, o servidor não entenderá o que chegou até ele.

Certificado autoassinado, no mesmo CN que o SNI do domínio permitido (usamos o mesmo domínio que no Reality, por questões de consistência de logs e porque o cert CN dentro do TLS é visível apenas para o cliente, para DPI ele está dentro da camada obfs e não importa). O cliente usa insecure: true, porque a autenticação é transferida para senhas, não para PKI.

Outbound do cliente (o mesmo sing-box, o mesmo framework que no primeiro artigo):

json
{
  "outbounds": [{
    "type": "hysteria2",
    "server": "RELAY_ADDR",
    "server_port": 443,
    "password": "...",
    "obfs": {
      "type": "salamander",
      "password": "..."
    },
    "tls": {
      "enabled": true,
      "server_name": "www.apple.com",
      "insecure": true
    }
  }]
}

Uma nuance sobre a compilação do gomobile. Se você compilou o sing-box para o artigo anterior, você já tem a tag with_utls. Para hy2 + obfs, você precisará adicionalmente de with_quic, caso contrário, os módulos necessários simplesmente não entrarão no framework. Isso afeta o tamanho do binário de forma perceptível (dezenas de megabytes), mas este é o preço do transporte UDP, não há nada a ser feito.

Como o cliente escolhe entre Reality e hy2

No sing-box, há um outbound do tipo urltest, que levanta em paralelo vários outbound semelhantes e escolhe o mais rápido pelos resultados dos testes HTTP. Colocamos ambas as opções, Reality e hy2, no urltest, para cada relay. Nas redes onde Reality passa, ele vence (em TCP, obtemos uma latência um pouco menor). Nas redes com lista branca, o teste Reality silenciosamente falha, hy2 passa, urltest vê isso e muda o fluxo principal para hy2.

O usuário não configura nada. O aplicativo faz uma conexão direta na primeira vez, se não funcionar, levanta o sing-box, o sing-box dentro de si escolhe o transporte de trabalho.

Obstáculos no caminho

Com o próprio protocolo e com as configurações, tudo estava em ordem, a documentação do sing-box é suficiente. Os obstáculos apareceram na infraestrutura.

Firewalls de nuvem em UDP.

Nesse obstáculo, perdi honestamente parte da noite, e escrevo sobre isso em detalhes, porque essa é a categoria de erros em que você culpa sua configuração por muito tempo. Em duas hospedagens de quatro, o iptables na própria máquina foi configurado corretamente (UDP/443 ACCEPT), os certificados foram distribuídos, o sing-box foi iniciado e está ouvindo, tudo está bem. Do lado de fora, o tráfego para a máquina não chega de forma alguma. Você está sentado com tcpdump no host, vê zero pacotes e pensa “o que eu quebrei no iptables”, embora no iptables tudo esteja em ordem. Este não é um bug de configuração, é um firewall de nuvem no nível do provedor, que por padrão abre apenas as portas familiares, e UDP/443 está fechado. Um deles, a propósito, a função API da instância não tinha o direito de abrir portas programaticamente, tive que entrar na interface do usuário manualmente. O segundo teve uma história semelhante com o grupo de segurança nomeado. Esta é uma tarefa de cinco minutos quando você sabe que precisa fazê-la, e uma investigação de uma hora quando você não sabe.

Lição: depois de levantar um listener em uma nova porta, certifique-se de verificar de outra máquina se os pacotes estão chegando. Nunca acredite em “iptables diz open”, acredite no tcpdump na máquina, no qual você vê os pacotes de entrada. E se o tcpdump estiver em silêncio, procure a causa não nas regras da própria máquina, mas na camada acima.

Um único SNI comum para um par de transportes em um relay.

Dentro do Reality, o handshake TLS é real, é proxyado para microsoft.com (por exemplo), e o cert CN deve corresponder. Dentro do hy2, o TLS é criptografado pela camada obfs e não é visível do lado de fora, mas o cert CN também é microsoft.com, para que os logs internos e o painel de administração não pareçam heterogêneos. Esta não é uma necessidade funcional, é higiene operacional, para olhar para os logs em seis meses e não se perder.

Certificado autoassinado e insecure: true.

Isso é normal para hy2 com senhas, mas na primeira execução a mão se estende para verificar “o cert é válido?”. Não é válido, e isso é normal. Toda autenticação está nas senhas. Se para alguma etapa futura você quiser emitir um certificado real (através do desafio DNS-01 para um subdomínio relay), isso não tornará o hy2 melhor em termos de contorno, isso simplesmente removerá a linha com insecure da configuração do cliente.

Diversidade de hospedagem é mais forte do que parece.

Reality queima por IP, e hy2 queima por IP quase da mesma forma. Faz sentido que um par (Reality, hy2) esteja em diferentes provedores, em diferentes regiões. Se o cidr de um provedor cair em um bloco em massa, você terá outros. Mantemos um conjunto de várias combinações (um provedor, o segundo provedor, o terceiro) e na configuração, que é entregue separadamente da compilação, todos eles são prescritos. Mais detalhes sobre essa abordagem foram escritos no primeiro artigo.

Honestamente sobre limites

Já escrevi no primeiro artigo, repetirei brevemente: o túnel muda a aparência da conexão para o censor ao longo do caminho e não muda quem está nas extremidades. O conteúdo da correspondência é protegido separadamente, no nível do aplicativo (usamos libsignal). Hy2 + Salamander é sobre outra camada: sobre como os pacotes chegam ao servidor.

A ofuscação Salamander está na senha, não na criptografia completa. Se você extrair a senha (por exemplo, de sua própria compilação comprometida), o obfs é removido trivialmente e o DPI vê novamente o handshake QUIC por dentro. Portanto, as senhas nós (assim como tudo que deve mudar rapidamente) armazenamos não no binário, mas em uma configuração assinada, que é entregue em tempo de execução. Mudou o relay, mudou as senhas, reemitimos a assinatura, o cliente puxou.

Outra observação importante que vemos nos logs e feedback. Mesmo com hy2, além do Reality, alguns usuários ainda não conseguem estabelecer o transporte. Esta é uma minoria, mas estável. Por sinais indiretos (geolocalização, operadora, hora do dia em que o problema se agrava), a imagem é bastante consistente: quanto mais próxima a rede do usuário estiver das zonas com atividade particularmente sensível para o estado, mais densamente as listas brancas são configuradas e mais agressivamente tudo o que não se encaixa é cortado. Ou seja, DPI não é uma camada homogênea em todo o país, é um gradiente com restrições locais. Não posso provar isso estritamente, não tenho acesso às configurações de nenhuma operadora, mas a imagem coincide com um número suficiente de usuários para mencionar isso como uma observação, e não uma coincidência.

E separadamente: tudo isso é uma corrida. Hoje, a lista branca DPI não consegue cortar bem o UDP ofuscado, em um ano pode aprender. Este é um processo normal, você só precisa tratá-lo como um processo: ter um suprimento de ferramentas, monitorar o que falhou e não considerar nenhum deles “para sempre”.

O que levar

Se você está fazendo uma tarefa semelhante:

  • Em redes com lista branca DPI, um Reality sobre TCP pode não funcionar, porque o DPI funciona não em “identificar o ruim”, mas em “se certificar do bom”. Este é outro paradigma, e Reality não se encaixa nele por construção.
  • UDP + obfs (no nosso caso, Hysteria2 + Salamander) nessas redes funciona melhor, porque UDP-DPI é, em princípio, mais fraco, e Salamander remove adicionalmente quaisquer assinaturas dentro do pacote.
  • Não escolha um transporte, mantenha ambos. Reality economiza recursos em redes sem lista branca, hy2 atravessa o que Reality não atravessa.
  • urltest no sing-box resolve isso automaticamente.
  • Firewalls de nuvem em UDP são um tópico separado e quase sempre uma dor separada. Reserve tempo para “abrir a porta necessária na interface do usuário do provedor”, especialmente se você tiver vários provedores diferentes.
  • E planeje imediatamente que tanto hy2 quanto Reality queimarão por IP. Um deles pode viver mais tempo, mas ambos são consumíveis. A configuração é entregue separadamente da compilação, caso contrário, cada IP em chamas é um novo lançamento na App Store.

Tudo o que foi descrito vive em nosso mensageiro RCQ, agora está em beta aberto no iOS. O cliente é de código aberto, você pode ver como o transporte e a comutação entre eles são estruturados: github.com/rcq-messenger/rcq-ios.

Se você está envolvido em algo semelhante e viu o comportamento da lista branca DPI ou trabalhou com hy2 em produção, conte nos comentários. Especialmente interessante sobre as redes em que nem Reality nem hy2 funcionaram: ainda não nos deparamos com tais, mas isso não significa que não existam.

📤 Compartilhar & Baixar