I have an Oral-B iO. It talks to a phone app over Bluetooth, it has a charger that sits on my WiFi, and at some point I got curious: could someone turn the thing on remotely? Not "flash the LED" remotely, actually start the motor. So I spent a weekend pulling the app apart to find out.
This was all done on my own device, on my own network. The write-up below is static reverse engineering of the companion Android app, plus what I could confirm without touching Procter & Gamble's cloud.
Getting the app
The app is com.pg.oralb.oralbapp. I grabbed the split APK bundle off APKPure and threw it at the usual tools:
./jadx-dist/bin/jadx -d jadx --no-res apk/oralb.apk
java -jar apktool.jar d -s apk/oralb.apk -o apktool/base
It is a hybrid app, Kotlin/Compose with a small Flutter module bolted on. The interesting part is that the BLE logic is not in the Flutter/Dart side (libapp.so). It lives in the Kotlin DEX, in an SDK at codes.alchemy.oralb.blesdk built on top of Polidea's RxAndroidBle. jadx gave me about 28,000 decompiled files to read through, which is where the weekend went.
The BLE protocol
Every characteristic uses the same UUID base and swaps four hex digits per attribute:
A0F0XXXX-5047-4D53-8208-4F72616C2D42
If you decode the tail bytes you get PG / MS / Oral-B, which is a nice little confirmation you are in the right place. The charger (it advertises as "iO Sense") exposes a command/read/write/status service. The brush itself exposes a pile of characteristics: battery, button state, brushing mode, brushing time, pressure, sensor data, RTC, and a Control characteristic (FF21) that everything actuation-related goes through.
So I went looking at what opcodes Control actually supports. From Control.java:
SET_MODE, STOP_TIMER_SIGNAL, RESET_MEMORY_TIMER,
MY_COLOR_ENABLE/DISABLE, MOTION_ENABLE/DISABLE,
HIGH_RESOLUTION_MEASUREMENT, PRESSURE_DISABLE,
RAINBOW_ILLUMINATION_ENABLE/DISABLE/TRIGGER,
CHARGE_ILLUMINATION_ENABLE/DISABLE,
TRIGGER_CONNECTION_ILLUMINATION
Read that list again and notice what is missing. There is no "start motor" opcode. None. You can change the mode, trigger LEDs, toggle sensors, but you cannot tell the brush to physically run. The motor is gated behind the physical button. That already half-answers my question, but I kept going because the cloud side turned out to be the actual story.
The remote path exists (by design)
The charger is an ESP32 on WiFi, and the app provisions it to talk to the cloud. When the app starts up it calls a discovery endpoint:
GET https://pgdiscovery.com/api/v3/app?countryCode=XX
The response (DiscoveryV3) hands back region, userPoolId, identityPoolId, appClientId, appClientSecret, mqttUrl, mqttPort, rootCaUrl, firmwareToken, and apiUrl. That is an AWS IoT Core plus Cognito backend. The app then provisions the charger over BLE with a discovery URL and token, the charger connects out to AWS IoT over MQTT, and there is a charger-to-brush passthrough command that relays writes down to the brush's Control characteristic.
So the shape is:
Internet -> (Cognito auth) -> AWS IoT Core (MQTT) -> iO Sense charger (ESP32)
-> (BLE passthrough) -> brush FF21
There genuinely is a remote path to the brush. It is meant to be there. But it inherits the same ceiling as the local protocol: the passthrough can only send the same Control opcodes, and those top out at LEDs, modes, a retail "demo" toggle, and reading your brushing telemetry. No motor.
The two things worth reporting
While mapping the cloud side I found two issues on the app itself, both reportable to P&G's VDP:
No TLS certificate pinning. The network_security_config.xml trusts system CAs only and permits cleartext, with no pin set. So a network attacker, or anyone who can get a user to install a CA, can MITM every bit of the app's HTTPS. That includes the discovery config and the firmware downloads. No pinning bypass needed, which is a bit of a gift.
A hardcoded API bearer token. The China discovery path (discovery.pg.com.cn) ships with a Bearer token baked straight into the APK. When I tested it the endpoint 404'd, so it looks rotated or stale, but hardcoding a production bearer in a shipped app is still worth flagging: rotate it and move it server-side.
I want to be fair about the parts that were done well, because they were:
- The main discovery config is Cognito-gated.
pgdiscovery.com/api/v3/appreturns 401 without a valid user token, so no anonymous leak of pool IDs or the client secret. - No Cognito identifiers are hardcoded anywhere. I swept the DEX, the native libs, and the resources for them and came up empty.
- OTA firmware is signature-verified (
OTAInfo.INVALID_SIGNATURE_ERROR), so I cannot flash a custom image to add a motor opcode. - The brush exposes no inbound IP services, and the charger is outbound-only.
So, can you turn it on remotely?
No. Not through the protocol, anyway. There is no motor opcode, the motor is button-gated, and the OTA path that could theoretically add one is signed. The most a remote attacker gets, even if everything else went their way, is LED illumination, a mode change, the demo toggle, and your brushing telemetry (which is a privacy question, not a "someone is brushing my teeth from Belarus" question).
That last part matters more than it sounds. It is easy to write a scary headline about a hackable toothbrush. The honest version is more boring and more useful: the remote surface is real, the auth around it is mostly sane, and the worst realistic outcome is a privacy leak plus some blinking lights.
What I did not test
To be clear about coverage, this was static app analysis only. I did not test any live AWS or Cognito behaviour, I did not confirm the BLE write-auth on the charger provisioning characteristics, and I did not pull and reverse the charger firmware. Those are the next steps if I pick this back up, and a couple of them (the IoT topic policy scope, the BLE re-provisioning path) are the only places I think there is real bug potential left. But all of that means testing P&G's infrastructure, which is a separate authorization decision from poking at my own toothbrush.
Anyway. Fun device. Sane engineering, mostly. Still cannot make it brush your teeth over the internet.