Adds OFFICIAL_APP_REVERSE_ENGINEERING.md — a technical teardown (methodology, auth model, device-ID, GPS, Instabug) with mermaid diagrams and the mitmproxy capture timeline. Corrects OFFICIAL_APP_PRIVACY.md, which was wrong on two counts: - IMEI: the app reads no IMEI; it sends the Android SSAID to /api/Device in a field misleadingly named IMEINumber. - Trackers: Segment/Amplitude/Firebase-Analytics/Sentry/ad-ID are all ABSENT; the real telemetry SDK is Instabug (session replay, screenshots, network logs). Also folds in the confirmed GPS auto-send (with the GrapheneOS caveat). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
Reverse-engineering the official ParkSmarter app
A technical teardown of the official Park Smarter Android app
(com.ipsgroupinc.parksmarter, v4.4.0, versionCode 170), focused on what
data leaves the device, when, and to whom — with special attention to the
device identifier and GPS location. Combines static decompilation with a
live man-in-the-middle capture.
Independent, good-faith security/privacy research on a publicly distributed app, for the purpose of building a privacy-respecting alternative (BigBrainParking). Not affiliated with or endorsed by IPS Group. Findings reflect v4.4.0. The full decompiled source is kept in a private mirror; this report quotes only the excerpts needed as evidence.
TL;DR
| Claim | Verdict | Evidence |
|---|---|---|
| The app reads your hardware IMEI | False | No TelephonyManager.getImei/getDeviceId anywhere; react-native-device-info isn't even bundled; and Android 10+ blocks IMEI for normal apps regardless. |
| The app sends a persistent device ID to its backend | True | PUT /api/Device carries the Android SSAID in a field misleadingly named IMEINumber, plus the push token as DeviceID. |
| The app sends your GPS to its servers | True, for meter search only | GET /api/Meter?Lat=…&Long=…; captured live. Not attached to login/session/payment. |
| GPS is sent automatically on map open | True in code, situational at runtime | Wired to map-ready when authenticated + permission granted; on GrapheneOS the map fails to render (needs Play Services), which suppresses the auto-fetch. |
| Bundles Segment / Amplitude / Firebase-Analytics / Sentry | False | None present. |
| Bundles heavy telemetry | True | Instabug — session replay, screenshot capture, network-request logging, visual user-steps → api.instabug.com. |
| Advertising ID / install-referrer | False | Neither is present. |
Methodology
Both static and dynamic analysis were used.
flowchart LR
A["Park+Smarter_4.4.0.xapk<br/>(base + 3 config splits)"] --> B[jadx 1.5.1<br/>dex → Java]
A --> C["hermes-dec<br/>index.android.bundle → JS"]
A --> D[apktool 2.10.0<br/>manifest + resources]
A --> E["APKEditor merge → 1 universal APK<br/>+ apk-mitm (trust user CA)"]
E --> F["mitmproxy on device<br/>(live HTTPS capture)"]
B --> G[(Findings)]
C --> G
D --> G
F --> G
- jadx decompiled the dex → Java. This is only the RN framework + native modules + SDKs; the app's own logic is not here.
- The app is React Native / Expo SDK 53, so its business logic ships as
Hermes bytecode (
assets/index.android.bundle, Hermes v96). hermes-dec recovered readable pseudocode (decomp.js) with de-obfuscated function names. - apktool decoded the manifest and resources.
- For the live capture, the split APKs were merged into one universal APK
(APKEditor) and repackaged to trust a user CA (
network_security_config), then driven through mitmproxy. (Merging first was necessary: patching only the base split renumbers its resource IDs and desyncs the untouchedconfig.hdpisplit, which crashes the app on the first text field.)
App architecture & network model
Expo SDK 53 RN app. All API traffic goes through one request builder that stamps these headers on every call:
| Header | Value | Notes |
|---|---|---|
Application_Token |
B66EEDDA-… (static) |
App-wide, embedded in the bundle |
X-Request-Id |
random UUID | Per request |
Auth_Token |
rolling | Rotates on each response |
ParkSmarter_SessionId |
per session | |
Content-Type |
application/json |
POST/PUT only |
User-Agent |
okhttp/4.12.0 |
RN's HTTP stack |
Five hard-coded environments (bundle string literals):
- Prod:
https://apiv2.parksmarter.com,https://apiv3.parksmarter.com - Staging:
https://staging-parksmarter-api.ipsmeters.com - Testing:
https://testing-parksmarter-api.ipsmeters.com
No TLS pinning is configured (okhttp's CertificatePinner class is present but no
pins are set), and the manifest declares no networkSecurityConfig — which is why
a stock install can't be MITM'd without repackaging.
Finding 1 — Device identifier: the "IMEINumber" that isn't an IMEI
The app defines a Redux thunk literally named device/setIMEINumberAsyncThunk.
Despite the name, it never reads an IMEI. It reads the Android SSAID
(Application.androidId from expo-application), caches it in secure storage as
psUUID, and later transmits it.
Evidence (decomp.js):
// getAndroidId → expo-application's Application.androidId (the SSAID)
r0 = r0.default; r0 = r0.androidId; return r0;
// stored under 'psUUID'
r2 = 'psUUID'; r2 = setObjectAsync.bind(...)('psUUID', androidId);
// later placed on the wire:
r2['DeviceID'] = pushNotificationsToken;
r2['IMEINumber'] = imeiNumber; // === Application.androidId (SSAID)
r2['DeviceType'] = '1';
// endpoint: putUpdateDeviceToken → { url: '/api/Device' }
Confirmations:
- No real IMEI read. Zero
getImei/TelephonyManager.getDeviceId/getSubscriberId/getSimSerialNumberin the Java or the bundle. EverygetDeviceId()match in the Java is unrelated —MotionEvent/KeyEventdevice IDs (input routing) orContext.getDeviceId()(the API-34 virtual device id, anint).react-native-device-info(which could read IMEI) isn't bundled at all. READ_PHONE_STATEcomes from@react-native-community/netinfo, which usesTelephonyManageronly forgetNetworkOperatorName()(carrier name) and network type — not identity.- Modern Android blocks it anyway: since Android 10,
getImei()throws for non-privileged apps regardless of the permission.
sequenceDiagram
participant App
participant OS as Android OS
participant PS as apiv2.parksmarter.com
Note over App: cold start / login / token change
App->>OS: Application.androidId (SSAID)
OS-->>App: e.g. "a1b2c3…"
App->>App: cache as psUUID (secure store)
App->>PS: PUT /api/Device<br/>{ IMEINumber: SSAID, DeviceID: pushToken, DeviceType: "1" }
Note over PS: a stable per-device ID,<br/>tied to the account — just not the IMEI
Net: the app does transmit a persistent, per-device identifier to its own
backend, mislabeled IMEINumber. It is the SSAID, not the hardware IMEI, and it
is not sent to any third party. The earlier "sends your IMEI" claim was wrong
in substance but pointed at something real — this field.
Finding 2 — GPS location
Location is acquired via expo-location (getLastKnownPositionAsync +
watchPositionAsync at Accuracy.Balanced) and sent to the backend only on
the meter-search endpoints:
GET /api/Meter?Lat=<lat>&Long=<long>&localeCode=en-US
The query carries only Lat/Long (no radius/limit); "all vs limited meters"
is an endpoint choice, not a parameter. Location is not attached to login,
session load, or payment — confirmed both in code and in the live capture.
Auto-send on map open. The map hub wires useAutoGoToUserLocation, a state
machine that — when isAuthenticated && isMapReady && locationPermissionGranted
and the app is foreground — recenters on the user's GPS and fires the meter query
without any tap. Panning/zooming fires more (debounced).
sequenceDiagram
participant User
participant Map as Map screen
participant Loc as expo-location
participant PS as apiv2.parksmarter.com
User->>Map: open Map (logged in, permission granted)
Map->>Loc: watchPositionAsync (Balanced)
Loc-->>Map: {lat, long}
Map->>PS: GET /api/Meter?Lat=…&Long=… (auto, no tap)
User->>Map: pan / zoom
Map->>PS: GET /api/Meter?Lat=…&Long=… (debounced)
GrapheneOS caveat (observed). react-native-maps needs Google Play Services
for tiles; on GrapheneOS the map doesn't render, so isMapReady may never flip
and the auto-fetch can be suppressed. In the live capture the single coordinate
sent (Lat=48.274147628&Long=-116.550122619) exactly matched a known lot
location rather than a fresh arbitrary GPS fix — consistent with the map being
degraded. Location egress is proven; the fully-automatic-on-open behavior is a
property of the code that a Play-Services device would exercise more visibly.
BigBrainParking, by contrast, never sends GPS to the API except when you explicitly tap "My location" and search; the map opens on your last lot from history, not your GPS.
Finding 3 — Third-party telemetry: Instabug (and only Instabug)
Contrary to the earlier analysis, Segment, Amplitude, Sentry, Firebase Analytics, Crashlytics, advertising-ID, and Play install-referrer are all absent (verified by package-dir and endpoint-host search). Firebase is present only as Cloud Messaging (push), not analytics.
The one real telemetry SDK is Instabug (2,328 classes), and its scope is broad:
- Session replay (
library/sessionreplay) - Screenshot / screen capture (
instacapture,screenshot) - Network-request logging & interception (
apm/networking,networkinterception) - Visual user-steps / interaction tracking (
visualusersteps,interactionstracking) - Crash reports, APM, surveys, user attributes
All reporting to api.instabug.com. Captured live on startup:
POST /api/sdk/v3/sessions/v2, GET /api/sdk/v3/features, /api/sdk/v3/first_seen.
flowchart TD
D[Your device]
D -->|"SSAID as IMEINumber + push token<br/>GPS (Lat/Long) on meter search<br/>account, vehicle, payment, sessions"| PS[apiv2.parksmarter.com]
D -->|"session replay, screenshots,<br/>network logs, user-steps, crashes"| IB[api.instabug.com]
D -->|push registration| FCM[Firebase Cloud Messaging]
style PS fill:#294,color:#fff
style IB fill:#922,color:#fff
style FCM fill:#247,color:#fff
Permissions (from the manifest)
Present: ACCESS_FINE/COARSE_LOCATION, READ_PHONE_STATE (netinfo),
ACCESS_WIFI_STATE, CAMERA, RECORD_AUDIO, BLUETOOTH_SCAN/CONNECT (meter
BLE), POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, FOREGROUND_SERVICE,
USE_BIOMETRIC, SYSTEM_ALERT_WINDOW, DETECT_SCREEN_CAPTURE, install-referrer
service binding, plus a long list of launcher badge permissions.
Absent (important): ACCESS_BACKGROUND_LOCATION. The app cannot track
location while closed.
Live capture — evidence timeline
Captured through mitmproxy against a repackaged (user-CA-trusting) universal APK, authenticated session:
18:44:07 GET apiv2.parksmarter.com/api/ParkingSession
18:44:07 GET apiv2.parksmarter.com/api/Session
18:44:08 POST api.instabug.com/api/sdk/v3/sessions/v2 (414B) ← telemetry
18:44:08 GET api.instabug.com/api/sdk/v3/first_seen
18:44:16 GET apiv2.parksmarter.com/api/Meter?ZoneName=dl
18:44:17 GET apiv2.parksmarter.com/api/Meter?Lat=48.274…&Long=-116.550… ← GPS
18:44:20 GET apiv2.parksmarter.com/api/ParkingEstimateItems
18:44:33 GET apiv2.parksmarter.com/api/ApplicationValidity
18:44:39 GET apiv2.parksmarter.com/api/ParkSmarterPrivacyPolicies
Header sample on a parksmarter request: Application_Token: B66EEDDA-…,
Auth_Token: … (rolling), ParkSmarter_SessionId: …, User-Agent: okhttp/4.12.0.
PUT /api/Device (the IMEINumber PUT) did not fire in this session because
the login was cached — it sends on fresh login / token change.
Reproducing this
The repackaged capture-ready APK and step-by-step mitmproxy instructions live in
the private source mirror (ParkSmarterSourceCode, MITMPROXY.md). In short:
merge splits → inject a user-CA network_security_config → resign → install the
CA as a user cert → proxy the phone through mitmproxy (mobile data off so the
Wi-Fi proxy applies).
Appendix — backend endpoint inventory (from the bundle)
ApplicationValidity, Auth, SignUp, User, Device, Session,
ParkingSession, Meter (by location / scanner code / serial / zone name),
ParkingLots/ParkingLogix, ParkingEstimateSingle/Multi/Items,
StartParkingSession, ParkingReceipt, credit-card + vehicle CRUD,
NotificationSettings, ParkSmarterPrivacyPolicies / Terms / About / FAQ.