Skip to the content.

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:



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)

1.4 General ADB / PowerShell gotchas (apply everywhere below)


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()))"

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.


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

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)

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

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.

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.

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"

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"

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)

9. Reproduction checklist (condensed)

  1. Rooted Android 14+ device, ADB, pip install mitmproxy, jadx + Java 17.
  2. Static: pull base.apk, jadx -d src base.apk, confirm the symbols in §3.2.
  3. On-device state: read shared_prefs + the Room DB (§2) for the id↔identity join and the geo-zone/active flag (binary-safe via adb exec-out; merge the WAL).
  4. 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).
  5. Clean run: pm clear, launch, drive onboarding, confirm a fresh client UUID and that it is silent pre-consent (§6.4).
  6. Geo: mock GPS (§5) or widen the local geofence; for outer-zone, mock to Vries and run 1 h.
  7. 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.
  8. 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.