HB Updated Nov 13, 2026

Four Ways Into Your Bathroom Scale: The VitaFit Android App

Smart scale on a dark marble-tile floor, thin crimson smoke rising from the glass plate into a black background (AI-generated illustration)

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.

Map of the four findings around the VitaFit APK: CVE-2026-54372 trust-all TLS, CVE-2026-54374 embedded third-party credentials, CVE-2026-54375 cleartext backed-up session tokens, CVE-2026-54376 Fitbit callback scheme race

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.

Sequence of the Fitbit callback race: implicit-grant redirect vitafit://vtio.cn#access_token resolved by Android between the exported FitbitActivity and an attacker app claiming the same scheme, delivering a 30-day weight-scope token to the attacker

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:

https://gitlab.com/hunt-benito/four-ways-into-your-bathroom-scale-vitafit-android-app-cve-2026-54372-54376

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/

Tags

Account-Takeover Agentic-AI AI Gateway android Android Android-Adb Android-apktool Android-Avd Android-Studio Anti-Bot API-Security Authentication Automotive Backslash-Breakout Baseband Bot-Detection Browser-Automation Browser-Fingerprinting Buffer-Overflow C2 Cache-Key-Collision Camoufox CAN-Bus CAPTCHA Chrome Cloudflare Command-Injection Container-Escape Cross-Site-Scripting Cryptography Cudy CWE-121 CWE-122 CWE-125 CWE-1333 CWE-23 CWE-269 CWE-288 CWE-290 CWE-347 CWE-384 CWE-706 CWE-798 CWE-862 CWE-89 CWE-926 CWE-94 Data-Scraping Denial-of-Service Diagnostics Elixir Embedded-Systems Exploit FastGPT Firmware Frida Geolocation GooglePlay Hardware Heap-Overflow Hi-Browser html-sanitize-ex HTTP2 Hugging-Face Identity-Confusion Information Security Integer-Overflow IoT JNI JWT Lexer-Differential Linux Linux-Kernel LiteLLM llama-cpp LLaMA-Factory LLM LwM2M Machine-Learning MCP Measurement MediaTek Memory Disclosure MindsDB Missing-Authorization mitmproxy Mobile Security Mobile-Security MQTT Network-Analysis Networking OAuth OAuth2 OBD2 OIDC OpenID-Connect Out-of-Bounds Page-Cache Patchright Path-Traversal Penetration Testing pgAdmin PHP phpIPAM Playwright PostgreSQL Privilege-Escalation Prompt-Injection Python Race-Condition RCE ReDoS Reverse Engineering Reverse-Engineering Router Ruby Samsung-Bixby Samsung-Exynos SD-WAN Security Security-Research Session Hijacking Session-Hijacking SiYuan SMS Snapchat SNMP Speech-Recognition SQL-Injection SSD SSL-Pinning STIG-Manager Supply-Chain TECNO Tenda TLS Traefik UC-Browser UDS Vulnerability Web Applications Web Scraping Web-Crawling Web-Scraping Web-Security WebGPU Websocket WeChat Wi-Fi Zammad Zephyr Zero-Day