Methodology, techniques & reproduction steps
How every finding in this repository was produced. This is the “lab notebook” companion to the evidence docs. It collects the prerequisites, the techniques used, the exact reproducible steps, and the gotchas we hit, so that an independent reviewer can re-run any part of the investigation and arrive at the same result.
The actual findings live in the evidence docs and are not repeated here in full:
identity-correlation-proof.md— location ↔ identity proof (static + live captures)crowd-connected.md/crowd-connected-faq.md— the location SDK deep-divebierroute-gamification.md— badge/games protocolhardcoded-credentials-and-keys.md— secrets shipped in the APKprivacy-policy-analysis.md/public-disclosure-comparison.md— policy vs. codereverse-engineering.md/trackers-and-sdks.md— decompilation notesappmiral-comparison/00-app-inventory.md— the 114-app white-label cohort comparison (CrowdConnected presence, config, privacy-policy reality check)
0. Scope, ethics & consent
- Subject device is the researcher’s own phone. All root-level manipulation (CA injection, GPS mocking, database edits, traffic interception) was performed by the device owner, on their own device, on their own account/data. Geert de Graaf is the data subject for every captured record.
- No third-party data was touched. Only the researcher’s own GPS, account id, name and email appear in any capture.
- The production app was never repackaged or modified. We deliberately analysed the unmodified, Play-signed binary (see §6) so that no finding can be dismissed as “you tampered with the APK.” Static analysis used a read-only decompile.
- We declined contest fraud. When badge-unlock
secrets came up, we documented the protocol but refused to harvest/forge secrets to win the prize contest. Legitimate protocol analysis only. - Data minimisation during dynamic tests. Where we needed an “in-zone” GPS fix, we first tried widening the local geofence so the researcher’s real coordinates qualified, rather than injecting fabricated coordinates into the vendor’s production analytics. GPS mocking (§5) was used only later for the controlled outer-zone (Vries) test, and the buffered records were blocked from reaching AWS where possible.
1. Environment & prerequisites
1.1 Hardware / OS
| Item | Value |
|---|---|
| Device | Google Pixel 10a |
| Android | 16 (SDK 36) |
| Root | Magisk (no modules installed) |
| ADB serial | 5B161JEA308016 (USB transport) |
| App | com.appmiral.ttfestival v2.0.1 (also be.appstrakt.gmm2010 for Graspop cross-checks) |
| App UID | u0_a326 |
| Screen | 1080 x 2424 |
1.2 Host (Windows)
| Tool | Use | Notes |
|---|---|---|
adb (platform-tools) |
device control | daemon occasionally crashes — just retry |
| Python 3.11 | decoding buffers, reading SQLite/WAL, flow analysis | |
mitmproxy / mitmdump |
TLS interception | pip install mitmproxy |
| jadx 1.5.1 | APK decompilation | needs Java 17 (C:\Program Files\Microsoft\jdk-17.0.19.10-hotspot) |
| Node v24 / npm 11 | only if using apk-mitm as a fallback | not the primary path |
1.3 On-device tooling (present without installing anything)
/system/bin/tcpdump— packet captureiptablesv1.8.11 withxt_string(SNI string-match) andnattablescmd locationtest-provider support (AOSP mock locations)uiautomator,input,monkey,am,pm,appops,dumpsys,settings- busybox,
nsenter - Not present: frida, mock-location apps, Magisk modules,
pidof(usepgrep/ps -A | grep)
1.4 General ADB / PowerShell gotchas (apply everywhere below)
- Multi-line
su -c "<script>"loses root / gets mangled by host PowerShell. Fix: write the logic into a.shfile,adb pushit, and runsu -c "sh /data/local/tmp/x.sh". $(...),| wc -l,headinsideadb shell "su -c '...'"are rewritten by PowerShell — same fix (push a script).- Binary reads must use
adb exec-out, notadb shell.adb shell su -c "cat <bin>" > outcorrupts binary via PTY/CRLF translation;adb exec-out su -c "cat <bin>" > outis byte-exact. adb pushcreates a new inode — it breaks an existing bind-mount over the target path.- Python via
python - <<HEREwith escaped quotes fails in PowerShell — write a.pyfile and run it instead. input textuses%sfor spaces (e.g.input text "de%sGraaf").
2. Reading on-device app data (rooted, no interception)
This is the cheapest technique and produced the first wave of findings (games config, identity storage, SDK state) without any network tooling.
2.1 okHttp response cache (API responses, offline)
The Appmiral public API returns 403 Missing authentication to outside callers (the auth header is
added by an in-app network interceptor). Instead of replaying requests, read what the app already
cached:
# okHttp cache lives at /data/data/<pkg>/cache/okHttp/
# *.0 = URL + request/response headers (first line is the URL)
# *.1 = response body (often gzipped)
adb exec-out su -c "cat /data/data/be.appstrakt.gmm2010/cache/okHttp/<hash>.1" > games.gz
python -c "import gzip;open('games.json','wb').write(gzip.decompress(open('games.gz','rb').read()))"
- Gotcha:
cpto/sdcardfails for0700dirs (files/,shared_prefs/) under SELinux but works for0771databases/. Reading individual files withsu -c catworks regardless of mode. - What it proved: the GMM/TT games structure, badge
codes, and that the badgesecretis not present in any client-readable JSON (→ server-side only). Seebierroute-gamification.md.
2.2 shared_prefs (identity storage)
adb exec-out su -c "cat /data/data/com.appmiral.ttfestival/shared_prefs/<name>.xml"
Key files and what they hold:
| File | Holds |
|---|---|
AppmiralUUID.xml |
app_user_id / AppmiralUUID (the client tracking UUID) |
ProfileIdentityProvider.xml |
cleartext Email / Firstname / Lastname / Marketing.Consent |
TemplateStorage.xml |
app_user_id == user_id, user_login_provider, name, email together |
CrowdConnected.xml |
CC deviceId, appKey, Cognito authToken, aliasKeys, settings timestamp |
SnsApi.xml |
the id packaged for AWS SNS push (Sns.Platform.Userdata) |
This is where the local join between the tracking id and real identity is visible on disk.
2.3 Room/SQLite databases with WAL (the Crowd Connected state)
/data/data/com.appmiral.ttfestival/databases/CrowdConnected-RoomDatabase carries the live SDK
config. You must read the -wal/-shm sidecars too or you get stale values.
# Pull all three binary-safe, then merge WAL in Python:
adb exec-out su -c "cat .../CrowdConnected-RoomDatabase" > cc.db
adb exec-out su -c "cat .../CrowdConnected-RoomDatabase-wal" > cc.db-wal
adb exec-out su -c "cat .../CrowdConnected-RoomDatabase-shm" > cc.db-shm
python -c "import sqlite3;c=sqlite3.connect('cc.db');c.execute('PRAGMA wal_checkpoint(TRUNCATE)');c.commit()"
Tables: Settings (active, frequency_1/2, sendPositionData), GeoZone
(lat/lng/radius1/radius2/zoneID), SDKStatus (deviceId, aliasKeys, aliases), Surface,
Beacon.
- What it proved: the geo-zone (Assen
52.994439, 6.563694, innerradius1=820 m, outerradius2=60000 m,frequency_1=30 s/frequency_2=1800 s) and — critically — that the serveractiveflag flippedfalse(pre-festival) →true(live), demonstrating the remote kill-switch. Seecrowd-connected.md§9.
3. Static analysis (APK decompilation)
3.1 Pull and decompile the unmodified APK
# The app is split: base + arm64_v8a + xxhdpi. Find the install path:
adb shell pm path com.appmiral.ttfestival
adb exec-out su -c "cat /data/app/~~.../base.apk" > base.apk
# Decompile (Java 17 on PATH):
jadx -d src base.apk # ~16,900 java files for this build
3.2 Key code findings (where to look)
| Claim proven | File / symbol |
|---|---|
app_user_id is client-generated UUID.randomUUID() on first launch, not server-issued |
com/appmiral/AppmiralApp.java → getGeneratedUUID(), KEY_UUID="AppmiralUUID" |
| The id is aliased to the location stream unconditionally | com/appmiral/crowdconnected/CrowdConnected.java → onStartUpSuccess() always setAlias("app_user_id", …); login_user_id only if logged in |
| The id is POSTed off-device to CC | net/crowdconnected/android/core/API.java → sendAlias() → POST https://mobile.crowdconnected.com/prod/device-alias[/cached], header x-cognito-token, body {appKey, deviceID, aliasKey, aliasValue} |
| CC reads location via Play Services (so AOSP mocks must be empirically verified) | net/crowdconnected/android/geo/GeoObserver.java → FusedLocationProviderClient |
| Badge unlock is a server POST with full account headers; “Game closed” is a server error | com/appmiral/gamification/api/BadgeService.java, view/BadgeDetailFragment.java, api/ApiBadgeErrorResponse.java |
| Hardcoded CC keys + AWS Cognito client secret shipped as plain resources | see hardcoded-credentials-and-keys.md |
- Why static + dynamic: static analysis tells you what the code can do; the captures in §6–§7 prove what it actually did on the wire. Both are needed to rebut “that code path isn’t used.”
3.3 Scaling to the whole app cohort (114 apps)
The single-app decompile above was repeated across the Appmiral white-label fleet so config
could be compared platform-wide (see
appmiral-comparison/00-app-inventory.md).
Enumerate what’s installed:
adb shell pm list packages -3 # -3 = third-party (user-installed) only
adb shell pm path <pkg> # base.apk + split_config.*.apk for App-Bundle apps
Harvest APKs two ways: (a) adb pull the exact installed bytes (preferred), and (b) a
Playwright + Firefox downloader for apps not installed — the public APK mirror sits behind
Cloudflare and headless Firefox cleared the interstitial more reliably than Chromium; it
pulls the full XAPK (base + config splits), installed with
adb install-multiple base.apk split_config.*.apk. INSTALL_FAILED_NO_MATCHING_ABIS means the
mirror only had a 32-bit (armeabi_v7a) archive — unrunnable on the arm64-only Pixel 10a. The
app list came from scraping https://appmiral.com/our-work.
Fast per-app config scan (no full jadx): aapt2 dump resources <apk> and grep the string
table — e.g. the CrowdConnected keys, value on the line below each match:
$aapt2 = "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe"
& $aapt2 dump resources <apk> | Select-String 'string/crowdConnected'
This built the per-app appKey/token/secret table in
appmiral-comparison/crowd-connected-endpoints.md
across all 114 apps without decompiling each one.
3.4 Bulk APK harvest + integration matrix + live-config audit (2026-06-28)
The 2026-06-28 re-harvest turned the above into reusable, parameterized tooling
under ../tools/apk-harvest/ and scaled it to the whole
installed cohort (140 apps): pull every split-APK, statically scan each binary
for the integrations Appmiral advertises (presence-only, matched-pattern recorded),
and decode every app’s live Crowd Connected km-range + remote kill-switch from
its on-device Room DB / shared_prefs (root). It is checksummed end-to-end (public
apk-manifest.sha256 == private SHA256SUMS.txt) and has a one-command entry point
(run-all.ps1). Full standalone replication guide — prereqs, pinned versions, exact
commands, expected outputs, determinism caveats — is
appmiral-comparison/10-apk-harvest-reproduction.md;
results in 07 /
08 /
09.
PowerShell gotchas (cost time, documented so you avoid them): write unicode files via
[System.IO.File]::WriteAllText(path,text,(New-Object System.Text.UTF8Encoding $false)) and set
$env:PYTHONUTF8=1; "$t:" parses : as a scope sigil — use ("{0}:" -f $t); format
coordinates with InvariantCulture F7 (an EU/Dutch locale emits a decimal comma and corrupts
a GPS fix); pull binaries with adb pull (a PowerShell > redirect corrupts binary output).
4. Network blocking with iptables (controlled buffering)
Used to (a) stop the on-launch settings poll from reverting a locally-edited geofence, and (b) hold location records on-device so they could be decoded without shipping them to AWS.
4.1 SNI string-match DROP (robust to IP rotation)
# Block by TLS SNI hostname regardless of the (rotating) AWS IP:
iptables -A OUTPUT -p tcp --dport 443 -m string --string 'firehose.us-east-1.amazonaws.com' --algo bm -j DROP
iptables -A OUTPUT -p tcp --dport 443 -m string --string 'mobile.crowdconnected.com' --algo bm -j DROP
# Remove afterwards:
iptables -D OUTPUT ... # (delete each rule you added)
/system/etc/hostsblackhole does NOT work on this device — the Android resolver /pingbypassed it and resolved the real AWS IP. The SNI match is the reliable approach.- The CC settings host is
mobile.crowdconnected.com(AWSus-east-1EC2). The geo sink isfirehose.us-east-1.amazonaws.com. They are different endpoints — block selectively. Cognito/SNS ineu-west-1were left reachable. - Why this matters for buffering: Firehose uploads use a cached Cognito
authTokenfromCrowdConnected.xml, so they still fire even with the settings host blocked — you must block the Firehose endpoint itself to make records buffer locally.
4.2 Decoding the local Kinesis buffer
When Firehose is blocked, records accumulate in a flat append-only text file (not SQLite):
cache/CrowdConnected/firehose/positions/KinesisRecorder/kinesis_firehose_records
# each line: colocator-v3-positions-firehose,<base64(CSV record)>
decode_buf.py base64-decodes each line to the CSV position record:
deviceId,geo,projX,projY,lat,lng,surfaceId,maxRssi,beaconX,beaconY,
surfaceIdForMaxRssi,numObservedSurfaces,use|discard,calcTime,timestampMs,quality,sequence,appKey
- What it proved: ~56 records over ~33 min at ~31 s cadence, every record carrying only the CC
deviceId(the correlation key) — no name/email in the record itself, but joinable via the alias.
5. GPS mocking (AOSP test providers, no frida)
Used for the controlled outer-zone (Vries) test, to place the device at a known coordinate and characterise outer-zone cadence.
5.1 Set up a continuous mock
appops set 2000 android:mock_location allow # shell uid 2000
for p in gps fused network; do
cmd location providers add-test-provider $p
cmd location providers set-test-provider-enabled $p true
cmd location providers set-test-provider-location $p --location 53.0775,6.5836 --accuracy 12
done
Vries = 53.0775, 6.5836 (~9 km N of Assen): inside the 60 km outer zone, outside the 820 m
inner ring.
- Must push continuously (every ~3 s via a
setsidloop script,mockloop.sh) or Android expires the mock and real hardware GPS dominates.nohupdied on this device;setsidsurvived. - CC reads location through Play Services
FusedLocationProviderClient, which runs its own fusion and can ignoreLocationManagermocks. So the mock is not trusted until empirically verified by decoding a Firehose record and confirminglat/lng ≈ 53.077. (It worked — the mocked Vries coordinate appeared in every outer-zone Firehose record.) - Fallback if the fused mock had failed: download
frida-server(arm64) and hookLocation.getLatitude/getLongitudein the app process. Not needed in the end.
5.2 Teardown
# kill mockloop.sh, then:
for p in gps fused network; do cmd location providers remove-test-provider $p; done
appops set 2000 android:mock_location default
6. TLS interception of the unmodified app (the core capture harness)
The headline technique: decrypt the unmodified, Play-signed app’s HTTPS traffic by trusting a
mitmproxy CA at the system level, while keeping a raw tcpdump for corroboration. The app has no
certificate pinning, so once the CA is trusted, all URLs/headers/bodies are readable.
6.1 Why not apk-mitm?
apk-mitm repackages and re-signs the APK to add a user-trust network config. That works, but it
produces a modified binary that the operator could wave away as tampering — and this build is
wrapped in Google Play Licensing (com.pairip.licensecheck) that refuses to run unless installed by
Play (see §6.5). Injecting the CA into the OS trust store leaves the production binary untouched, so
it is the more evidentially credible path. apk-mitm remains a documented fallback.
6.2 Android 16 APEX trust store CA injection (reboot-free)
Android 14+ conscrypt reads CAs from the APEX store (/apex/com.android.conscrypt/cacerts, 145
certs), not /system/etc/security/cacerts, and ignores user-added certs. Inject the mitm CA by
bind-mounting an augmented cert directory:
# 1. Compute the Android subject hash of the mitm CA → filename <hash>.0
# (first 4 bytes of MD5(DER subject), little-endian). For our persistent CA: c8750f0d
# 2. Build an augmented dir = 145 originals + mitm cert as c8750f0d.0
mkdir /data/local/tmp/cacerts
cp /system/etc/security/cacerts/* /data/local/tmp/cacerts/
cp /data/local/tmp/mitm.pem /data/local/tmp/cacerts/c8750f0d.0
chown root:root /data/local/tmp/cacerts/*
chmod 644 /data/local/tmp/cacerts/*
chcon u:object_r:system_security_cacerts_file:s0 /data/local/tmp/cacerts/*
# 3. Bind-mount over APEX globally, then into init + zygote64 ONLY:
mount -o bind /data/local/tmp/cacerts /apex/com.android.conscrypt/cacerts
nsenter --mount=/proc/1/ns/mnt -- mount -o bind /data/local/tmp/cacerts /apex/com.android.conscrypt/cacerts
nsenter --mount=/proc/$(pgrep zygote64)/ns/mnt -- mount -o bind /data/local/tmp/cacerts /apex/com.android.conscrypt/cacerts
Fresh app launches fork from zygote64, inheriting the mount → they trust the mitm CA.
- CRITICAL gotcha — do NOT mount into every process namespace. A mass
nsentermount across all PIDs triggered a device reboot and lost the global mount. Onlyinit(pid 1) andzygote64are needed. - Never combine
set -ewithnsenter … && CNT++loops — one failingnsenteraborts the whole script. zygote64’s PID changes across reboots (885 → 889 in our runs); resolve it withpgrep zygote64.
6.3 Route traffic through mitmdump + raw tcpdump
# Host: mitmdump listens on 0.0.0.0:8080, writes a flow file
mitmdump -w flows_clean.mitm
# Bridge device→host. Device port 8080 was occupied, so we used 8081:
adb reverse tcp:8081 tcp:8080
adb shell settings put global http_proxy 127.0.0.1:8081
# Device: raw packet capture in parallel (corroborates the decrypted flows)
adb shell su -c "/system/bin/tcpdump -i any -s 0 -w /data/local/tmp/cap.pcap"
- Because of the port shift, the device proxy must be
127.0.0.1:8081while mitmdump binds:8080. - Keep both a
.mitm(decrypted) and a.pcap(raw) so the evidence does not rest on the proxy alone.
6.4 Clean-install A/B procedure
adb shell pm clear com.appmiral.ttfestival # wipe → new UUID minted on next launch
# launch, drive onboarding, observe traffic
Drive onboarding via ADB when there is no email verification (registration is a local form,
ProfileIdentityProvider.authenticate() returns “No authentication support”):
uiautomator dump /data/local/tmp/ui.xml # pull + parse text/resource-id/bounds for tap coords
input tap <x> <y>
input text "Geert"
- UI-driving gotcha: tapping between adjacent form fields is unreliable — focus did not always move, concatenating “Geert” + “de Graaf” into one field. Fix: tap field, verify focus via a fresh dump/screenshot before typing, and clear the field first if needed.
- What it proved: a brand-new random
app_user_ideach install (client-side generation); the id is transmitted nowhere pre-consent (only Firebase installations + Crashlytics settings, without the id → CC is genuinely consent-gated); and post-consent the id goes to Crowd Connected ANDapp.appmiral.com/.../users/<id>AND AWS SNS — not CC-only.
6.5 Reinstall + the Play Licensing wrapper
For the outer-zone run we did a full reinstall of the pulled splits. The app is wrapped in Google
Play Licensing (LVL) via com.pairip.licensecheck and kills itself in LicenseActivity unless it
was installed by Play. Restore the attribution:
pm install -i com.android.vending base.apk split_config.arm64_v8a.apk split_config.xxhdpi.apk
That this anti-tamper/licensing wrapper ships at all is itself a notable finding.
6.6 Analysing the decrypted flows
from mitmproxy.io import FlowReader
# copy flows_*.mitm to a snapshot first — mitmdump holds the live file open
for fl in FlowReader(open('snapshot.mitm','rb')).stream():
r = fl.request # r.pretty_host, r.pretty_url, r.headers, r.get_text()
Workflow: enumerate all hosts, then search every flow for the id needle (e.g. 95260f99) and the
CC deviceId across URL / headers / body to build the “which service received the id” table
(identity-correlation-proof.md §8.3). Firehose bodies: Records[].Data is base64 → the CSV
position record in §4.2.
7. The two end-to-end captures (what each run established)
| Run | GPS | Network state | Established |
|---|---|---|---|
Clean-install A/B (identity-correlation-proof.md §8) |
real (~1.2 km N of Assen, outer band) | full MITM, all hosts reachable | new client UUID per install; pre-consent silence; post-consent the id reaches CC + Appmiral backend + SNS; one real-GPS Firehose record decoded; full location→id→name/email chain |
One-hour Vries outer-zone (identity-correlation-proof.md §9) |
mocked to Vries 53.0775,6.5836 |
full MITM | server switch observed active:true; outer-zone cadence measured ≈ one position / 30 min (frequency_2=1800 s); mocked coordinate confirmed in every Firehose record; same id chain on a fresh install; US endpoints (firehose.us-east-1) confirmed live |
Earlier, buffer-only runs (no proxy, §4) characterised the inner-ring 30 s cadence by widening the local geofence so the researcher’s real coordinates qualified, then blocking Firehose so records buffered locally for decoding.
8. Teardown / restoring the device (run after every dynamic test)
Always return the phone to an authentic state. Order matters.
# 1. Interception off
Stop-Process -Id <mitmdump_pid> # host
adb shell su -c "pkill tcpdump"
adb reverse --remove-all
adb shell settings put global http_proxy :0
# 2. GPS mock off (see §5.2)
# 3. iptables rules removed (delete each one you added, §4.1)
# 4. CA / trust store — REBOOT is the clean way (see gotcha)
adb reboot
# 5. Delete scratch: /data/local/tmp/{cacerts,mitm.pem,*.sh,*.pcap}; session-folder scratch DBs
# 6. If a geofence/DB value was edited, restore it (radius1 back to 820, verify active flag)
- CRITICAL teardown gotcha: running
umount /apex/com.android.conscrypt/cacertsleft the APEX cacerts dir empty (link count 0), breaking the system-wide trust store (conscrypt on Android 14+ reads only from APEX). The reliable recovery is a reboot, which cleanly remounts APEX with its 145 certs and clears every injected bind-mount. Preferadb rebootover unmounting. - After teardown we verified: APEX trust store back to 145 certs, no proxy, no iptables rules, mock
removed,
GeoZone.radius1= authentic 820,activeflag as the server set it.
9. Reproduction checklist (condensed)
- Rooted Android 14+ device, ADB,
pip install mitmproxy, jadx + Java 17. - Static: pull
base.apk,jadx -d src base.apk, confirm the symbols in §3.2. - On-device state: read
shared_prefs+ the Room DB (§2) for the id↔identity join and the geo-zone/activeflag (binary-safe viaadb exec-out; merge the WAL). - Harness: inject the mitm CA into the APEX store (§6.2, init+zygote64 only — never all PIDs),
adb reverse+ device proxy, start mitmdump + tcpdump (§6.3). - Clean run:
pm clear, launch, drive onboarding, confirm a fresh client UUID and that it is silent pre-consent (§6.4). - Geo: mock GPS (§5) or widen the local geofence; for outer-zone, mock to Vries and run 1 h.
- Analyse: FlowReader over the flows; enumerate hosts; search for the id needle; decode Firehose CSV (§6.6 / §4.2). Cross-check against the raw pcap.
- Teardown: restore the device (§8) — reboot to fix the APEX store, remove mock/iptables/proxy.
Every command above was run by the device owner on their own device and account. The findings these techniques produced are written up, with the decoded evidence, in the linked docs at the top of this file.