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

277 lines
12 KiB
Markdown

# 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.
```mermaid
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`):
```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.
```mermaid
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).
```mermaid
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`.
```mermaid
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`.
```