BigBrainParking/docs/OFFICIAL_APP_REVERSE_ENGINEERING.md
Erik 2434879804 docs: RE report + correct the privacy writeup from live analysis
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>
2026-07-16 19:11:26 +00:00

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 untouched config.hdpi split, 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 / getSimSerialNumber in the Java or the bundle. Every getDeviceId() match in the Java is unrelated — MotionEvent/KeyEvent device IDs (input routing) or Context.getDeviceId() (the API-34 virtual device id, an int). react-native-device-info (which could read IMEI) isn't bundled at all.
  • READ_PHONE_STATE comes from @react-native-community/netinfo, which uses TelephonyManager only for getNetworkOperatorName() (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.