Monday, August 17, 2026

Comprei um water cooler sem suporte para Linux. Então fiz engenharia reversa do software.

DEV Community just published something on Comprei um water cooler sem suporte — here's a quick summary before you dive in.

Quando comprei um Water Cooler Pichau Aqua 120X, a ideia era simples: resfriar e usar o display LCD para mostrar a temperatura da CPU.

O problema apareceu quando fui procurar o software.

O fabricante disponibilizava uma aplicação para Windows, mas não existia suporte nativo para Linux. O hardware funcionava normalmente como dispositivo USB, mas o display não recebia nenhuma informação.

Em outras palavras:

O Linux enxergava o dispositivo. Eu só não sabia como conversar com ele.

A solução mais óbvia seria encontrar uma aplicação compatível ou uma biblioteca pronta. Como não encontrei algo que funcionasse com esse modelo específico, resolvi seguir outro caminho: descobrir como o software oficial fazia isso.

Obs: Foi a minha primeira experiência prática de engenharia reversa.

Este artigo é um relato técnico do processo: desde a identificação da interface usada pelo dispositivo até a descoberta do formato dos bytes enviados ao LCD e a criação de um script para atualizar a temperatura da CPU periodicamente.

O primeiro passo: descobrir como o dispositivo aparecia no Linux

Antes de pensar em protocolo, eu precisava entender como o water cooler era exposto pelo sistema.

O dispositivo apareceu como:

/dev/ttyACM0

Isso já era uma pista importante.

Em vez de precisar falar diretamente com USB através de uma biblioteca específica, aparentemente existia uma interface serial disponível no sistema.

Fiz alguns testes básicos:

ls -l /dev/ttyACM0

e também:

sudo fuser -v /dev/ttyACM0

A partir daí, a pergunta era:

Quais bytes o software oficial envia para /dev/ttyACM0?

Procurando pistas no software oficial

Como o fabricante disponibilizava um executável para Windows, comecei analisando o binário no Ghidra.

Eu nunca tinha feito engenharia reversa antes, então o processo foi bastante incremental.

Primeiro procurei por strings relacionadas às informações que eu imaginava que o software poderia enviar ao dispositivo.

Encontrei referências como:

set_send_cpu_temperature
set_send_cpu_frequency
set_send_cpu_utilization
set_send_cpu_fan_speed

set_send_gpu_frequency
set_send_gpu_utilization
set_send_gpu_fan_speed

Isso indicava que o programa provavelmente conseguia enviar várias métricas para o display.

Também encontrei referências ao QSerialPort, parte do framework Qt usada para comunicação serial.

A partir daí, comecei a seguir as referências no Ghidra.

Descobrindo como o software encontra o dispositivo

Uma das funções encontradas fazia algo conceitualmente parecido com isso:

QSerialPortInfo::availablePorts();

Depois percorria as portas disponíveis e comparava o Vendor ID e o Product ID do dispositivo.

O software tentava duas combinações:

VID: 1f3a
PID: 0008

e, caso não encontrasse essa:

VID: 1a86
PID: 484a

Depois de localizar o dispositivo, configurava a comunicação serial com:

Baud rate: 1000000
Data bits: 8
Parity: none
Stop bits: 1
Flow control: none

Ou seja:

1.000.000 baud
8N1

Essa foi uma descoberta importante.

Eu já sabia que existia uma interface serial no Linux, e agora tinha descoberto a configuração usada pelo software oficial.

Mas ainda faltava a parte mais importante:

Qual era o formato dos dados enviados?

Seguindo o fluxo até a construção do pacote

A função que inicializava o dispositivo era chamada por outra rotina maior.

Seguindo as chamadas, encontrei uma função responsável por montar um QByteArray.

Foi aqui que o protocolo começou a aparecer.

A rotina fazia algo parecido com:

QByteArray::clear(buffer);

QByteArray::append(buffer, 't');
QByteArray::append(buffer, flag);
QByteArray::append(buffer, value1);
QByteArray::append(buffer, value2_high);
QByteArray::append(buffer, value2_low);

// ...

Convertendo a sequência de append() para um layout de bytes, chegamos a um pacote de 23 bytes.

A estrutura era:

Offset Tipo
0 byte fixo: 't' (0x74)
1 flag
2 campo de 8 bits
3–4 campo de 16 bits
5 campo de 8 bits
6–7 campo de 16 bits
8 campo de 8 bits
9–10 campo de 16 bits
11–14 campo de 32 bits
15–18 campo de 32 bits
19–20 campo de 16 bits
21 campo de 8 bits
22 campo de 8 bits

Os valores de múltiplos bytes eram montados manualmente usando shifts, o que mostrou que estavam sendo enviados em big-endian.

Por exemplo, um valor de 16 bits aparecia conceitualmente assim:

append(value >> 8);
append(value);

Já os valores de 32 bits seguiam:

append(value >> 24);
append(value >> 16);
append(value >> 8);
append(value);

Nesse momento já tínhamos o esqueleto completo do pacote.

Mas havia mais uma transformação.

O XOR escondido no meio da função

Depois de montar o QByteArray, a função percorria os bytes do pacote e aplicava:

byte ^= 0x02;

Inicialmente o código parecia um pouco confuso porque o compilador havia otimizado parte do loop usando operações de 32 bits.

Mas, reduzindo aquilo à lógica real, o comportamento era simples:

byte 0 → não é alterado
byte 1 → não é alterado

byte 2 até byte 22 → XOR 0x02

Então o protocolo efetivamente era:

[ 0x74 ][ flag ][ payload XOR 0x02 ]

onde o payload possui 21 bytes.

Essa parte foi especialmente interessante porque agora era possível reproduzir um pacote sem sequer precisar saber o significado de todos os campos.

Bastava:

  1. Montar os 23 bytes.
  2. Manter os dois primeiros intactos.
  3. Aplicar XOR 0x02 nos bytes restantes.
  4. Escrever o resultado na porta serial.

O primeiro pacote enviado manualmente

Com essa estrutura em mãos, fiz o teste mais simples possível.

Se todos os campos do payload fossem zero antes do XOR, eles se tornariam 0x02 depois da transformação.

Então o pacote seria:

74 00 02 02 02 02 02 02 02 02 02
02 02 02 02 02 02 02 02 02 02 02 02

No Linux, enviei diretamente para o dispositivo usando:

printf '\x74\x00\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02' \
  | sudo tee /dev/ttyACM0 > /dev/null

O resultado no display foi:

00

Esse foi o momento em que ficou claro que a estrutura estava funcionando.

O hardware estava respondendo ao pacote.

Agora era necessário descobrir qual campo controlava o valor mostrado no LCD.

Testando os campos um por um

Como eu sabia que o pacote aplicava XOR 0x02, não podia simplesmente colocar o valor decimal diretamente no byte enviado.

Por exemplo, para mostrar:

50

o valor decimal é:

50 = 0x32

Mas o protocolo aplica:

0x32 XOR 0x02 = 0x30

Portanto, o byte efetivamente transmitido deveria ser:

0x30

Para testar o primeiro campo do payload, o pacote ficava:

74 00 30 02 02 02 02 ...

ou, no shell:

printf '\x74\x00\x30\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02' \
  | sudo tee /dev/ttyACM0 > /dev/null

E funcionou.

O display mostrou:

50

Isso confirmou que:

Offset 0 → 't'
Offset 1 → flag
Offset 2 → valor exibido

E o valor do offset 2 era enviado com:

valor XOR 0x02

No código original, esse byte vinha de um campo interno da estrutura:

param_1 + 0x1c

Eu ainda não precisava saber o nome original do campo.

Experimentalmente, já tinha descoberto o que ele fazia.

Transformando o teste em um script

Depois de confirmar que o byte controlava o display, criei um pequeno script de linha de comando.

A ideia era simples:

temperatura decimal
        ↓
XOR 0x02
        ↓
montar pacote
        ↓
escrever em /dev/ttyACM0

Uma versão simplificada:

#!/usr/bin/env bash

PORT="/dev/ttyACM0"
TEMP="$1"

if [[ -z "$TEMP" ]]; then
    echo "Uso: $0 <temperatura>"
    exit 1
fi

if ! [[ "$TEMP" =~ ^[0-9]+$ ]] || (( TEMP < 0 || TEMP > 99 )); then
    echo "Temperatura deve estar entre 0 e 99."
    exit 1
fi

ENCODED=$((TEMP ^ 2))

printf -v TEMP_BYTE '\\x%02x' "$ENCODED"

FRAME="\x74\x00${TEMP_BYTE}"

# Os demais campos permanecem zerados antes do XOR,
# portanto são enviados como 0x02.
FRAME+="\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02"
FRAME+="\x02\x02\x02\x02\x02\x02\x02\x02\x02\x02"

printf "$FRAME" > "$PORT"

Assim:

./aqua120x 50

fazia o display mostrar:

50

Pegando a temperatura diretamente do kernel

O próximo passo era parar de enviar valores manualmente.

Eu queria mostrar a temperatura real da CPU.

A primeira opção seria chamar:

sensors

e filtrar a saída.

Mas isso seria executar um programa completo repetidamente apenas para ler um valor que o próprio kernel já disponibiliza.

No Linux, sensores de hardware são expostos através do sysfs.

No meu caso, o sensor coretemp estava disponível em:

/sys/class/hwmon/hwmonX/

O arquivo:

temp1_input

retornava algo como:

35000

O valor está em milésimos de grau Celsius.

Então:

35000 / 1000 = 35°C

Para converter diretamente para inteiro:

awk '{print int($1/1000)}' /sys/class/hwmon/hwmonX/temp1_input

O resultado:

35

Agora bastava passar essa saída para o script:

./aqua120x "$(awk '{print int($1/1000)}' /sys/class/hwmon/hwmonX/temp1_input)"

Atualizando o display periodicamente

Por fim, coloquei o comando para rodar periodicamente.

Uma forma simples de fazer isso foi usando watch:

watch -n 1 -t './aqua120x "$(awk '\''{print int($1/1000)}'\'' /sys/class/hwmon/hwmonX/temp1_input)"'

A cada segundo:

temperatura do kernel
        ↓
awk
        ↓
valor inteiro
        ↓
script
        ↓
XOR
        ↓
pacote de 23 bytes
        ↓
/dev/ttyACM0
        ↓
LCD do water cooler

Para testar a variação da temperatura, também usei:

stress-ng --cpu 1 --cpu-load 50 --timeout 1m

Enquanto o processador recebia uma carga leve, a temperatura variava e o display acompanhava.

O que eu aprendi com isso

O resultado final foi relativamente simples: um pequeno script escrevendo bytes em uma porta serial.

Mas chegar até ele foi a parte interessante.

O processo envolveu:

  • Identificar como o dispositivo era exposto no Linux.
  • Analisar um executável Windows no Ghidra.
  • Encontrar referências a QSerialPort.
  • Descobrir como o software localizava o hardware através de VID/PID.
  • Descobrir a configuração serial: 1000000 8N1.
  • Seguir as chamadas até encontrar a função que montava o pacote.
  • Reconstruir o layout de 23 bytes.
  • Identificar a transformação XOR 0x02.
  • Enviar pacotes manualmente.
  • Alterar os campos individualmente.
  • Descobrir experimentalmente qual byte controlava o valor exibido.
  • Ler a temperatura diretamente do sysfs.
  • Automatizar a atualização do LCD.

Tudo isso sem documentação do protocolo e sem código-fonte.

O mais curioso é que, no começo, parecia que o dispositivo simplesmente "não tinha suporte para Linux".

No fim, ele tinha uma interface serial perfeitamente utilizável.

O que faltava era saber:

Qual sequência de bytes ele esperava receber.

Essa experiência também mudou um pouco a forma como eu vejo engenharia reversa.

Eu imaginava algo muito distante, extremamente complexo e quase inacessível para quem nunca tinha feito isso.

Na prática, pelo menos neste caso, o processo foi muito mais incremental:

encontrar uma pista
      ↓
seguir uma função
      ↓
entender uma pequena parte
      ↓
testar no hardware
      ↓
usar o resultado como próxima pista

No final, um water cooler sem aplicativo para Linux acabou virando um pequeno projeto de engenharia reversa.

E agora o display mostra a temperatura da CPU em tempo real.


Source: DEV Community

Previous Post
Next Post

post written by: