Mobile Application Penetration Testing Securing a digital-health flagship (iOS & Android) against insecure PHI storage, SSL-pinning bypass MITM, and hardcoded API keys ahead of a high-profile launch
Project Details
- Client
- Vitalis Health is a fast-growing digital-health startup whose flagship iOS and Android app, VitalisCare, delivers remote patient monitoring, secure clinician messaging, and lab-result delivery for over 240,000 patients across a network of partner clinics
- Industry
- Digital Health / HealthTech
- Company Size
- 80 - 120
- Headquarters
- Boston, Massachusetts
- Project Duration
- 1 month (Apr 2026 - May 2026)
A comprehensive grey-box mobile application penetration test of a digital-health flagship app (VitalisCare, iOS & Android) handling protected health information. The engagement combined static analysis, dynamic instrumentation, and network interception to prove insecure local storage of auth tokens, an SSL-pinning bypass enabling full MITM of PHI traffic, and a hardcoded API key recovered via decompilation — then delivered Keychain/Keystore migration, hardened certificate pinning, and secrets management to achieve HIPAA-aligned launch readiness.
Engagement Classification · TLP:AMBER
Project VitalShield / Mobile PHI Audit
Grey-box mobile penetration test of a digital-health flagship across iOS and Android. Six weeks: decompilation, runtime instrumentation, live API interception, and a remediation cycle that closed three PHI-exposure paths before the release shipped.
A Launch-Blocking Risk in Digital Health
Four commands. That is what stood between an attacker holding a VitalisCare device and a patient’s lab results: adb shell run-as, cat, a copy of the JWT, and a curl against the production API. No exploit chain, no memory corruption, no zero-day. A file that should have been in the Keystore was sitting in an XML file instead.
That is the structural problem with mobile, and it is not a coding-standards problem. A server binary lives somewhere you control. A mobile binary ships to the adversary — it can be decompiled at leisure, hooked at runtime, run on a device whose OS the attacker has already rooted, and pushed through a proxy on a network they operate. Every client-side control in the app is a control the attacker gets to study offline, for as long as they like, with the source in front of them.
Vitalis Health engaged us six weeks before a major VitalisCare release, with payer partners and clinician onboarding already scheduled against the date. The question they asked was the right one and unusually specific: could someone reverse-engineer the app, pull patient data out of local storage, or read PHI in transit? On the pre-release build the answer was yes to all three, and none of them were hard. This case study walks the three that mattered — token storage, pinning bypass, and a hardcoded key — through proof, fix, and re-test. The fixes were not all free, and where they cost something we say so.
Technical Audit Snapshot
4-Phase Mobile Assessment Methodology
We aligned the engagement to the OWASP Mobile Application Security Verification Standard (MASVS) and worked across four layers: the binary, the runtime, the network, and the backend the app talks to. The order matters. Static analysis tells you what the code intends; dynamic analysis tells you what it actually does with a real session and real data; the network layer tells you what leaves the device; the API tells you whether any of the client-side controls were ever load-bearing in the first place. Skipping straight to the proxy — the common shortcut when time is short — finds insecure communication and misses insecure storage entirely, because storage never crosses the wire.
Static Analysis (SAST & Reverse Engineering)
Decompiled the Android APK (jadx, apktool) and inspected the iOS IPA (class-dump, Hopper). Read the manifest for exported components, swept embedded strings for credential-shaped material, and audited third-party SDKs — which is where insecure defaults usually live, because nobody reviews a dependency the way they review their own code.
Dynamic Analysis (Runtime Instrumentation)
Ran the app on rooted Android and jailbroken iOS hardware, hooking methods with Frida and Objection to watch what it wrote to disk, what it put in the keychain, which crypto calls it made with which parameters, and how it behaved when those calls were tampered with mid-flight.
Network Interception (MITM)
Routed device traffic through an intercepting proxy (Burp/mitmproxy), defeated certificate pinning at runtime, then read every request and response for PHI in URLs, over-broad response bodies, and tokens with longer lives than the sessions they belonged to.
API Security Auditing & Remediation
Tested the backend for object-level authorisation flaws and PHI leakage with a legitimately obtained token, then co-authored the Keychain/Keystore migration, the hardened pinning configuration, and the secrets-management change — re-testing each fix on a fresh build rather than accepting a diff as evidence.
Interactive Methodology Checklist
Tap each control to mark it complete. This is the MASVS-aligned checklist we worked through on VitalisCare — six items, deliberately short. A mobile checklist that runs to eighty rows gets skimmed, and the rows people skim are the storage ones, because they are the least interesting to test and the most likely to be broken.
Mobile Architecture & MITM Attack Path
VitalisCare is a native iOS/Android client talking to a REST + GraphQL backend behind an API gateway. Transport security rested on TLS with certificate pinning, and pinning was doing more work than the team realised: because the client was trusted to validate the certificate, the backend accepted any bearer token that reached it, from anywhere. That is the assumption we set out to break. Defeating pinning was not the finding in itself — pinning is bypassable by design on a device you control. The finding was what became visible once it was gone, and what the API did not do about it.
Pinning Bypass]):::attacker App([VitalisCare Client
iOS / Android]):::device Frida -.runtime hook.-> App App -->|TLS traffic| Proxy{Intercepting Proxy
Burp / mitmproxy}:::proxy Proxy -->|cleartext to attacker| Loot[Captured PHI
+ Auth Tokens]:::attacker Proxy -->|re-encrypted| GW[API Gateway
REST + GraphQL]:::backend GW --> DB[(PHI Datastore)]:::backend
Vulnerability Classification Matrix · OWASP Mobile Top 10 (2024)
Each finding was scored with CVSS v3.1 and mapped to the OWASP Mobile Top 10 (2024) and the relevant MASVS control group. The Exploit Complexity column exists because CVSS on its own misleads on mobile: OC-MOB-001 and OC-MOB-005 both need physical or root access to the device, which the CVSS vector already reflects, but the effort involved differs by orders of magnitude once the device is in hand. A developer reading a 9.1 next to a 5.3 needs to know which one is a one-line cat and which one requires a forensic image.
Static vs Dynamic Analysis
Neither technique is sufficient alone, and the reasons are asymmetric. Static analysis finds the code path but cannot tell you whether it runs, so it produces findings in dead code and abandoned SDK integrations that waste a developer’s afternoon. Dynamic analysis proves the behaviour but only on the paths you exercised — anything behind a feature flag, a specific locale, or an error branch you never triggered is invisible, and its absence looks exactly like safety. Toggle between the two workflows on the token-storage finding below.
# Decompile the APK and grep for token persistence
apktool d vitaliscare-release.apk -o out/
jadx -d jadx_out vitaliscare-release.apk
$ grep -rni "getSharedPreferences\|putString" jadx_out/sources/ | head
TokenStore.java: prefs = ctx.getSharedPreferences("vc_auth", MODE_PRIVATE);
TokenStore.java: prefs.edit().putString("jwt", token).apply(); # plaintext!
# The JWT is written to an unencrypted XML file on disk:
# /data/data/com.vitalis.care/shared_prefs/vc_auth.xmlStatic review located the exact class and preference key persisting the auth JWT into plaintext SharedPreferences. Note what it could not tell us: whether that code path was still live in the release build, or whether a later refactor had superseded it. That took the runtime hook.
# Pull the live secrets file straight off a rooted device
$ adb shell run-as com.vitalis.care cat shared_prefs/vc_auth.xml
<string name="jwt">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...</string>
# Confirm at runtime with a Frida hook on the setter
frida -U -f com.vitalis.care -l hook_tokenstore.js
[TokenStore.putString] key=jwt value=eyJhbGciOiJ... (PHI scope: read:labs)The runtime hook closed it: the JWT was readable from disk on a live session and observable as it was written, carrying a read:labs scope. That is a working session in an attacker’s hands, and nothing server-side would have distinguished its replay from the patient’s own app.
Critical Finding OC-MOB-001 — Insecure Local Storage of Auth Tokens
VitalisCare persisted its session JWT — carrying PHI-read scopes — into unencrypted SharedPreferences on Android and UserDefaults on iOS. Both are the platform default for “somewhere to put a small value”, both are backed up, and neither has ever claimed to be secure storage. The token could be lifted off disk and replayed against the API from any machine on the internet.
The device-is-rooted caveat undersells this. Root or a jailbreak is one route; an unencrypted iTunes backup, a shared or resold handset, an MDM-managed device with a compromised admin, and any malware with the filesystem access that a sideloaded app on an older Android can still obtain are all others. Healthcare skews towards older devices, because patients on remote monitoring skew older and poorer, which is precisely the population least likely to be on a current OS with full-disk encryption behind a strong passcode. The threat model is not “a security researcher with a Pixel”. It is a second-hand phone sold with the account still on it.
Worse, the token had a 30-day life and no device binding. Once copied, it kept working for a month, from anywhere, and revocation depended on the patient noticing something they had no way of seeing.
Attack Path Sequence
← Swipe horizontally to view full sequence flow →
The Remediation Block (Before vs After)
// JWT stored in UserDefaults — unencrypted plist
func saveToken(_ token: String) {
UserDefaults.standard.set(
token, forKey: "vc_jwt"
)
}
func loadToken() -> String? {
UserDefaults.standard
.string(forKey: "vc_jwt")
}// Store in Keychain, hardware-backed where available
import Security
func saveToken(_ token: String) {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "vc_jwt",
kSecValueData as String: data,
kSecAttrAccessible as String:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}Two details in the Swift above carry most of the value. kSecAttrAccessibleWhenUnlockedThisDeviceOnly keeps the item out of backups and stops it migrating to a restored or cloned device — the plain WhenUnlocked variant does neither, and it is the one most sample code uses. The SecItemDelete before SecItemAdd is there because SecItemAdd returns errSecDuplicateItem on an existing account rather than overwriting; skip it and token refresh silently stops updating, leaving the app authenticating with a stale credential until the user reinstalls.
On Android we moved from raw SharedPreferences to EncryptedSharedPreferences with a Keystore-backed master key, so the token is encrypted at rest under a key that cannot be exported from the device.
The trade-off is real and worth stating. Keystore operations are slower than a plain preference read, which shows up on cold start where the app reads the token before its first API call — a few tens of milliseconds, invisible to most users and noticeable on low-end hardware. More importantly, Keystore keys are invalidated by events the app does not control: a lock-screen credential change, a biometric enrolment, and on some devices a system update. When that happens, decryption throws and the app must handle it by discarding the store and re-authenticating. Handle it badly and you ship a crash loop on exactly the users who just changed their PIN. The Jetpack Security library that provides EncryptedSharedPreferences has also had a long, slow release cadence; check its current status before committing, and treat the dependency as something to plan an exit from rather than a reason to keep plaintext.
// Android — EncryptedSharedPreferences backed by the Android Keystore
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"vc_auth_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("jwt", token).apply()
Critical Finding OC-MOB-002 — SSL Pinning Bypass Enabling MITM
VitalisCare pinned certificates, and the team was rightly proud of it — most apps at their stage do not. The problem is what pinning is for. It defends the user against a hostile network: a coffee-shop access point, a compromised router, an enterprise TLS-inspection appliance the patient never consented to. It has never defended the app against the person holding the device, because that person can change the code that performs the check.
The implementation was OkHttp’s CertificatePinner on Android and a URLSessionDelegate trust callback on iOS. Both live in application code, and application code on a rooted device is editable at runtime. A twelve-line Frida script replaced the check on both platforms; total time from clean device to cleartext PHI in the proxy was under ten minutes, most of it spent provisioning the device.
So the interesting result is not “pinning was bypassed”. It is that once bypassed, nothing else objected. The captured bearer token replayed cleanly from a laptop with no device identifier, no client certificate, and no anomaly signal — the API had delegated its entire trust decision to a control that only ever worked against a different attacker.
MITM Attack Vector
hookable at runtime?} Pin -->|Yes: Frida overrides verify| Bypass[Trust attacker cert]:::vuln Bypass --> Intercept[Proxy reads PHI in cleartext]:::vuln Pin -->|No: native + attestation| Reject[Handshake aborted]:::ok
Frida Pinning-Bypass Script (Proof-of-Concept)
// frida-okhttp-unpin.js — neutralise OkHttp CertificatePinner at runtime
Java.perform(function () {
const CertificatePinner = Java.use('okhttp3.CertificatePinner');
// Force the pinning check to always succeed
CertificatePinner.check.overload(
'java.lang.String', 'java.util.List'
).implementation = function (hostname, peerCertificates) {
console.log('[+] Bypassed pinning for: ' + hostname);
return; // no exception thrown => pin "valid"
};
});
// Run: frida -U -f com.vitalis.care -l frida-okhttp-unpin.js --no-pause
Note what the script does not do: it never touches the certificate chain, the trust store, or the OS. It changes one method to return instead of throwing. That is why “we obfuscate the pinning code” is not an answer — obfuscation makes the method harder to find, not harder to replace once found, and it needs finding exactly once per release.
With pinning defeated, the proxy captured a live PHI request in cleartext:
GET /v1/patients/me/labs HTTP/2
Host: api.vitalis.example
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Device-Id: 9F3A-CC21-7741
HTTP/2 200 OK
Content-Type: application/json
{"patientId":"pt_88231","labs":[{"test":"HbA1c","value":"7.9%","date":"2026-05-02"}]}
Hardened Pinning Strategy
We moved pinning out of application code and into OS-enforced configuration, then stopped relying on it. Declarative pinning — Android’s Network Security Config, NSPinnedDomains in the iOS Info.plist — is validated by the platform’s networking stack rather than by a method the app owns, which puts it out of reach of a trivial Java-layer hook. It is not out of reach of a determined attacker with kernel-level access; it raises cost, it does not close the door.
<!-- Android — declarative Network Security Config (OS-enforced pinning) -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.vitalis.example</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">k3y0fThePr1maryLeafCert0000000000000000000=</pin>
<pin digest="SHA-256">BackUpP1nF0rR0tati0n00000000000000000000000=</pin>
</pin-set>
</domain-config>
</network-security-config>
The expiration attribute and the backup pin are the two lines that stop this configuration becoming an outage. Pinning fails closed: if every pin in the set stops matching, the app cannot reach the API at all, and no amount of restarting fixes it — the fix is a store release, which on iOS means review time you do not control. A backup pin covering the next key in the rotation is what makes certificate renewal survivable. The expiration date is the deliberate escape hatch: past it, Android stops enforcing the pin set rather than bricking the app, which is the right default for a control whose worst realistic failure is self-inflicted denial of service against your own patients. Pin to the intermediate CA’s key rather than the leaf if your certificate rotation is automated and you value uptime over the last increment of strictness. That is a genuine trade, not a best practice with one right answer.
Alongside the config we shipped the layers that matter more: jailbreak and Frida detection feeding telemetry rather than hard app termination, short-lived access tokens with refresh bound to the device, and mutual TLS with a device-provisioned client certificate on the high-sensitivity PHI endpoints. The detection is explicitly not a security boundary — anti-tamper checks are themselves hookable, and an app that hard-exits on a false positive locks out legitimate users on customised ROMs. Its value is the signal: a session that reports integrity.frida_detected and then pulls forty patient records is an alert someone can act on. mTLS is what actually stopped the replay, because a stolen bearer token without the device’s client certificate is now inert.
Critical Finding OC-MOB-003 — Hardcoded API Key via Decompilation
A grep for AIza across the decompiled APK returned a production maps and analytics key on the first pass. This is the least sophisticated finding in the report and, in our experience, the most common: every mobile codebase accumulates keys in constants, in gradle.properties that get compiled in, in strings.xml, in a .env bundled as an asset. ProGuard and R8 do not help — they rename symbols, not string literals, so API_KEY becomes a while the key itself sits there in plaintext.
The quota abuse is the boring part of the impact. The real problem was scope: the key was unrestricted, so it worked from any origin and against every API enabled on the project, including two the mobile app had no reason to touch. An extracted key is not a leaked string, it is whatever that string is authorised to do — and nobody had ever asked what this one was authorised to do.
Recovery via Static Analysis
# Smali excerpt from the decompiled APK (com/vitalis/care/Config.smali)
.field public static final API_KEY:Ljava/lang/String; =
"AIzaSyB-VitalisProd-3kf9whardcodedKEY8x21q"
# Or trivially, from the raw binary strings:
$ apktool d vitaliscare-release.apk -o out/
$ grep -rni "AIza" out/ # Google-style API key prefix
out/smali/com/vitalis/care/Config.smali: "AIzaSyB-VitalisProd-3kf9w..."
Remediation: Remove Secrets from the Client
There is no way to hide a secret in a binary you hand to the attacker. Encrypting it moves the problem to the decryption key, which is also in the binary; native code moves it to a disassembler; a remote fetch on first launch moves it to the proxy we already own. Every one of these is a delay, priced in hours.
So the fix is architectural: the client stops holding anything worth stealing. We rotated the key the same day, applied package-name and SHA-1 signing restrictions plus a per-API allowlist to what remained client-side, and moved the privileged calls behind our own endpoint that injects the key from a vault server-side.
The costs are worth naming. A backend proxy adds a network hop and a service to run, monitor and keep available — if the proxy is down, mapping is down, where previously the client talked to the upstream directly. It also puts your infrastructure in the path of the upstream’s rate limits, so one abusive user now consumes quota attributed to your service account rather than to a key you could revoke in isolation. For a key that is genuinely low-value and unavoidably client-side, restrictions alone are the proportionate answer. For anything that touches PHI or costs real money per call, the proxy earns its keep. Rotation, meanwhile, is the part teams skip: a key that has been in a public app store binary for eighteen months should be treated as known to everyone, and restricting it without rotating it leaves the old value valid.
// Secret compiled into the client binary
object Config {
const val API_KEY =
"AIzaSyB-VitalisProd-3kf9w..."
}
val url = "$BASE/geo?key=" + Config.API_KEY// Client holds NO secret — calls our backend,
// which injects the key server-side from a vault.
val res = api.geocode(
GeoRequest(address = q)
) // Authorization: short-lived user token only
// Backend (never shipped to device):
// key = vault.read("maps/api_key")
// upstream.get(".../geo?key=$key")PHI Exposure Risk Score · Before vs After
A composite score across local storage, transport security, secret exposure and platform configuration, tracked weekly through the engagement. Read it as a shape, not a measurement: the score is our own weighting of findings by severity and exploitability, so “93.7 to 2.0” is a way of showing that the two step changes came from the Keychain/Keystore migration in week three and the pinning-plus-mTLS release in week five, not from steady incremental effort. Composite risk scores are useful for showing an executive where the effort paid off and dangerous when anyone starts treating the number as an absolute. Two is not zero, and nothing on this chart says the next release cannot reintroduce all of it.
Systemic Risk Mitigation Velocity
Composite PHI-exposure score across the iOS and Android builds
Live MITM Interception Simulator
Replay a PHI request through the device and toggle between the two builds. The Frida hook is active in both — that is the point. The pre-engagement client hands the proxy a bearer token and a patient’s HbA1c result; the hardened build aborts the handshake at the OS layer, and just as usefully, emits telemetry saying an instrumented device tried. The second line is what turns a bypass attempt from an invisible event into an investigable one.
Bearer eyJhbGci… + HbA1c 7.9%Quantifiable Business Impact
Every PHI-exposure path we proved was closed and re-tested before the release reached a patient device. The compliance framing needs care, though: HIPAA has no certification and no auditor signs off a mobile app. What Vitalis gained is evidence — a documented assessment, findings with proof, remediation with re-test results — which is what the Security Rule’s risk-analysis requirement actually asks for and what a payer’s due-diligence questionnaire actually wants to see. That is worth a great deal in a partner conversation. It is not a certificate, and describing it as one is how organisations end up surprised during an enforcement action.
Strategic Takeaways
Three findings, one underlying mistake: the app was written as though the device were part of the trusted infrastructure. It is not. The binary, the runtime and the filesystem all belong to whoever is holding the phone.
- Treat the device as hostile, and be specific about who the attacker is. Cleartext on disk is already compromised on a rooted, jailbroken, backed-up, resold or malware-carrying device — and in healthcare, the resold handset is the realistic case, not the researcher’s Pixel. Tokens and PHI belong in Keychain/Keystore with the device-only accessibility attribute. Budget for the cost: Keystore keys are invalidated by credential and biometric changes, so re-authentication is a path you have to handle rather than an edge case you discover in crash reports.
- Client-side controls buy time and generate signal; they do not enforce anything. Pinning, obfuscation and jailbreak detection all raise the attacker’s cost, and all of them fall to someone with a rooted device and an afternoon. Move enforcement to the OS where you can, then assume it fails: short-lived tokens, device-bound mTLS and telemetry on tamper indicators are what still hold when the client is fully owned. And remember pinning fails closed — a pin set without a backup pin and an expiry is an outage waiting for your next certificate renewal.
- Nothing secret survives shipping. Encryption, native code and remote fetch all relocate the secret rather than protect it. Route privileged calls through a backend, scope whatever must stay client-side to the narrowest possible permissions, and rotate anything that has already shipped — restricting a key without rotating it leaves the exposed value working.
- The fix is verified on a rebuilt binary, not on a diff. Every remediation here was re-tested against a fresh release build on a fresh device. Two of them needed a second pass. A code review that looks correct and an artefact that behaves correctly are different claims, and only one of them is evidence.
Ready to secure your architecture?
Initiate a full cryptographic security review, IAM baseline audit, and penetration testing engagement for your organisation.
System Schema & Architecture
Curated diagrams, interface snapshots, and architectural blueprints illustrating our core technical approach and environment mapping.
Hear it straight from Vitalis Health
“"We were weeks from our biggest launch, with payer partners and clinicians depending on us to protect highly sensitive patient data. The assessment team reverse-engineered our app, pulled auth tokens straight off a rooted device, bypassed our SSL pinning to read live API traffic, and recovered an API key we thought was safely buried in the binary. Every finding came with working proof and a precise, production-ready fix. They turned a launch-blocking risk into a HIPAA-aligned security story we now tell with confidence."
Webster Herzog
Co-Founder & CTO at Vitalis Health
AI & Machine Learning Pentesting
Hardening autonomous LLM agents against jailbreaks, prompt injection, and RAG leakage using our adversarial-ML testing harness
API Penetration Testing
Eliminating BOLA mass-data exposure, GraphQL introspection-driven rate-limit bypasses, and a blind SQL injection buried in an undocumented legacy logistics endpoint