Segurança e Arquitetura
AeroMais — Sistema de Medição
Documentação consolidada de todas as pendências de segurança levantadas ao longo do projeto, como foram resolvidas, e demonstração de que os objetivos de segurança foram atingidos para avaliação de pentest.
1. Resumo Executivo
O AeroMais é um sistema embarcado de medição de combustível composto por: um servidor Python/Flask rodando em Raspberry Pi, um aplicativo Android (Flutter) com WebView, um display embarcado e integração com a plataforma Automais.IO via WireGuard VPN.
Durante o processo de desenvolvimento, foram identificadas e tratadas 16 pendências
de segurança (referenciadas como PTRZN-XXXX). Cada pendência foi
documentada, analisada e resolvida com código rastreável nos comentários do fonte.
Este relatório apresenta: (1) a arquitetura do sistema, (2) os fluxos de autenticação e WiFi, (3) o detalhamento de cada PTRZN com o problema original e a solução implementada, e (4) a demonstração de que os objetivos de segurança máxima foram atingidos.
| PTRZN | Título | Severidade | Status |
|---|---|---|---|
| 1516 | Disclosure de tecnologias via headers | Média | Resolvido |
| 1517 | Detecção de root (Android) | Alta | Resolvido |
| 1518 | Detecção de emulador (Android) | Média | Resolvido |
| 1520 | Integridade do APK (anti-tamper) | Alta | Resolvido |
| 1521 | Ausência de headers de segurança HTTP | Média | Resolvido |
| 1522 | Exposição de stack traces em erros HTTP | Média | Resolvido |
| 1523 | Cookie de sessão sem flags Secure/HttpOnly | Alta | Resolvido |
| 1525 | Hash de senha MD5 → bcrypt | Alta | Resolvido |
| 1526 | Segredos hardcoded → variáveis de ambiente | Alta | Resolvido |
| 1527 | Política de senha fraca | Média | Resolvido |
| 1528 | IDOR — exposição de IDs internos | Alta | Em Dev |
| 1529 | Usuário duplicado / lógica de criação | Baixa | Resolvido |
| 1530 | Sessão não invalidada no logout | Alta | Resolvido |
| 1531 | XSS via innerHTML e campos sem restrição | Alta | Resolvido |
| 1532 | Validação de entrada insuficiente | Média | Resolvido |
| 1538 | Ausência de rate limiting no login | Alta | Resolvido |
| 1540 | Remote View — captura de tela dual | — | Feature |
3. Contexto Técnico e Progresso
3.1 Principal Ponto de Atenção — Enlace Tablet ↔ CPMais
O maior desafio contínuo do projeto é garantir que a comunicação entre o tablet Android (AeromaisApp) e o CPMais (dispositivo Linux embarcado) seja estabelecida sobre HTTPS com certificados seguros — e que essa comunicação se mantenha estável ao longo do tempo, independente das condições de campo.
3.2 Decisão Arquitetural — CPMais como Hotspot (AP)
Durante os testes observamos um comportamento de instabilidade quando o CPMais atuava como cliente WiFi (STA) e o tablet como ponto de acesso: o tablet por vezes derrubava o roteador interno, e o CPMais não se reconectava de forma confiável. O caminho inverso se mostrou muito mais estável.
| Cenário | Tablet | CPMais | Resultado |
|---|---|---|---|
| Anterior | Hotspot (AP) | Cliente WiFi (STA) | ❌ Instável — tablet derrubava o AP, CPMais não reconectava |
| Atual | Cliente WiFi (STA) | Hotspot (AP) | ✅ Estável — Android excelente em manter e reconectar ao AP conhecido; Linux excelente em manter o AP em pé |
3.3 Desafio Derivado — CPMais sem Acesso à Internet
Com o CPMais em modo AP, ele deixa de ser um cliente de rede — portanto não tem acesso direto à internet. Isso levantou um problema: como fazer manutenção remota e sincronizar certificados sem expô-los?
Solução: Relay UDP via AeromaisApp
O app Android atua como relay de pacotes UDP da VPN do CPMais para o servidor VPN da plataforma.
Desafio de Roteamento Android — WiFi vs. 4G
Um obstáculo crítico surgiu durante os testes: ao conectar ao AP do CPMais, o Android detecta que o WiFi não tem internet e exibe o diálogo "rede sem internet, conectar mesmo assim?". Em vários dispositivos/OEMs, ao confirmar, o Android passa a rotear todo o tráfego pelo WiFi — incluindo os pacotes do relay UDP. Como o CPMais não tem saída para a internet, esses pacotes eram descartados no RPi e o relay nunca conseguia completar o handshake WireGuard.
Um segundo problema era o DNS: o dnsmasq do AP está configurado com
address=/#/10.254.254.1, fazendo todos os domínios resolverem para
o IP do CPMais. Com isso, automais.io também resolvia para
10.254.254.1, e o relay tratava os pacotes do CPMais como respostas do
servidor remoto, descartando-os silenciosamente.
Solução: Dual-Socket + DNS Celular + Captive Portal 302
Três correções complementares foram implementadas:
| Mecanismo | Arquivo | Efeito |
|---|---|---|
| Captive portal 302 | wifimaismanager.py |
Responde 302 (redirect) em vez de 204 ao probe do Android.
Com 302, o Android marca a rede como "captive portal" e mantém o 4G como
rota padrão — todos os outros apps e o sistema operacional continuam usando
celular sem interrupção. |
| Dual-socket no relay | udp_relay_service.dart |
_socketLocal vinculado ao IP WiFi AP (10.254.254.x:51821)
recebe pacotes WireGuard do CPMais.
_socketRemote vinculado ao IP celular (porta efêmera) envia e recebe
de automais.io:51820. O tráfego para o servidor sai obrigatoriamente
pela interface celular, independente do roteamento do Android. |
| DNS via rede celular | RouterMonitorPlugin.java |
Novo método getCellularInfo() usa ConnectivityManager
para encontrar a rede celular e chama Network.getAllByName("automais.io")
— resolve o DNS diretamente pelo DNS do operador, ignorando o
dnsmasq do AP. |
O fluxo completo do relay com dual-socket:
sequenceDiagram
participant C as CPMais (AP · 10.254.254.1)
participant WL as _socketLocal (WiFi 10.254.254.x:51821)
participant WR as _socketRemote (IP Celular:efêmero)
participant V as automais.io:51820
Note over C,WL: Enlace local WiFi AP (sem internet)
Note over WR,V: Enlace celular 4G (internet)
C->>WL: Handshake WireGuard UDP
WL->>WR: relay (memória interna)
WR->>V: Reencaminha via 4G
V-->>WR: Resposta WireGuard
WR->>WL: relay
WL-->>C: Entrega ao CPMais
Note over C,V: Túnel WireGuard estabelecido via relay
C->>V: Tráfego de manutenção / PKI (via túnel)
V-->>C: Configuração remota / PKI enrollment
3.4 Resultado Final — HTTPS Seguro sem Exposição de Certificados
Com o túnel VPN viabilizado pelo relay do AeromaisApp, o CPMais consegue:
-
Relay UDP estável — dual-socket — o relay usa dois sockets separados:
um vinculado ao IP WiFi AP (para o CPMais) e outro ao IP celular (para
automais.io). Isso garante que o tráfego de relay saia obrigatoriamente pelo 4G, independente de como o Android roteou o tráfego ao conectar no AP. O DNS deautomais.ioé resolvido viaConnectivityManagerda rede celular, contornando odnsmasqdo AP (que retornaria10.254.254.1para todos os domínios). -
4G mantido para outros apps e sistema — o captive portal do AP responde
com
302(redirect) em vez de204ao probe/generate_204do Android. Com204, o Android consideraria o WiFi como "rede com internet" e rotearia tudo pelo CPMais. Com302, o Android marca a rede como "captive portal" e mantém o 4G como rota padrão de internet para todos os apps e o sistema operacional. - Enrollment de certificados via PKI Service — o CPMais gera o par de chaves localmente, envia apenas o CSR (nunca a chave privada) e recebe o certificado assinado pela plataforma, tudo pelo canal VPN.
- Manutenção e configuração remota — acesso seguro ao CPMais via túnel VPN sem expor nenhuma porta diretamente à internet.
- HTTPS entre tablet e CPMais — após o enrollment, toda a comunicação entre o AeromaisApp e o CPMais ocorre sobre HTTPS/mTLS com certificados emitidos pela CA privada da plataforma. Nenhum certificado é transferido manualmente ou exposto durante o processo.
- Renovação automática — o CPMais verifica diariamente se o certificado precisa ser renovado e executa o processo automaticamente via o mesmo canal VPN, sem intervenção humana.
2. Topologia do Sistema
O AeroMais é composto por quatro camadas físicas que se comunicam de forma segura. A segurança não reside em um único componente mas na composição das camadas.
graph TB
subgraph cloud["☁️ Plataforma Automais.IO"]
WGS["WireGuard\nVPN Server"]
PKI["PKI Service"]
KV["🔐 Automais.IO\nCA RSA-4096\n(chave gerenciada internamente)"]
PG[("Audit log\ncertificados e tokens")]
KV-->|Sign| PKI
PKI---PG
WGS---PKI
end
subgraph rpi["🖥️ Raspberry Pi — AeroMaisServer"]
NGINX["Nginx\nmTLS · porta 443"]
FLASK["Flask\n127.0.0.1\n(não exposto)"]
DB[("MariaDB")]
WIFI["WiFiMaisManager\nhostapd · dnsmasq · iptables"]
NGINX-->FLASK
FLASK---DB
end
subgraph app["📱 AeromaisApp — Android"]
WV["WebView\nInterface Flask"]
WGC["WireGuard Client\nwg-automais"]
RM["RouterMonitor\nrelay UDP"]
WV---WGC
RM---WGC
end
DISPLAY["⚙️ AeromaisDisplay\nC++ · RoxProtocol"]
WGS-->|"VPN wg-automais"| WGC
WGS-->|"VPN wg-automais"| NGINX
app<-->|"WiFi AP · 10.254.254.x"| rpi
FLASK-->|"Serial UART"| DISPLAY
Componentes e responsabilidades
| Componente | Stack | Responsabilidade |
|---|---|---|
| AeromaisServer | Python 3 / Flask / Nginx / MariaDB | API REST, interface web, leitura do medidor, gerenciamento WiFi, PKI enrollment |
| AeromaisApp | Flutter / Dart / Kotlin (Android) | Interface do operador, relay WireGuard, monitoramento de rede, captura remota |
| AeromaisDisplay | C++ | Exibição de dados do medidor via display físico, protocolo RoxProtocol serial |
| Plataforma Automais.IO | — | PKI Service (emissão de certificados), servidor WireGuard VPN, gerenciamento de tokens de enrollment |
| Automais.IO | — | Armazenamento seguro da chave privada da CA RSA 4096 — gerenciada internamente pela plataforma |
3. Fluxo de Autenticação
A autenticação do AeroMais opera em duas camadas independentes e complementares: a camada TLS mútua (mTLS) resolvida no Nginx antes de qualquer HTTP, e a camada de sessão de aplicação gerenciada pelo Flask.
3.1 Camada 1 — mTLS (Mutual TLS) via Nginx
Toda requisição ao servidor Flask passa obrigatoriamente pelo Nginx. O Nginx está configurado
com ssl_verify_client on — qualquer conexão sem certificado de cliente válido
assinado pela CA privada é rejeitada na camada TLS, antes de qualquer código
Python ser executado.
sequenceDiagram
autonumber
participant App as AeromaisApp
participant N as Nginx (porta 443)
participant F as Flask (127.0.0.1)
App->>N: TLS ClientHello
N-->>App: ServerHello + server.crt
App->>N: client.crt (mTLS)
N->>N: Verifica client.crt vs ca.crt ✓
N->>F: proxy_pass + X-SSL-Client-Cert header
F-->>N: HTTP Response
N-->>App: HTTP Response
Note over App,N: Cenário sem certificado de cliente
App->>N: TLS ClientHello (sem client.crt)
N-->>App: 400 No required SSL certificate sent
Note right of F: Flask nunca é alcançado
3.2 Camada 2 — Sessão Flask (para operadores humanos)
A interface web (acessada via WebView do app) usa sessão Flask baseada em cookies assinados. O fluxo completo de login com todas as proteções PTRZN aplicadas:
O backend valida tamanho, tipo e padrão dos campos username e password antes de qualquer processamento.
O RateLimiter verifica se o usuário/IP não excedeu o limite de tentativas. Bloqueia temporariamente após falhas repetidas.
A senha é verificada com bcrypt.checkpw(). O hash armazenado no banco é sempre bcrypt — MD5 foi removido.
Um session_id único (secrets.token_urlsafe(32)) é gerado e registrado no SessionManager em memória.
O cookie aeromais_session é emitido com flags Secure, HttpOnly e SameSite=Lax.
Todos os endpoints protegidos verificam se o session_id ainda está na lista de sessões ativas (não revogado).
No logout, a sessão é explicitamente revogada no SessionManager — qualquer cookie remanescente é inválido imediatamente.
3.3 Camada 3 — Proteções do App Android
Antes de qualquer requisição ser enviada ao servidor, o app verifica o ambiente de execução:
| Verificação | PTRZN | Ação se detectado |
|---|---|---|
| Dispositivo rooteado (5 estratégias) | 1517 | Tela de bloqueio não-dispensável, app encerrado |
| Ambiente emulado (4 estratégias) | 1518 | Tela de bloqueio não-dispensável, app encerrado |
| APK adulterado (assinatura / checksum) | 1520 | Tela de bloqueio não-dispensável, app encerrado |
| mTLS — certificate pinning | 1515 | Rejeita qualquer CA que não seja a CA privada do projeto |
4. WiFiMaisManager — Topologia e Fluxo
O WiFiMaisManager é o módulo do AeroMaisServer responsável pelo
gerenciamento inteligente da interface WiFi wlan0 do RPi. Ele opera como
uma máquina de dois estados persistidos em
/var/lib/aeromais/wifi_state.json.
stateDiagram-v2
direction TB
[*] --> UNCONFIGURED : Boot · sem wifi_state.json
state UNCONFIGURED {
direction TB
s1 : wlan0 modo STA
s2 : Conecta SSID AutomaisIO
s3 : DHCP client · obtém IP
s4 : Verifica handshake WireGuard a cada 30s
s5 : Plataforma Automais.IO configura WG remotamente
s1 --> s2
s2 --> s3
s3 --> s4
s4 --> s5
}
UNCONFIGURED --> CONFIGURED : 1º handshake WG OK\n→ persiste wifi_state.json\n(transição permanente)
state CONFIGURED {
direction TB
ap : wlan0 modo AP\nSSID AeroMais-XXYYZZ\nIP 10.254.254.1/24\niptables DROP ALL (wlan0)
relay : Sub-loop · Relay WireGuard\nvia AeromaisApp\nWhitelist dinâmica por IP
ap --> relay : Tablet conecta ao AP
relay --> ap : Relay encerra
}
Por que o AP é aberto (sem senha WPA)?
O AP intencionalmente não usa senha WPA porque a segurança é garantida por mecanismo superior: iptables com política DROP total. Qualquer cliente que se conectar ao AP recebe um IP, mas nenhum pacote é aceito. O acesso à rede só é liberado para o IP do dispositivo que estabelecer o túnel WireGuard de relay — e mesmo assim, somente os pacotes necessários para o relay.
Isso elimina o problema clássico de "senha WiFi anotada/esquecida" em ambientes industriais, mantendo uma segurança superior à de uma senha WPA2 estática.
Captive Portal — 302 intencional (não 204)
O servidor HTTP do AP responde ao probe de conectividade do Android
(GET /generate_204) com HTTP 302 (redirect), nunca com
204. A distinção é crítica para o funcionamento do relay:
| Resposta ao probe | Interpretação do Android | Efeito no roteamento |
|---|---|---|
204 No Content |
"Esta rede tem internet" | ❌ Android roteia TODO o tráfego pelo WiFi. Outros apps perdem internet. Relay UDP tenta sair pelo WiFi (CPMais sem internet) → handshake WireGuard nunca completa. |
302 Found |
"Captive portal detectado" | ✅ Android mostra notificação "Fazer login no WiFi" (ignorável). Mantém o 4G como rota padrão para todos os apps e sistema. Relay UDP e tráfego do sistema continuam usando celular normalmente. |
O 302 aponta para http://10.254.254.1/ onde o captive portal
serve uma página informativa sobre o AP AeroMais. Ao abrir a notificação, o operador
vê "Conectado ao ponto de acesso AeroMais — internet disponível via rede celular".
A notificação pode ser simplesmente ignorada — a conexão WiFi ao CPMais permanece
ativa e funcional.
Arquivos gerados em runtime
| Arquivo | Descrição |
|---|---|
/var/lib/aeromais/wifi_state.json | Estado persistido (unconfigured / configured) |
/etc/hostapd/aeromais-ap.conf | Configuração do AP hostapd |
/etc/dnsmasq.d/aeromais-ap.conf | Configuração DHCP para o AP |
/etc/wireguard/wg-automais-ap.conf | Config WireGuard de relay (gerada dinamicamente, nunca persistida entre usos) |
/var/log/wifimaismanager.log | Log completo do módulo |
5. PKI e mTLS — Arquitetura de Certificados
O uso de IP fixo local inviabiliza Let's Encrypt (que não emite para IPs). A solução adotada é uma CA privada com emissão dinâmica via PKI Service da plataforma Automais.IO, com a chave privada da CA armazenada e gerenciada internamente pela plataforma Automais.IO.
flowchart LR
KV["🔐 Automais.IO\nCA RSA-4096\n(gerenciada internamente)"]
subgraph api["Plataforma Automais.IO · PKI Service"]
CA["CA pública\n(pinning Flutter + RPi)"]
ENR["Enrollment\nCSR + token OTP → Certificado"]
REN["Renovação\nmTLS → Cert renovado"]
REV["Revogação\n→ Atualiza CRL"]
CRL["CRL\n(Certificate Revocation List)"]
end
PG[("Audit log\ncertificados emitidos\ntokens de enrollment")]
KV-->|"assina"| ENR
KV-->|"assina"| REN
KV-->|"assina"| REV
ENR---PG
REN---PG
REV---PG
Fluxo de Enrollment do RPi
| # | Etapa | Quem executa | Segurança |
|---|---|---|---|
| 1 | Gera keypair RSA local (openssl req -newkey rsa:2048) | RPi (install.py) | Chave privada NUNCA sai do RPi |
| 2 | Envia CSR + enrollment token para o PKI Service | RPi | Token OTP, TTL 15min, uso único, hash SHA-256 no banco |
| 3 | Valida token + CSR, assina internamente e retorna certificado | Plataforma Automais.IO | Rate limit por IP |
| 4 | Salva server.crt, ca.crt, remove server.csr | RPi | Permissões 0600 nos arquivos |
| 5 | Configura Nginx com mTLS usando os certificados | RPi | Nginx verifica CRL a cada request |
| 6 | Cron job diário verifica renew_after | RPi | Renovação automática sem novo token (usa mTLS) |
Problemas resolvidos pela arquitetura PKI
| Achado típico de pentest | Severidade | Solução nesta arquitetura |
|---|---|---|
| Chave privada embarcada no APK | Crítica | Chave gerada no dispositivo, nunca transmitida |
| Certificado estático no APK | Alta | Emissão dinâmica via CSR com enrollment token |
| Ausência de revogação | Alta | CRL endpoint + Nginx verifica a cada request |
| Sem auditoria de emissão | Média | Audit log completo de todos os certificados emitidos, renovados e revogados |
| Replay de credenciais | Alta | Tokens OTP com TTL 15min, uso único |
| CA key acessível em disco | Crítica | Chave gerenciada internamente pela plataforma Automais.IO — não exposta ao AeroMais |
6. Pendências PTRZN — Visão Geral
Cada item PTRZN representa uma pendência de segurança identificada durante o desenvolvimento. As seções a seguir detalham o problema original, a solução implementada e como validar a correção.
Problema
As respostas HTTP incluíam o header Server: Werkzeug/3.1.5 Python/3.12.3, revelando framework, versão e runtime para qualquer atacante que inspecionasse as respostas. Isso permite pesquisa direcionada de CVEs para a versão exata.
Evidência do pentest
# Antes da correção:
HTTP/1.1 500 INTERNAL SERVER ERROR
Server: Werkzeug/3.1.5 Python/3.12.3
Solução
Remoção ativa no hook @app.after_request em security_headers.py e server_tokens off no Nginx:
# security_headers.py
if 'Server' in response.headers:
del response.headers['Server']
if 'X-Powered-By' in response.headers:
del response.headers['X-Powered-By']
# /etc/nginx/nginx.conf
http {
server_tokens off; # PTRZN-1516
}
Resultado
# Depois da correção:
HTTP/1.1 200 OK
(header "Server" ausente)
Problema
O app não verificava se o dispositivo estava rooteado ou era um emulador. Dispositivos rooteados permitem interceptação via Frida/Xposed, bypass de certificate pinning e extração de dados sensíveis.
Detecção de Root — 5 camadas independentes (SecurityPlugin.kt)
| # | Estratégia | O que verifica |
|---|---|---|
| 1 | Arquivos / binários | su, daemonsu, paths do Magisk, KernelSU, APatch |
| 2 | Apps instalados | ~20 pacotes: com.topjohnwu.magisk, eu.chainfire.supersu, Xposed... |
| 3 | Build tags | Build.TAGS contendo test-keys |
| 4 | Sistema gravável | File("/system").canWrite() |
| 5 | Binário su via which | Runtime.exec(["which", "su"]) |
Detecção de Emulador — 4 camadas (SecurityPlugin.kt)
| # | Estratégia | O que verifica |
|---|---|---|
| 1 | Propriedade ro.kernel.qemu | getSystemProperty("ro.kernel.qemu") == "1" |
| 2 | Build strings | Build.MODEL, BUILD.HARDWARE, Build.FINGERPRINT |
| 3 | Arquivos QEMU | /dev/socket/qemud, /dev/qemu_pipe |
| 4 | Fingerprint genérica | Começa com generic/unknown sem release-keys |
Build.MANUFACTURER="Google". Incluir "google" na lista causaria falso positivo bloqueando usuários legítimos. A detecção AVD é coberta pelas outras três estratégias.
Comportamento por tipo de dispositivo
| Tipo de Dispositivo | Resultado |
|---|---|
| Android físico sem root | ✅ App inicia normalmente |
| Android físico rooteado (Magisk) | 🚫 Tela "Dispositivo Rooteado" — app encerrado |
| Google Pixel físico (sem root) | ✅ App inicia normalmente |
| AVD / Android Studio Emulator | 🚫 Tela "Emulador Detectado" — app encerrado |
| Genymotion / BlueStacks / NoxPlayer | 🚫 Tela "Emulador Detectado" — app encerrado |
Problema
O APK poderia ser decompilado com apktool, modificado e reempacotado sem que o app detectasse a adulteração. Um APK adulterado pode remover verificações de segurança, injetar código malicioso ou modificar endpoints da API.
Solução
O SecurityChecker (integrado ao fluxo PTRZN-1517/1518) inclui verificação de integridade do APK. Se detectado APK adulterado (securityResult.isTampered), o mesmo fluxo de bloqueio é ativado: SecurityBlockedApp com PopScope(canPop: false).
// main.dart — executado antes de qualquer init
if (securityResult.isTampered) {
runApp(SecurityBlockedApp(reason: 'APK Adulterado'));
return; // nada mais é executado
}
Evidência para pentest
# Demonstrar que APK modificado é rejeitado
apktool d aeromais.apk -o apk_extracted
# Modificar qualquer arquivo e reempacotar
apktool b apk_extracted -o aeromais_mod.apk
# Instalar e executar → Tela de bloqueio "APK Adulterado"
# Demonstrar que não há chave privada embarcada
grep -r "BEGIN PRIVATE KEY" apk_extracted/
# Resultado esperado: sem saída (vazio)
Headers implementados (dupla camada Flask + Nginx)
| Header | Valor | Ataque mitigado |
|---|---|---|
X-Frame-Options | SAMEORIGIN | Clickjacking via iframe |
X-Content-Type-Options | nosniff | MIME type sniffing |
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | MITM / downgrade HTTP |
Content-Security-Policy | default-src 'self'; ... | XSS, injeção de recursos externos |
Referrer-Policy | strict-origin-when-cross-origin | Vazamento de dados via Referer |
Permissions-Policy | geolocation, microphone, camera... bloqueados | APIs sensíveis do browser |
Cross-Origin-Embedder-Policy | require-corp | Isolamento de recursos incorporados |
Cross-Origin-Resource-Policy | same-origin | Leitura cross-origin |
Cross-Origin-Opener-Policy | same-origin | Acesso cross-origin ao objeto window |
Problema
Blocos except retornavam str(e) diretamente ao cliente, podendo expor caminhos de arquivo, nomes de módulos, detalhes de queries SQL e outros detalhes internos.
Evidência do pentest
# Requisição com Content-Type JSON mas body form-encoded
curl -X POST http://192.168.1.12:5000/api/auth/login \
-H 'Content-Type: application/json' \
--data-binary 'username=Administrador&password=admin'
# Antes: HTTP 500 (JSONDecodeError potencialmente exposto)
# Depois: HTTP 400 {"error": "Dados inválidos"}
Solução: módulo error_handler.py centralizado
- Stack trace completo logado apenas no servidor
- Cliente recebe apenas mensagem genérica e segura
- Handlers globais Flask para 404, 405, 500 e exceções não tratadas
- HTTP status codes semânticos corretos (400, 401, 403, 404, 422, 500, 503)
Arquivos corrigidos (varredura completa)
~18+ blocos except em: routes.py, auth.py, afericao.py, interface.py, wifimaismanager.py, cpmaisio-emulator.py
Flags configuradas
| Flag | Valor | Proteção |
|---|---|---|
Secure | True em produção/Nginx | Cookie não transmitido em HTTP puro |
HttpOnly | True sempre | Cookie não acessível via JavaScript |
SameSite | Lax | Proteção CSRF |
Resultado esperado no header de resposta
Set-Cookie: aeromais_session=<valor>; Secure; HttpOnly; SameSite=Lax; Path=/
webview_flutter — o WebView respeita automaticamente as flags retornadas pelo servidor. Não foi necessária nenhuma alteração no código Dart.
Problema
O hash de senha usava MD5 — algoritmo quebrado, reversível com rainbow tables e trivialmente bruteforçável em hardware moderno.
Solução
# auth.py — verificação com bcrypt puro (PTRZN-1525: MD5 removido)
return bcrypt.checkpw(senha.encode('utf-8'), result['senha'].encode('utf-8'))
O bcrypt usa salt automático e cost factor ajustável. Hashes MD5 existentes foram migrados na atualização do banco.
Segredos movidos para variáveis de ambiente
| Segredo | Variável de ambiente |
|---|---|
| JWT secret key | JWT_SECRET |
| Flask session secret | FLASK_SECRET_KEY |
| DB password | DB_PASSWORD |
| Cookie flags | SESSION_COOKIE_SECURE, SESSION_COOKIE_SAMESITE |
Gerenciado via python-dotenv com arquivo .env não versionado (no .gitignore).
Validações implementadas
- Comprimento mínimo e máximo (32 chars)
- Exigência de letra maiúscula, minúscula, número e símbolo
- Bloqueio de sequências numéricas (
123,987) - Bloqueio de sequências alfabéticas (
abc,xyz) - Bloqueio de caracteres repetidos consecutivos (
aaa,111) - Bloqueio de padrões repetitivos (
121212,ababab)
Problema
A API retornava IDs numéricos auto-incrementais do banco ({"id": 1}), permitindo enumeração de usuários e tentativas de IDOR (DELETE /api/usuarios/1).
Solução: RefManager — referências efêmeras por sessão
Um hash de referência efêmero (HMAC-SHA256(session_secret + session_id, entity_type + real_id)),
truncado em 12 caracteres, substitui os IDs reais. As refs são por sessão — inválidas em outras sessões
e limpas no logout.
# ANTES — ID real exposto na URL
GET /api/usuarios → [{"id": 1, "nome": "João"}, {"id": 2, "nome": "Maria"}]
DELETE /api/usuarios/1 ← ID real previsível, enumerável
# DEPOIS — ref efêmera por sessão
GET /api/usuarios → [{"ref": "a3f7b2", "nome": "João"}, {"ref": "9c1d44", "nome": "Maria"}]
DELETE /api/usuarios/a3f7b2 ← válida somente nesta sessão
# No servidor:
# resolve("a3f7b2") → real_id = 1 → executa DELETE WHERE id = 1
# ref de outra sessão → HTTP 403 Forbidden
ref_manager.py está implementado. A integração completa nos endpoints e frontend está em andamento.
Verificação de duplicidade de usuário via tratamento de exceção no banco. Resposta de sucesso usa get_usuario_publico() que retorna apenas dados sem o hash de senha — o hash nunca é exposto na API.
# HTTP 409 Conflict se usuário já existe
# HTTP 400 Bad Request se senha não atende à política
# Resposta de sucesso: {"id": X, "nome": "...", "nivel_acesso": 2}
# (sem campo "senha")
Problema
O logout apenas chamava session.clear() no cliente. O servidor não invalidava a sessão — um atacante com o cookie poderia continuar usando-o após o logout.
Solução: SessionManager com lista de revogação server-side
- Cada login gera um
session_idúnico (secrets.token_urlsafe(32)) - O
SessionManagermantém dicionários de sessões ativas e revogadas em memória - Cada endpoint protegido verifica se a sessão foi revogada antes de processar
- No logout:
revoke_session(session_id)move a sessão para a lista de revogadas - Limpeza automática de dados antigos a cada 5 minutos
- Suporte a
revoke_all_user_sessions(user_id)— logout em todos os dispositivos
Pontos vulneráveis identificados
| Arquivo | Dado exposto | Vetor |
|---|---|---|
config.js | usuario.nome | innerHTML com template literal |
afericao.html | dado.usuario | innerHTML com template literal |
| Campo nome (API) | Aceita <img src=x onerror=alert()> | Sem restrição de caracteres HTML |
Correções em 3 camadas
| Camada | Correção |
|---|---|
| DOM (frontend) | innerHTML → textContent via função criarCelula() |
| Backend (routes.py) | Regex ^[a-zA-Z0-9 \-_àáâã...]+$ no campo nome; max_length=20 |
| Banco de dados | Coluna nome VARCHAR(20); script de migração incluído |
Limite de senha — justificativa da decisão de 32 caracteres
A senha armazena sempre o hash bcrypt (60 chars fixos). Limitar a senha a 10 chars não traz ganho de segurança. O limite de 32 chars bloqueia DoS via hashing de strings enormes, sem prejudicar passphrases (SolarSys!2026 cabe em 32).
Módulo centralizado input_validator.py aplicado em todos os endpoints críticos.
Valida tipo, comprimento mínimo/máximo, padrão regex e caracteres proibidos antes de qualquer
processamento. Inclui validação de que request.get_json() não retorna None
(Content-Type inválido) antes de acessar campos.
# Exemplo de uso em routes.py
input_validator = get_input_validator()
username = input_validator.validate_string(
data.get('username', ''),
field_name='username',
min_length=1, max_length=20,
pattern=r'^[a-zA-Z0-9 \-_àáâã...]+$'
)
Comportamento do RateLimiter
- Conta tentativas falhadas por usuário/IP
- Bloqueia temporariamente após N falhas consecutivas
- Header
Retry-Afterinforma quando pode tentar novamente - Login bem-sucedido reseta o contador
- Proteção contra enumeração de usuários (tempo de resposta constante)
Objetivo
Permitir visualização remota do app Android por suporte técnico, via VPN WireGuard, sem depender de root, modo desenvolvedor ou ADB.
Dois métodos de captura implementados
| Método | Permissão | Conteúdo | Fallback |
|---|---|---|---|
| Método 1: RepaintBoundary | Nenhuma | Widget tree Flutter (PNG) | Sempre disponível |
| Método 2: MediaProjection | 1 toque (padrão Android) | Tela inteira (JPEG) | Cai para M1 se falhar |
Segurança da captura
- Bytes ficam em memória — sem transmissão automática para lugar nenhum
- A transmissão (PTRZN-1541) será via WireGuard + sessão Flask autenticada
- SecurityChecker (PTRZN-1517/18) bloqueia em dispositivos rooteados antes do código de captura
- Android exige notificação persistente para MediaProjection — operador sempre sabe que captura está ativa
7. Objetivos de Segurança — Demonstração para Pentest
Checklist de controles implementados
- mTLS: Toda conexão ao servidor exige certificado de cliente válido (RPi/App). Conexão sem cert = 400 na camada TLS, Flask nunca é alcançado.
- PKI dinâmica: Chave privada gerada no dispositivo. Enrollment token OTP (TTL 15min, uso único). CA key gerenciada internamente pela plataforma Automais.IO.
- bcrypt: Senhas armazenadas com bcrypt. MD5 eliminado.
- Secrets em variáveis de ambiente: JWT_SECRET, FLASK_SECRET_KEY, DB_PASSWORD — nunca hardcoded.
- Session invalidation: Logout revoga sessão server-side. Cookie antigo é inválido imediatamente.
- Cookie flags: Secure + HttpOnly + SameSite=Lax em produção.
- Security headers: CSP, HSTS, X-Frame-Options, CORP, COEP, COOP em dupla camada (Flask + Nginx).
- Rate limiting: Brute force bloqueado no login.
- XSS: innerHTML → textContent; regex no campo nome; maxlength em 3 camadas (DOM, backend, banco).
- Error handling: Stack traces nunca expostos ao cliente. Detalhes logados apenas no servidor.
- Input validation: Centralizada, com tipo, comprimento, padrão e Content-Type validation.
- Server fingerprinting: Headers Server e X-Powered-By removidos. server_tokens off no Nginx.
- Root/emulator detection: 5+4 estratégias independentes; bloqueio total antes de qualquer código sensível.
- APK integrity: Anti-tamper verificado na inicialização.
- Renovação de certificados: Automática no RPi (cron) e no app Flutter (na abertura).
- CRL: Nginx verifica CRL a cada request. CRL atualizada imediatamente após revogação.
- Relay UDP estável — dual-socket:
_socketLocal(WiFi AP) recebe do CPMais;_socketRemote(IP celular) fala comautomais.io. Handshake WireGuard completa mesmo quando o Android rotearia o tráfego via WiFi. - 4G mantido para sistema e outros apps: Captive portal retorna
302ao probe/generate_204. Android mantém celular como rota padrão de internet — outros apps e o SO não perdem conectividade ao conectar no AP do CPMais. - IDOR (PTRZN-1528): RefManager implementado — integração em andamento.
8. Dificuldades Encontradas no Processo de Segurança
8.1 Ambiente de Desenvolvimento vs. Produção
Um dos maiores desafios foi garantir que controles como SESSION_COOKIE_SECURE=True
e HSTS ficassem ativados apenas em produção — em desenvolvimento local com HTTP puro,
essas flags quebram o fluxo. A solução foi lógica condicional em config.py:
detectar automaticamente se NGINX_DOMAIN está definido (indicador de produção)
sem precisar setar ENVIRONMENT=production manualmente.
8.2 Cobertura Total de Error Handling
A primeira rodada do PTRZN-1522 corrigiu os blocos mais óbvios. Somente uma varredura
manual completa (revisão 2) revelou pontos adicionais que retornavam str(e):
em reiniciar_abastecimento, no emulador SPI, nas rotas do encoder e no
WiFiManager. Isso ilustra a dificuldade de garantir cobertura total sem uma política
de "proibido usar str(e) em responses" testada por lint.
Traceback, File "..., Exception:.
8.3 Falso Positivo em Detecção de Emulador
A lista inicial de fabricantes de emuladores incluía "google", causando bloqueio em dispositivos Pixel físicos durante testes. A correção (remover "google" da lista) exigiu análise cuidadosa de quais estratégias de detecção alternativas cobriam o AVD sem afetar hardware real.
8.4 Garantir que mTLS se aplica a TODOS os endpoints
O Nginx com ssl_verify_client on garante mTLS globalmente. O risco estava em
endpoints Flask que poderiam ser alcançados diretamente (se o Flask escutasse em
0.0.0.0). A correção (PTRZN-1515) foi garantir que Flask escuta apenas em
127.0.0.1, tornando o Nginx o único ponto de entrada.
8.5 AP WiFi sem senha como vetor de ataque percebido
A decisão de manter o AP sem senha WPA é contraintuitiva e provavelmente será questionada
pela equipe de pentest. A justificativa técnica — iptables DROP ALL é superior
a uma senha WPA estática em ambiente industrial — precisa ser demonstrada com evidências
das regras de firewall ativas, não apenas documentada.
8.6 IDOR com IDs auto-incrementais
A solução RefManager (PTRZN-1528) exigiu refatoração do frontend e do backend em paralelo. O desafio foi garantir que a camada de resolução de refs fosse transparente para o WebView (que roda JavaScript servido pelo backend) sem quebrar a funcionalidade existente. Esta é a única pendência ainda em desenvolvimento.
8.7 Roteamento Android — WiFi vs. 4G no AP do CPMais
Um comportamento OEM não-padronizado causou instabilidade no relay por semanas: alguns dispositivos Android, ao se conectar a um AP sem internet e confirmar "conectar mesmo assim", passam a rotear todo o tráfego pelo WiFi — incluindo os pacotes UDP do relay. Como o CPMais não tem saída para a internet, os pacotes eram silenciosamente descartados no RPi.
O problema se manifestava como: relay conecta → conta 1 pacote recebido + 1 enviado → para → status cai para "aguardando" → tenta novamente após 60 s. Esse ciclo repetia indefinidamente sem nunca completar o handshake WireGuard.
A causa raiz era dupla: (1) roteamento pelo WiFi, e (2) o dnsmasq
com address=/#/10.254.254.1 fazia automais.io resolver
para 10.254.254.1, tornando o relay incapaz de distinguir pacotes
do CPMais de respostas do servidor remoto.
302
(mantém 4G como rota padrão), relay usa dual-socket separando WiFi de celular, e DNS
de automais.io é resolvido via ConnectivityManager da rede
celular, ignorando o dnsmasq do AP.
8.8 Dificuldade de demonstrar segurança "máxima" para pentest
Demonstrar que objetivos de segurança foram atingidos não é trivial porque:
- Segurança negativa — provar que algo não acontece é mais difícil que provar que algo acontece
- Profundidade de camadas — mTLS + sessão + rate limiting + bcrypt + cookie flags são interdependentes; testar uma camada isoladamente não simula o ataque real
- Ambiente embarcado — certificados para IPs locais, WireGuard, AP sem senha — cada elemento é justificável mas incomum, podendo ser marcado como "finding" antes de entender o contexto
9. Evidências para o Relatório de Pentest
9.1 mTLS — rejeição de conexão sem certificado
# Deve retornar erro SSL (400 no Nginx)
curl -v https://<IP_RPI>/api/configuracoes
# Esperado: SSL handshake error — 400 No required SSL certificate was sent
# Com certificado de cliente — deve funcionar
curl -v \
--cacert certs/ca.crt \
--cert certs/client.crt \
--key certs/client.key \
https://<IP_RPI>/api/configuracoes
9.2 Enrollment token de uso único
# A plataforma Automais.IO valida que cada token só pode ser usado uma vez.
# Um segundo uso do mesmo token deve retornar 401 — verificável no log do RPi:
# install.py: "Enrollment falhou: 401 — token inválido, expirado ou já usado"
# Evidência indireta no RPi:
sudo cat /etc/aeromais/certs/.renewal_info.json
# Deve conter issued_at e expires_at do certificado emitido com sucesso
9.3 Stack trace não exposto
# Content-Type JSON com body form-encoded
curl -X POST https://<IP_RPI>/api/auth/login \
-H 'Content-Type: application/json' \
--data-binary 'username=Administrador&password=admin'
# Esperado: HTTP 400 {"error": "Dados inválidos"}
# NÃO deve aparecer: Traceback, File ", Exception, str(e)
9.4 Cookie com flags de segurança
# Verificar header Set-Cookie no login
curl -v -X POST https://<IP_RPI>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"..."}'
# Esperado: Set-Cookie: aeromais_session=...; Secure; HttpOnly; SameSite=Lax
9.5 Sessão invalidada após logout
# 1. Fazer login e guardar o cookie
# 2. Fazer logout
# 3. Tentar usar o cookie antigo em qualquer endpoint protegido
# Esperado: HTTP 401 {"authenticated": false}
9.6 Rate limiting no login
# Tentar login com senha errada repetidamente
for i in {1..10}; do
curl -X POST https://<IP_RPI>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"errada"}'
done
# Após N tentativas: HTTP 429 Too Many Requests
# Header Retry-After presente na resposta
9.7 Ausência de chave privada no APK
apktool d aeromais.apk -o apk_extracted
grep -r "BEGIN PRIVATE KEY" apk_extracted/
grep -r "BEGIN RSA PRIVATE KEY" apk_extracted/
# Resultado esperado: sem saída (vazio)
9.8 Headers de segurança presentes
curl -I https://<IP_RPI>/
# Devem aparecer:
# X-Frame-Options: SAMEORIGIN
# X-Content-Type-Options: nosniff
# Strict-Transport-Security: max-age=31536000...
# Content-Security-Policy: default-src 'self'...
# Cross-Origin-Embedder-Policy: require-corp
# Cross-Origin-Resource-Policy: same-origin
# Cross-Origin-Opener-Policy: same-origin
# NÃO deve aparecer: Server: (com versão)
9.9 Firewall iptables no AP WiFi
# No RPi em modo AP, verificar regras ativas
sudo iptables -L INPUT -n -v | grep wlan0
# Esperado: policy DROP para wlan0
# Verificar que somente IPs whitelistados têm ACCEPT
sudo iptables -L INPUT -n -v | grep ACCEPT
9.10 Relay UDP — dual-socket e 4G isolado
# 1. Conectar tablet ao AP AeroMais-XXYYZZ
# 2. Confirmar "conectar sem internet" se solicitado
# 3. No AeromaisApp, abrir tela do relay UDP
#
# Verificar que o relay ativou dual-socket:
# - "dual-socket: WiFi 10.254.254.x:51821 → automais.io(IP_REAL):51820 via IP_CELULAR"
#
# 4. Em outro app do tablet (ex: browser), acessar qualquer site
# → deve carregar normalmente (internet via 4G ativa)
#
# 5. Aguardar handshake WireGuard no log do RPi:
# sudo journalctl -u wifimaismanager -f
# → "Relay ativo: 10.254.254.X — rota padrão e whitelist aplicadas"
# → "Handshake wg-automais-ap confirmado via 10.254.254.X"
# Verificar que 302 é retornado pelo captive portal (não 204):
curl -v http://10.254.254.1/generate_204
# Esperado: HTTP/1.1 302 Found
# Location: http://10.254.254.1/
# (NÃO deve retornar 204 — 204 causaria roteamento via WiFi)
10. Pendências Abertas e Próximos Passos
| Item | PTRZN | Descrição | Prioridade |
|---|---|---|---|
| IDOR / RefManager | 1528 | Integração completa do RefManager em todos os endpoints e frontend. Módulo implementado, integração em andamento. | Alta |
| Remote View Viewer | 1541 | Implementar o viewer do remote view (WebSocket/WebRTC + autenticação Flask + frontend HTML). Side Android já implementado. | Média |
| Play Integrity API | 1517 | Camada adicional de atestação via Google para cobertura de Magisk DenyList (quando Google Play Services disponível). | Baixa |
| CSP sem unsafe-inline | 1521 | Migrar para nonces ou hashes CSP, eliminando 'unsafe-inline' e 'unsafe-eval' da política. |
Baixa |
| Lint para str(e) em responses | 1522 | Criar teste automatizado que verifica ausência de Traceback / str(e) em respostas de erro de integração. |
Média |
| HSTS Preload | 1521 | Submeter domínio ao HSTS Preload List (requer max-age ≥ 1 ano em produção estável). |
Baixa |