Recently, as part of my company's fitness plan, I bought the HidrateSpark smart water bottle to track water intake. The water bottle itself was pretty interesting — using a capacitative sensor to measure water levels. The associated smartphone app, however, was horrible. As a privacy enthusiast, I was concerned with what sorts of data they were collecting, given the weird quirks in their app during my first interaction with it.
In this post, we'll take a look at reversing their app, and subsequently their API server, the end goal being to replace the upstream API server entirely.
Part 1: Using the app as intended
Immediately upon opening the app, you're hit with a login/registration page. Unfortunately, this is pretty standard behaviour for smartphone apps these days, so I didn't think too much of it. After filling out the details and hitting submit, the app claimed that it "couldn't connect with its server" and asked me to "turn off any VPNs". I was routing my traffic through a VPS at the time, so I wasn't shocked that it detected the VPS IP. What did shock me was the fact that they blocked it. What kind of app blocks non-residential IPs? The reviews showed that others encountered this issue as well, and there was no workaround. In fact, the official solution is to "disable VPN".
Just for the registration, I disabled my VPN temporarily and the registration went through. Fortunately, most of the app worked with the VPN enabled. Unfortunately, being able to sync with my water bottle and view my water intake — the entire point of the app — would not work with the VPN active. So I was forced to disable my VPN. Every single time I wanted to sync with my water bottle.
This was especially annoying. Not only did I have to enable location for Bluetooth Low Energy (required by Android to connect with the bottle), but I also had to disable my always-on VPN. Even more, the app would not work if you weren't connected to the internet. Of course, this was not going to fly.
Part 2: Initial research
The first thing to do was to find what the app was connecting to. Looking at my Pi-hole, I saw that the app was connecting with https://www.hidrateapp.com. Opening the website on a residential IP would give a simple
Hidrate App running successfully!. Opening the website on any non-residential IP would give a
403 Forbidden. This looked like the blanket VPN block.
At this point, the only solutions in sight were to replace their server, or to not route their API traffic through my VPN. The second solution was not an option, so the first solution it was.
To replace their server, I first needed to see what requests were being made. To do this, I would essentially need to man-in-the-middle (MITM) myself. This way, I could see which requests were being made and forward them to the upstream server to see their reply. Since this was an Android app, MITM would be significantly more difficult on an unrooted phone than on a computer where I had full control. Luckily, there were still a few ways.
Part 3: Man-in-the-middle
Android App: Attempt 1
The first attempt was to spoof my DNS with the Pi-hole to redirect the domain to one of my servers. The app used HTTPS, which meant I had to modify the CA certificates on my phone. However, this ended up being a dead end. Given that you're unable to install system CA certificates on Android 11 without a rooted phone, and apps by default do not trust user CA certificates, there's no way to MITM myself with this approach without a rooted phone. I did not feel comfortable with rooting a 3 month old phone.
Android App: Attempt 2
The second attempt was to modify their app, and replace all instances of
https://www.hidrateapp.com with a domain I control (i.e.
https://evanzhang.ca). This way, I can set up proper HTTPS as well as MITM. Using an APK extractor app on my phone, I extracted the main APK for HidrateApp (henceforth named
HidrateSpark.apk). Using Samsung's Smart-Switch app, I was able to get myself a copy of the split config APKs (named
Using apktool to extract files from the APK and convert the
classes.dex files to (somewhat) readable smali files:
$ java -jar apktool.jar d HidrateSpark.apk
Modifying the APK
Doing a quick
www.hidrateapp.com gave multiple results.
[HidrateSpark] $ grep -r "www.hidrateapp.com" smali_classes4/hidratenow/com/hidrate/hidrateandroid/models/HidrateApplication.smali: const-string v1, "https://www.hidrateapp.com/parse/" smali_classes4/hidratenow/com/hidrate/hidrateandroid/fragments/settingsfragments/SettingsDeveloperFragment.smali: const-string v1, "https://www.hidrateapp.com/parse/" smali_classes4/hidratenow/com/hidrate/hidrateandroid/api/HidrateParseService.smali: const-string v2, "https://www.hidrateapp.com/parse/" smali_classes4/hidratenow/com/hidrate/hidrateandroid/BuildConfig.smali:.field public static final BASE_API_URL:Ljava/lang/String; = "https://www.hidrateapp.com/parse/" smali_classes3/com/hidrate/networking/UserAuthModule.smali: const-string v3, "https://www.hidrateapp.com/parse/" smali_classes3/com/hidrate/networking/NetworkModule$Companion.smali: const-string v3, "https://www.hidrateapp.com/parse/" smali_classes3/com/hidrate/networking/BuildConfig.smali:.field public static final BASE_API_URL:Ljava/lang/String; = "https://www.hidrateapp.com/parse/"
For now, since we only want to MITM, we just need to replace these strings with our own domain.
[HidrateSpark] $ grep -rl "www.hidrateapp.com" | xargs sed -i 's/www.hidrateapp.com/evanzhang.ca/' [HidrateSpark] $ grep -r "evanzhang.ca" smali_classes4/hidratenow/com/hidrate/hidrateandroid/BuildConfig.smali:.field public static final BASE_API_URL:Ljava/lang/String; = "https://evanzhang.ca/parse/" smali_classes4/hidratenow/com/hidrate/hidrateandroid/models/HidrateApplication.smali: const-string v1, "https://evanzhang.ca/parse/" smali_classes4/hidratenow/com/hidrate/hidrateandroid/fragments/settingsfragments/SettingsDeveloperFragment.smali: const-string v1, "https://evanzhang.ca/parse/" smali_classes4/hidratenow/com/hidrate/hidrateandroid/api/HidrateParseService.smali: const-string v2, "https://evanzhang.ca/parse/" smali_classes3/com/hidrate/networking/BuildConfig.smali:.field public static final BASE_API_URL:Ljava/lang/String; = "https://evanzhang.ca/parse/" smali_classes3/com/hidrate/networking/NetworkModule$Companion.smali: const-string v3, "https://evanzhang.ca/parse/" smali_classes3/com/hidrate/networking/UserAuthModule.smali: const-string v3, "https://evanzhang.ca/parse/"
Perfect. Now we just need to repackage, zipalign, and re-sign the APK.
$ java -jar apktool.jar b --use-aapt2 HidrateSpark/
The repackaged APK will be in
Next, we will
zipalign all four of the APKs.
$ zipalign -f 4 HidrateSpark.apk HidrateSpark_aligned.apk $ zipalign -p -f 4 hidrate_split_config.arm64_v8a.apk hidrate_split_config.arm64_v8a_aligned.apk $ zipalign -f 4 hidrate_split_config.en.apk hidrate_split_config_aligned.en.apk $ zipalign -f 4 hidrate_split_config.xxhdpi.apk hidrate_split_config_aligned.xxhdpi.apk
Note the extra
-p flag for one of the split APKs as there are shared object files that requires special care. Also, I've excluded the paths to the APKs as they will vary, but ensure that you correctly set the paths to the APK files.
Finally, we will re-sign the APKs (again, all the APKs, not just the one we touched).
If you don't already have a keystore, you can generate one with
$ keytool -genkey -v -keystore my-release-key.keystore -alias <alias> -keyalg RSA -keysize 2048 -validity 10000
Sign the APKs with
$ apksigner sign -v --out HidrateSpark_signed.apk --ks my-release-key.keystore --ks-key-alias <alias> HidrateSpark_aligned.apk $ apksigner sign -v --out hidrate_split_config.arm64_v8a_signed.apk --ks my-release-key.keystore --ks-key-alias <alias> hidrate_split_config.arm64_v8a_aligned.apk $ apksigner sign -v --out hidrate_split_config_signed.en.apk --ks my-release-key.keystore --ks-key-alias <alias> hidrate_split_config_aligned.en.apk $ apksigner sign -v --out hidrate_split_config_signed.xxhdpi.apk --ks my-release-key.keystore --ks-key-alias <alias> hidrate_split_config_aligned.xxhdpi.apk
All that's left to do is to install the app on the phone. Ensure that the original app is uninstalled, as otherwise installation will fail. The easiest way that I found to install a split APK app was through
First, enable USB Debugging. This will vary between devices, but usually it will go along the lines of enabling developer options and toggling USB Debugging.
Next, install the APK with
$ adb install-multiple \ HidrateSpark_signed.apk \ hidrate_split_config.arm64_v8a_signed.apk \ hidrate_split_config_signed.en.apk \ hidrate_split_config_signed.xxhdpi.apk
This step is always very tough to get right the first time, as Android's error is undescriptive. If it fails for whatever reason, ensure that the original app was uninstalled and that you have properly signed the APKs. You can verify that signing was done properly with
apksigner verify <apk> (warnings are fine).
We must create a server at
https://evanzhang.ca (or whichever domain is specified in the APK) that is able to take in requests, log them, and forward them to the server. An example server that I used is available at https://github.com/Ninjaclasher/hidrateapp-server/. Note that this server is not exactly the same as this server strips all sensitive information and does not forward tracking pings, but the general idea is the same.
I let this server run for a few days while using the app to collect some sample requests, including headers, query params, request body, and response. The results were astonishing.
Server tech stack
Here are some information I've discovered on the upstream server. Note that this was all discovered via the sample requests and the actual server was not breached in any manner. The upstream server is a RESTful API written with ExpressJS. Data is stored in a MongoDB database in a series of collections (
Day, etc.), and an ACL is used to control read/write access to each document.
There are four types of endpoints:
- Class endpoints. These directly interact with MongoDB via
GET /parse/classes/Bottlefor listing all your water bottles.
PUT /parse/classes/Sip/<id>for updating a sip's data.
- Function endpoints. These do some sort of computation and return its results.
POST /parse/functions/userexists?email=<email>for checking if a user with the specified email exists. Don't ask me why they put
POST /parse/functions/getuseradsfor fetching ads. Imagine having ads in an app for a product that you already paid for.
- Event endpoints. These are used for tracking app actions.
GET /parse/events/addbottlefor when you add a water bottle to the app.
- Miscellaneous endpoints.
POST /parse/configfor listing some configuration options.
Requests & Responses
Here are the more interesting requests and responses I've seen.
Some of the more atrocious requests included:
- Logged event for every time you open the app / view drinking history / change settings (among other actions).
- Device name, type on initial open.
- Unique identifier in the header of every request. I'm guessing this is used to link all requests back to you.
- Request for ads alongside almost every other request.
- Request for pushing just your GPS location to the server (not for the "FindMyBottle" feature).
Some of the strangest fields in responses included:
"spam": falseon the
"breastfeeding": falseon the
For anyone who simply wants to strip sensitive information and tracking pings, running this MITM server is enough. You don't necessarily need the full server. However, there are a few issues.
- You must run the MITM on a residential IP. Otherwise, you will receive
403 Forbiddenon any request. One workaround I've found that works at the time of writing is to use https://www.hidratefrost.com, another HidrateSpark domain that doesn't seem to have these IP restrictions. From empirical testing, it seems that both sites are connected to the same Mongo database. I'm not sure why this domain exists, so it may suddenly stop working.
- You should properly secure this server so that it does not become an open proxy.
Part 4: Replacing upstream
The entire goal of this was to replace their server with a custom one so that I could be in control of my requests. With the few days worth of request and corresponding responses collected by the MITM server, we can now blindly reverse engineer the server.
Reversing the class endpoints
These endpoints were pretty easy to reverse. They followed a standard format, and all important data was directly dumped by Mongo. We're given the general collection "schema" and the relationships between the collections. For the most part, the schema was rigid enough that we could replace MongoDB with a relational database, which is what I ended up doing.
Reversing the function endpoints
These endpoints were somewhat difficult to reverse. All of them had different response formats, which meant each one had to be separately coded. However, most logic seemed to be relatively simple to code once the logic was figured out.
Reversing the event endpoints
These endpoints were the easiest to implement — they didn't need to be implemented at all! These are all the tracking endpoints.
Reversing the miscellaneous endpoints
These endpoints were the most difficulat to reverse. Fortunately, there weren't a lot of them.
The Django server is available at https://github.com/Ninjaclasher/hidrateapp-server/. Installation instructions are available in the README. Note that some endpoints were not implemented — in particular the ones used for tracking events and fetching challenges/friends/user group data. My copy of this server has been running for a few days at the time of writing with no obvious issues, so it should mostly be ready for use.
If you do have a use for the unimplemented/removed endpoints or have found a bug with the server, feel free to file an issue.
If you do plan to run a copy of this server, note that there is the same amount of authorization as the upstream server. For some collections, upstream has ACL checks in place. For other collections, however, upstream only has authorization via the
X-Parse-Rest-Api-Key headers. These values for these headers are hardcoded into the APK, so any malicious user who extracts the APK can tamper with these collections.
This server has the same ACL checks for the same collections that upstream has. It also has the same header checks as upstream, with the default header values being are hardcoded into
settings.py. If you wish to add a layer of security, it is recommended to change these header values and to perform a similar find/replace in the APK as was done with the domain above. Don't forget to re-build the APK.
Part 5: Final thoughts
This is probably one of the first times I've fully reversed something outside of a Capture The Flag (CTF) competition. I'm now more comfortable with using the app knowing the enormous amounts of data will be routed back to my server.
One downside of manually re-building the app is that you'll need to do it for every upstream app update (unless you don't care about the app being outdated). This isn't too bad though, as no important data is stored only in the app itself. All data eventually is synced to the server. You can simply uninstall the app, and reinstall to override if you lose your signing key.
For the future
There's still quite a bit that was left untouched in this post as it didn't quite fit. In a future post, perhaps I'll showcase some random discoveries and hacks with HidrateSpark's software, such as bypassing their paid feature (customizing the glow colours on the bottle), potential database leaks, and taking a look at the firmware for the bottle.