SkyCards, ground truth: reverse‑engineering a flight‑spotting game | JonLuca's Blog<br>Sep 12, 2025<br>SkyCards, ground truth: reverse‑engineering a flight‑spotting game
TL;DR - I reverse‑engineered SkyCards, a game that turns live flight spotting into a collectible. This post walks through intercepting traffic, reading a React Native + Hermes bundle, finding a native request‑signing bridge, and wiring up a small client that talks to FlightRadar24's protobuf/gRPC endpoints to automate captures.
SkyCards in a nutshell
SkyCards takes the plane‑spotter vibe and turns it into a collection loop. The app shows live flights near you with a rarity score. You pick one, jump into a mini‑game, and try to "photograph" the aircraft as it crosses the frame. If roughly ≥90% of the plane is inside the shot, you capture it. Captures store tail number, model (e.g., "Boeing 787"), route, and a timestamp. Rarer flights give more XP. You have a camera with a limited number of shots that refresh over time (with optional in‑app purchases for more).
SkyCards - map view
Once you tap a plane, you see its card and can decide whether to try a capture.
SkyCards - plane card
If you try to capture it, you play a mini‑game: the plane flies across the screen and you snap the photo when it's fully in frame.
SkyCards - capture mini‑game
On success, you get a confirmation screen and the flight is added to your collection.
SkyCards - capture success
Digging into the app
I enjoy aviation and reverse engineering, so this felt like a good crossover project. I started by booting up Proxyman to see if I could intercept the app's traffic.
Luckily the app didn't do certificate pinning or TLS fingerprinting, so I was off to the races.
The flights endpoint returned binary (protobuf) data that I didn't have definitions for yet, while the capture‑related endpoint spoke JSON.
When you attempt a capture, two client actions pop out immediately:
Spend camera shot - decrements your shot inventory.
Register capture - sends the result (selected flight, "% in frame," and some metadata).
The two endpoints called when you go through a mini game
Notably, the captures endpoint was separate from the consume (shot‑spend) endpoint and didn't validate against a prior consume call. Practically, you could sinkhole the consume endpoint and never burn shots.
The mini‑game itself runs client‑side; "how much of the plane is in frame" ultimately becomes a number in the request body, seen below. coverage represents the percent of the plane in the shot, and cloudiness captures how cloudy the frame is (which affects score/XP).
"sub": "your-user-id",<br>"iat": 1757403992,<br>"data": {<br>"alt": 3000,<br>"speed": 0,<br>"reg": "SKY-CARDS",<br>"callsign": "ONBOARDING",<br>"associatedAirportId": 3147,<br>"flightId": 1,<br>"track": 45,<br>"icon": 0,<br>"status": 0,<br>"timestamp": 1757403964331,<br>"onGround": false,<br>"source": 0,<br>"model": "B738",<br>"xp": 1380,<br>"xpUserBonus": 1700,<br>"coverage": 100,<br>"cloudiness": 85,<br>"glow": false,<br>"coins": 1,<br>"newAirport": false
Registering the capture (signing)
The register endpoint was the interesting one: a single POST with a signed JSON payload.
HTTP payload (redacted)
The server wouldn't accept unsigned register requests, so it was time to fire up the decompiler.
Android reverse engineering: APKLab + JADX
I booted a rooted Android emulator, installed the app from the Play Store, and extracted the APK. Then I opened it in APKLab and let it infer Java sources from the DEX/smali files.
It turned out the app was built with React Native, which meant the interesting bits were likely in the JS bundle.
Architecture tour: React Native + Hermes (and how I read it)
The app is React Native using Hermes. I jumped straight into the bundle-application and auth logic in RN apps is usually in JS. Native modules are typically small bridges to platform functionality (camera, crypto, storage) or third‑party deps, so I didn't bother there first.
Hermes emits compact bytecode. Gone are the days of trivially reversing RN bundles; Hermes reads more like a tiny VM than JavaScript. (Tools like hermes-dec help extract readable bytecode from the bundle.)
Fortunately, function names and strings weren't heavily obfuscated, which made tracing pleasant.
My workflow:
Grep the RN bundle for endpoint‑like strings.
Jump to the callers, then bubble up to action creators.
For bytecode that's tedious to read, paste chunks into GPT‑5 to get a JS sketch, then verify manually.
That takes you straight to the JS that assembles the capture payload. The actual signing happens elsewhere.
I was honestly surprised how well GPT‑5 read and interpreted the bytecode. It's not perfect, but it's a huge time saver for tedious chunks.
GPT‑5 returned a solid outline of what the original JavaScript looked like.
The full conversation and examples it decompiled are here - some pretty impressive stuff.
Where the heavy lifting happens: the native bridge for signing
React Native calls into a native "request signer."...