DEV Community

Kleber Silva
Kleber Silva

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

e também:

sudo fuser -v /dev/ttyACM0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

e, caso não encontrasse essa:

VID: 1a86
PID: 484a
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ou seja:

1.000.000 baud
8N1
Enter fullscreen mode Exit fullscreen mode

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);

// ...
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Já os valores de 32 bits seguiam:

append(value >> 24);
append(value >> 16);
append(value >> 8);
append(value);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Então o protocolo efetivamente era:

[ 0x74 ][ flag ][ payload XOR 0x02 ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

O resultado no display foi:

00
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

o valor decimal é:

50 = 0x32
Enter fullscreen mode Exit fullscreen mode

Mas o protocolo aplica:

0x32 XOR 0x02 = 0x30
Enter fullscreen mode Exit fullscreen mode

Portanto, o byte efetivamente transmitido deveria ser:

0x30
Enter fullscreen mode Exit fullscreen mode

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

74 00 30 02 02 02 02 ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

E funcionou.

O display mostrou:

50
Enter fullscreen mode Exit fullscreen mode

Isso confirmou que:

Offset 0 → 't'
Offset 1 → flag
Offset 2 → valor exibido
Enter fullscreen mode Exit fullscreen mode

E o valor do offset 2 era enviado com:

valor XOR 0x02
Enter fullscreen mode Exit fullscreen mode

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

param_1 + 0x1c
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Assim:

./aqua120x 50
Enter fullscreen mode Exit fullscreen mode

fazia o display mostrar:

50
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

O arquivo:

temp1_input
Enter fullscreen mode Exit fullscreen mode

retornava algo como:

35000
Enter fullscreen mode Exit fullscreen mode

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

Então:

35000 / 1000 = 35°C
Enter fullscreen mode Exit fullscreen mode

Para converter diretamente para inteiro:

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

O resultado:

35
Enter fullscreen mode Exit fullscreen mode

Agora bastava passar essa saída para o script:

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

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)"'
Enter fullscreen mode Exit fullscreen mode

A cada segundo:

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

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

stress-ng --cpu 1 --cpu-load 50 --timeout 1m
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)