Four Ways Into Your Bathroom Scale: The VitaFit Android App

A bathroom scale is an odd thing to distrust. You step on it, it tells you your weight, and that is the whole contract. VitaFit scales take the job further: body fat, muscle mass, water, bone mass, BMI, history, goals, all synced through the VitaFit Android app (com.vt.vitafit) to the vendor’s cloud. Health data of the most personal kind, flowing over HTTPS from bathrooms around the world.
On November 4, 2026, NVD published four CVEs against the app, all reported by researcher Jose Lomeli in a coordinated disclosure run with VulnCheck:
| CVE | Finding | CVSS | CWE |
|---|---|---|---|
| CVE-2026-54372 | Trust-all X509TrustManager and an always-true hostname verifier wired into the app’s shared HTTP client |
8.8 High | CWE-295 |
| CVE-2026-54374 | WeChat, Tencent QQ, Facebook and Fitbit credentials embedded in the APK | 7.5 High | CWE-798 |
| CVE-2026-54375 | Session tokens stored in cleartext, shipped in backups, valid for ten years | 8.4 High | CWE-613 |
| CVE-2026-54376 | Fitbit OAuth token handed to the vitafit://vtio.cn callback scheme, claimable by any installed app |
8.2 High | CWE-926 |
That is the reading list. Our goal today: confirm each of the four in our own lab, from a single APK, and understand how four separate mistakes fit into one app. Version 2.8.0.4 is the assessed target; 2.8.1.0 is the fixed build. Everything below runs on a plain workstation - no rooted phone required for what we verify here, which surprised us a little, because we expected at least one of the four to need a device.
The Lab
$ cd ~/vitafit-lab && export PATH=$PWD/bin:$PATH
$ uname -m && java -version 2>&1 | head -1
x86_64
openjdk version "21.0.12.1" 2026-08-18
$ ./bin/jadx --version && ./apkeep --version && java -jar apktool.jar --version && python3 --version
1.5.0
apkeep 1.1.0
2.9.3
Python 3.12.3
Tooling for the whole run: jadx for source reconstruction, apktool for the manifest and resources, apkeep (EFF) for pulling the historical APK that Google Play no longer serves, and keytool from the system JDK for signature checks. The companion repo pins these exact versions and wraps all of it in numbered scripts (01 through 06), so you can follow along command for command.
One warning before we start, because it bit us on the first attempt: the vulnerable build only exists on mirrors now. APKPure is a third-party store with a documented tampering incident in its past, and apkeep forces you to acknowledge that explicitly. The countermeasure is simple - verify what you downloaded before you trust anything it tells you.
The Log
Stage 1: Acquire the target, then doubt it
$ apkeep -a com.vt.vitafit@2.8.0.4 -d apk-pure -o 'acknowledge_dangers=true' apk-out
Downloading com.vt.vitafit version 2.8.0.4 from APKPure...
com.vt.vitafit@2.8.0.4 downloaded successfully!
The download lands as an .xapk, which is a zip of split APKs - a base module plus per-language, per-architecture and density configs. We unzip it and check the base module:
$ unzip -o -q apk-out/com.vt.vitafit@2.8.0.4.xapk -d xapk
$ cat xapk/manifest.json | jq -r '.version_code, .version_name'
2804
2.8.0.4
$ sha256sum xapk/com.vt.vitafit.apk
c55e022dab7d46e663746f91450070f6c1a11ef63bb178b281889f78cc642050 xapk/com.vt.vitafit.apk
Version code 2804 matches the assessed release exactly. Now the doubt part - who signed this file?
$ keytool -printcert -jarfile xapk/com.vt.vitafit.apk | sed -n '3,12p'
Certificate #1:
Owner: CN=Android, OU=Android, O=Google Inc., L=Mountain View, ST=California, C=US
Issuer: CN=Android, OU=Android, O=Google Inc., L=Mountain View, ST=California, C=US
Serial number: b11bdabd100b1607579ee23bbee7ba6810c5920b
Valid from: Fri Jan 15 08:44:56 UTC 2021 until: Sun Jan 15 08:44:56 UTC 2051
Certificate fingerprints:
SHA1: BF:A0:07:8B:1D:3A:8B:39:85:B8:0A:4C:29:CE:C5:D9:1E:5B:89:81
SHA256: A2:6C:EA:42:CD:BC:88:4D:95:B7:D5:78:14:21:00:75:FD:B2:5E:FF:A9:83:E9:C6:68:76:9B:84:CC:9A:59:3A
CN=Android, O=Google Inc. is Google Play App Signing: the developer opted in, so Google holds the release key and re-signs every build it distributes. The mirror copy is the same bytes Play serves, which is exactly what we want from a lab specimen. Stage 1 passes.
Stage 2: The TLS finding, in one class file
$ ./03-decompile.sh xapk/com.vt.vitafit.apk
== jadx (sources, ~5-10 min on a 16 MB app)
...
== apktool (manifest + resources)
...
Decompiled files: 15674
The prior SSL pinning article on this blog dealt with apps that try hard to refuse interception: pinned certificates, and Frida hooks to neuter the checks. Here we expected the same fight. We got a stranger result - the grep finished before the coffee did:
$ ./04-tls-audit.sh jadx-out/sources
== Files implementing X509TrustManager outside known TLS libraries
jadx-out/sources/b8/C2508b.java
== For each candidate: does checkServerTrusted have an empty body?
--- jadx-out/sources/b8/C2508b.java
public void checkServerTrusted(X509Certificate[] x509CertificateArr, String str) {
}
== Hostname verifiers that always return true
jadx-out/sources/b8/C2508b.java:75: public boolean verify(String str, SSLSession sSLSession) {
jadx-out/sources/b8/C2508b.java-76- return true;
That is CVE-2026-54372 in full. The decompiled class (b8.b before jadx renamed it) contains a textbook trust-all trust manager:
public class C0237b implements X509TrustManager {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] x509CertificateArr, String str) {
}
public void checkServerTrusted(X509Certificate[] x509CertificateArr, String str) {
}
}
Both check methods are empty: nothing is trusted, everything is accepted. The hostname verifier is its twin (verify(...) { return true; }), and the OkHttpClient factory in the same file bolts both onto the client every request uses:
return retryOnConnectionFailure
.connectTimeout(65L, timeUnit).readTimeout(60L, timeUnit).writeTimeout(60L, timeUnit)
.sslSocketFactory(sSLContext.getSocketFactory(), c0237b)
.hostnameVerifier(cVar)
.build();
Attention! The practical consequence is the inversion of the usual interception workflow. No CA certificate needs installing on the device, because the app never asks who it is talking to. Any proxy on the network path - our workstation running mitmproxy, the coffee-shop router, a corporate inspection box - terminates the TLS session and the app plays along. The CVSS vector (AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) scores the adjacent-network version of that story at 8.8.
Same file, same class, one more gift for later: a response interceptor that watches every reply for a m_session_id cookie and files it away. Hold that thought for Stage 4.
Stage 3: Sweeping the APK for other people’s passwords
Stage 2 was one class doing one bad thing. Stage 3 asks the broader question: what else did the developer leave in here? The classic sweep is embedded third-party credentials, and the app does not disappoint:
$ python3 05-secrets-audit.py jadx-out/sources apktool-out
== Hardcoded third-party credentials in X7/a.java
field value (redacted) role
f11132s wx21...239 [redacted, 18 chars] WeChat App ID (wx + 16 hex)
f11134t [redacted] WeChat App SECRET
f11136u 1019...640 [redacted, 9 chars] Tencent QQ App ID
f11138v [redacted] Tencent QQ App SECRET
f11071B 22C2... [redacted] Fitbit OAuth client ID
f11146z [redacted] Fitbit OAuth client SECRET
f11070A BZHK...ao= [redacted, 44 chars] Fitbit SDK local token ENCRYPTION KEY (base64)
== Facebook SDK credentials (res/values/strings.xml)
facebook_app_id 1232...552 [redacted, 16 chars] meta-data com.facebook.sdk.App
facebook_client_token [redacted] meta-data com.facebook.sdk.Client
== Call sites wiring the credentials into the integrations
com/vt/vitafit/activity/SplashActivity.java: references f11132s - social SDK init (WeChat/QQ/Facebook setup)
com/vt/vitafit/activity/mine/FitbitActivity.java: references f11146z - Fitbit OAuth client configuration
O8/c.java: references createWXAPI - WXAPIFactory created with the embedded WeChat App ID
That is CVE-2026-54374, and the call sites are what turn it from trivia into a finding. The WeChat pair is loaded at splash-screen time into the app’s social SDK (d.h().o(wechatId, wechatSecret).m(qqId, qqSecret)...), and the share module builds its WeChat session straight from those fields. The Fitbit triple feeds ClientCredentials(clientId, clientSecret, redirectUri) from Fitbit’s own Android authentication SDK - meaning the client secret travels inside every installed copy of the app.
Attention! We deliberately show every secret partially redacted, and the companion repo’s script does the same. The registrations these credentials unlock were live during the original assessment, and even with the app fixed in 2.8.1.0, old APKs circulate forever. A secret you cannot unship is a secret you have to rotate - the vendor’s fix was to stop embedding them, which is the correct move, but the rotation story is theirs to finish.
Why does a bathroom scale talk to WeChat, QQ, Facebook and Fitbit in the first place? Social login and share buttons, mostly, plus Fitbit sync so a workout crowd can consolidate body metrics. None of that requires the client secret to live in the APK - that is what backend token exchange endpoints are for. The pattern here is a Chinese Android development ecosystem default: SDK init objects with appid, secret constructor pairs are how WeChat and QQ integrations have always been wired, and the idiom sailed through code review because it always looks like this.
Stage 4: Follow the session cookie
Remember the Stage 2 interceptor? This is where it pays off:
for (Cookie cookie : Cookie.parseAll(request.url(), proceed.headers())) {
...
if (TextUtils.equals(name, "m_session_id") && expiresAt > currentTimeMillis) {
C5245b.c(value, expiresAt);
}
}
Every response is scanned for m_session_id, and matching cookies go to C5245b.c(value, expiresAt) - which is a thin wrapper over SharedPreferences:
public static void c(String str, long j10) {
F0.k("tokenProfile").B("token", str);
F0.k("tokenProfile").z("expires", j10);
}
Cleartext preferences, no EncryptedSharedPreferences, no keystore. Combined with the manifest, the storage story gets worse:
$ grep -oE 'android:allowBackup="[^"]*"|android:usesCleartextTraffic="[^"]*"' \
apktool-out/AndroidManifest.xml | sort -u
android:allowBackup="true"
android:usesCleartextTraffic="true"
allowBackup="true" with no backup rules XML means the tokenProfile file rides along in Android auto-backup and adb backup extracts. And the expiry inside it is whatever the server said: the researcher’s assessment measured ten-year m_session_id validity, meaning one stolen preferences file is a decade-long key to the account. The researcher also demonstrated the follow-on: a captured session can delete the account’s authentication record, locking the real owner out - which pairs nastily with Stage 2, where the same sessions are obtainable off the air from anyone sharing your coffee-shop router. That combination is CVE-2026-54375 (8.4, CWE-613).
(While we are staring at that manifest line: usesCleartextTraffic="true" is the low-severity fifth finding, CVE-2026-54373, scored 3.5 - the researcher observed no credentials crossing the cleartext path, only support content. We mention it because it shares the root cause: transport security was not a design constraint in this codebase.)
Stage 5: Racing the Fitbit callback
Stage 3 showed a Fitbit client secret in the APK. Stage 5 shows where the authorization token itself lands. Two pieces of evidence, both in the decompiled sources. First, the manifest hands the callback scheme to one exported activity:
$ grep -B6 "android.intent.category.BROWSABLE" apktool-out/AndroidManifest.xml \
| grep -oE 'android:name="com.vt[^"]*"|<data android:[^>]*>'
android:name="com.vt.vitafit.activity.mine.FitbitActivity"
<data android:host="vtio.cn" android:scheme="vitafit"
FitbitActivity is exported="true" with a BROWSABLE vitafit://vtio.cn intent filter - a public mailbox with a well-known address. Second, the class that builds the authorization URL (v3/C5353f in our tree):
public static final String f51389d =
"https://www.fitbit.com/oauth2/authorize?response_type=token"
+ "&client_id=%s&redirect_uri=%s&scope=%s&expires_in=%d";
...
Uri parse = Uri.parse(str.replaceFirst("#", "?"));
this.f51393b.q(AuthenticationResult.k(new AccessToken(
parse.getQueryParameter("access_token"), ...)));
response_type=token - the OAuth2 implicit grant. No authorization code, no server-side exchange, no PKCE: Fitbit’s browser flow drops the finished access token straight into the redirect URL’s fragment, and the callback handler picks it out with a query-parameter read. The app requests it with Scope.weight and an expiry of 2592000L seconds - thirty days, to the hour.
Any installed app can claim that same scheme, so Android treats both as eligible handlers for the callback. Our PoC harness in the repo is exactly that minimal claimant - register vitafit://vtio.cn, log what arrives. On the researcher’s bench, a proof-of-concept app registered the same scheme, appeared as an eligible handler, and received the callback from his own test account’s authorization flow: a live 30-day token with weight scope that was addressed to VitaFit. Per Fitbit’s Body API documentation, that scope grants read, create and delete on the victim’s weight and body-fat records. That is CVE-2026-54376 (8.2, CWE-926). It needs a malicious app installed plus one completed authorization by the user, which the CVSS UI:R vector is honest about.
What We Learned
Four findings, one APK, zero device access needed for what we verified. Stack them and the app’s threat model reads plainly: on a hostile network you get sessions handed to you (54372) that stay valid for ten years and live unencrypted in backups (54375); on a shared device or a stolen backup file you get the same decade-long key; on a victim’s phone a squatter app can catch their Fitbit authorization (54376); and one decompile hands you the vendor’s social-integration credentials for free (54374). The researcher’s fuller assessment connects the same threads into the cloud layer - forged social logins (9.8) and shared AWS credentials (9.6), chaining to a 9.9-critical account-takeover path documented in the disclosure writeup. The app-side findings we verified are the front door of that story.
The disclosure itself is worth a note of respect: reported May 19, 2026, CVEs allocated by VulnCheck on June 17, fixes verified by November 3. Eleven of twelve CVE-listed findings are marked Fixed in 2.8.1.0. The session finding is Partially Fixed - new sessions expire after 90 days and stored sessions are now encrypted, but tokens issued before the remediation remain accepted (exchanged for a fresh 90-day session, then invalidated after a five-minute grace) until VitaFit’s January 28, 2027 cutoff. If you run one of these scales, update the app and log out and back in regardless.
The transferable lesson is about defaults, not this vendor. Every one of the four findings is a recognizable ecosystem idiom: trust-all trust managers to “make TLS work in testing”, appid, secret SDK pairs because that is how the integration docs show it, long-lived sessions for “user convenience”, implicit grant because it is the simplest OAuth flow that seems to work. None of them required a novel exploit to weaponize - only a decompiler. When your data model is weight, body fat and birthdays, “it compiles” is not the review bar.
Reproduce It
Everything in this article is scripted in the companion repository:
| Script | What it does |
|---|---|
01-download-apk.sh |
pulls VitaFit 2.8.0.4 from APKPure via apkeep (risk acknowledged, then verified) |
02-verify-apk.sh |
SHA-256 + signing-certificate provenance check |
03-decompile.sh |
jadx sources + apktool manifest/resources |
04-tls-audit.sh |
finds trust managers, flags empty checkServerTrusted, locates the wiring |
05-secrets-audit.py |
the credential sweep, printed fully redacted |
06-manifest-audit.sh |
backup flags, cleartext traffic, exported activities with BROWSABLE filters |
deeplink-poc/ |
the scheme-claiming harness for the CVE-2026-54376 callback race |
Scope note, stated plainly: what we ran and showed above is static analysis of the Play-signed APK, plus the callback harness logic. The dynamic confirmations - live TLS interception, backup extraction, and the scheme race against a real authorization flow - were performed by the original researcher with his own test accounts, and we chose not to re-run them against production infrastructure. The harness lets you repeat those on your own bench with your own accounts in minutes.
SOURCES
Jose Lomeli, Responsible Disclosure of VitaFit Android and Cloud Security Vulnerabilities: https://github.com/je-lv/vitafit-security-research
VulnCheck Advisory - VitaFit Android Insecure TLS Certificate Validation (CVE-2026-54372): https://www.vulncheck.com/advisories/vitafit-android-insecure-tls-certificate-validation
VulnCheck Advisory - VitaFit Android Hardcoded Credentials Disclosure (CVE-2026-54374): https://www.vulncheck.com/advisories/vitafit-android-hardcoded-credentials-disclosure
VulnCheck Advisory - VitaFit Android Long-Lived Session Token Exposure (CVE-2026-54375): https://www.vulncheck.com/advisories/vitafit-android-long-lived-session-token-exposure
VulnCheck Advisory - VitaFit Android Custom URI Scheme Token Hijacking (CVE-2026-54376): https://www.vulncheck.com/advisories/vitafit-android-custom-uri-scheme-token-hijacking
NVD - CVE-2026-54372: https://nvd.nist.gov/vuln/detail/CVE-2026-54372
NVD - CVE-2026-54374: https://nvd.nist.gov/vuln/detail/CVE-2026-54374
NVD - CVE-2026-54375: https://nvd.nist.gov/vuln/detail/CVE-2026-54375
NVD - CVE-2026-54376: https://nvd.nist.gov/vuln/detail/CVE-2026-54376
Google Play - VitaFit (com.vt.vitafit): https://play.google.com/store/apps/details?id=com.vt.vitafit
Fitbit Web API Reference - Body: https://dev.fitbit.com/build/reference/web-api/body/
VitaFit scales (vendor product line): https://vitafitscale.com/
apkeep (EFF) - APK download tool, APKPure risk documentation: https://github.com/EFForg/apkeep/blob/master/USAGE-apkpure.md
jadx - Dex to Java decompiler: https://github.com/skylot/jadx
apktool - resource and manifest decoder: https://apktool.org/
Hunt-Benito, Bypassing Android SSL Certificate Pinning with Frida (prior art on the interception workflow): https://www.hunt-benito.com/blog/bypassing-android-ssl-certificate-pinning-with-frida/