When a Paywall Bug Looks “Encrypted”
This is a practical lesson for security researchers and developers. If you are testing an app paywall and get stuck because the response body looks encoded, compressed, or unreadable, that does not automatically mean “secure crypto” or “dead end.” In many modern mobile stacks, you are often looking at serialized transport data (commonly Protocol Buffers), not business logic that is truly hidden.
The same pattern appears again and again:
- app uses protobuf instead of JSON for API payloads,
- client still receives entitlement/subscription state in that payload,
- a superficial inspection misses the vulnerable trust boundary,
- researchers assume the traffic is opaque and stop too early.
Core Lesson
The paywall bug hunt usually fails at the same point: people stop at the encoding layer.
The correct mental model is:
- Transport format is not security.
- Readable UI state still comes from parsable server data.
- Authorization mistakes can survive any wire format.
Why This Trips Up Researchers
Many offensive workflows are JSON-first by default. When a target switches to protobuf:
- proxies show binary blobs,
- fields are unnamed,
- nested messages are not obvious,
- entitlement flags are harder to spot.
This is essentially the squidward chair meme for cybersecurity researchers. I am sure every security researcher has approached an app that looks incredibly vibecoded and easy to hack, but upon looking at the traffic you realize that it is some weird proprietary format or protobuf and you give up. This is what this post is about.
Why This Also Matters for Developers
Switching from JSON to protobuf improves performance and payload size, and can also steer away some talented reverse engineers, but it does not fix:
- insecure client trust in purchase state,
- weak server-side authorization checks,
- business logic errors (or really any IDOR).
If sensitive decisions are made client-side or based on mutable state, the attack surface remains the same. It just appears more secure.
What Is Usually Happening Technically
In these cases, the app is often returning protobuf messages that include:
- user/account records,
- subscription / purchase objects,
- status enums (active, trial, expired, canceled),
- timestamp fields (purchase/expiry/renewal),
- feature flags or entitlement lists.
The data is there. It is just not rendered as plain JSON.
Methodology: When Responses Are Unreadable
Use this sequence when you hit an “encoded” paywall response.
Look at headers, endpoint behavior, and payload characteristics. Check for binary responses, content-type hints, repeated field structures, and varint-like patterns.
Attempt deterministic decode paths (protobuf, compression, framing). If parse attempts produce structured fields, you are in business logic territory again.
Start with raw field extraction (field number, wire type, value). Add semantic mappings iteratively (field_n -> meaning) as you observe traffic.
Track subscription status, product IDs, timestamps, and entitlement switches. Compare before/after account states (free, trial, paid, expired).
Verify which side (server vs client) enforces premium access. Confirm whether altered state can unlock paid behavior. Validate replay, stale state, and downgrade/upgrade edge cases.
Strings, offsets, file formats. Files with purchase/entitlement related data (Subscriptions.js, RestorePurchases.swift, etc.).
Common Anti-Patterns
- “Binary equals encrypted, give up.”
- “Client UI says premium, so backend must be secure.”
- “We migrated to protobuf, therefore paywall is safer.”
- “No
.protofiles means analysis is impossible.”
Decoding Flighty: Step-by-Step Walkthrough
Everything above is the general lesson. What follows is how we applied it to a real target — the Flighty iOS app — from initial binary analysis through to fully automated protobuf interception and modification.
Step 0 · What We Were Looking At
Flighty is a premium flight tracking iOS app. Traffic proxied through mitmproxy showed API calls to https://api.flightyapp.com with binary response bodies — no JSON anywhere. The main endpoint of interest was /v1/sync/, which returned what appeared to be a large binary blob. This is the endpoint called to sync your purchases and premium state (what happens when you click restore purchases). Content-type headers were minimal, and did not leave us with any revealing information on how to decode it. The immediate question: is this some weird proprietary binary format, encrypted, or just serialized?
Step 1 · Confirming the Wire Format Is Protobuf
Before touching the binary, we examined the raw bytes from intercepted responses. Key indicators that pointed to Protocol Buffers:
- Varint patterns: The leading bytes of fields followed varint encoding (high bit continuation, 7-bit payloads).
- Tag structure: Bytes decoded cleanly as
(field_number << 3) | wire_type, with wire types 0 (varint), 2 (length-delimited), and 5 (32-bit) appearing repeatedly. - No magic bytes or framing: Unlike gzip, MessagePack, or CBOR, there was no file header — just raw tag-length-value sequences. If you were to open any file like a ZIP file in a text editor, you will see headers that specify what kind of file it is. However if you are developing an app and want to implement every step necessary to prevent hackers from touching it, you would not want to include these headers.
- SwiftProtobuf dependency in the bundle: The
.appdirectory containedSwiftProtobuf_SwiftProtobuf.bundle, confirming the app links against Apple’s official SwiftProtobuf library.
At this point we knew: this is just standard protobuf, no encryption or compression wrapper. Just binary serialization without a .proto schema to reference.
Step 2 · Reading the Flighty Binary for Protobuf Metadata
The Flighty app ships as a Mach-O binary. I ran string extraction and targeted searches against it to pull out protobuf-related metadata. These need to be somewhere in the app so it can understand how to read the raw mess it’s getting from the server.
2a · Finding Proto Message Type Names
strings Flighty | grep -i 'FlightyAPI' | head -40
This revealed every protobuf message class that SwiftProtobuf generated in the app:
FlightyAPIRemoteSubscription
FlightyAPIRemoteUser
FlightyAPISyncResponseProto
FlightyAPISyncResponseData
FlightyAPISyncRequestProto
FlightyAPISyncChangeRequestProto
FlightyAPIReceiptRequestProto
FlightyAPISubscribeFlightResponseProto
FlightyAPISharingSubscribeFlightResponseProto
FlightyAPIRemoteUserFlight
FlightyAPIRemoteTicket
FlightyAPIRemoteFlight
FlightyAPIRemoteAirport
FlightyAPIRemoteAirline
FlightyAPIRemoteProfile
FlightyAPIRemoteAircraftType
FlightyAPIStartLiveActivityRequest
... (and many more)
At this point, we can basically start celebrating. The hard part is over. We know it’s protobuf, and we know the message types. The only remaining question is what they mean.
Each of these corresponds to a message definition in the original .proto file that Flighty’s developers wrote. SwiftProtobuf embeds the full class name as a string literal for reflection/debugging purposes.
2b · Extracting Proto Wire Field Names
Ok so this is the part that probably looks weird because the grep command I show has very specific search terms. Like, how did I already know to search for connecting_flight or payment_plan_cohort? I didn’t.
The first thing I did was just dump every string from the binary:
strings Flighty | less
This gives you tens of thousands of lines. Most of it is garbage — framework paths, ObjC selectors, random UI text. But if you scroll through it you start noticing a pattern: clusters of snake_case strings that look suspiciously like protobuf field names. user_id, flight_id, seat_number, departure_airport. Swift uses camelCase. These aren’t Swift property names. They’re proto wire names, just sitting there as plain strings.
Once I spotted the pattern, I started grepping for clusters of them. Find one like connecting_flight, notice it appeared near other flight-related field names, use those to pull out more. Iterative process: find a few, grep for related terms, find more, repeat.
# final search terms after the iterative discovery process
strings Flighty | grep -i 'connecting_flight\|flight_profiles\|latest_timestamp\|payment_plan_cohort\|download_url\|share_url\|h3_cell' | sort -u
Result — the actual proto wire names used on the wire:
connecting_flight
connecting_flight_profiles
connecting_flight_ticket
download_url
flight_profiles
h3_cell_level5
h3_cell_level8
latest_timestamp
payment_plan_cohort
share_url
And the ones I cared about most — the subscription and user-related fields:
user_flight, user_id, flight_id, push_token, live_activity_id,
bundle_id, is_sandbox, replaces_live_activity_id, is_friends_flight,
device_id, device_model, device_manufacturer, product_id, start_date,
end_date, airport_id, start_time, end_time, flight_number,
departure_airport, arrival_airport, departure_time, arrival_time,
cloud_user_id, owner_user_id, event_id, seat_number, seat_position,
cabin_class, flight_id_a, flight_id_b, next_refresh, all_flights
These are the names that SwiftProtobuf uses in its _protobuf_nameMap — the mapping that converts between Swift property names and proto wire field names. Once you have these, you have the vocabulary for every field in the schema. You don’t know the field numbers yet (that comes later from traffic), but you know what each field is called and roughly what it does.
2c · Finding _StorageClass Patterns
SwiftProtobuf uses _StorageClass for messages with many fields (an optimization to avoid stack-allocating large structs). I searched for it:
strings Flighty | grep '_StorageClass'
This told us which messages had complex internal storage, i.e., which ones had many fields and nested sub-messages: SyncChangeRequestProto, RemoteFlight, RemoteAirport, RemoteAirline, AirportWeatherProto, etc.
2d · Extracting In-App Purchase Product IDs
strings Flighty | grep 'com.flightyapp.flighty' | grep -i 'plan\|lifetime\|family\|pass\|flight\|holiday' | sort -u
family.monthlyPlan
family.upgrade.lifetime
family.yearlyPlan
holiday2020Monthly
lifetime
singleFlight
weekPass
Combined with standard naming conventions, the full product ID set:
com.flightyapp.flighty.weeklyPlan
com.flightyapp.flighty.monthlyPlan
com.flightyapp.flighty.yearlyPlan
com.flightyapp.flighty.lifetime
com.flightyapp.flighty.family.monthlyPlan
com.flightyapp.flighty.family.yearlyPlan
com.flightyapp.flighty.family.upgrade.lifetime
com.flightyapp.flighty.holiday2020Monthly
2e · Extracting Enum Values and User Types
Same story as 2b — the final grep command looks like I already knew the answer. I didn’t.
When you’re scrolling through the strings output you notice another pattern alongside the snake_case field names: clusters of ALL_CAPS strings that look like enum values. ACTIVE, EXPIRED, LAPSED, ANONYMOUS, PRO, FREE. These stand out because normal Swift code doesn’t produce many ALL_CAPS strings — but protobuf enum values do, because SwiftProtobuf embeds the wire-format enum names for serialization.
I also already knew from 2a that there’s a class called FlightyAPIRemoteSubscription and FlightyAPIRemoteUser. So I started grepping for status words, user type words, plan type words. Once I found a few, I cross-referenced by looking at what strings appeared near them in the binary (strings part of the same NameMap tend to be stored close together).
strings Flighty | grep -iE 'ANONYMOUS|BASELINE|NOT_DETERMINED|CONTROL|ProUserType'
From this, plus SwiftProtobuf’s AllCases reflection metadata (which stores enum cases in declaration order), I recovered the full ProUserType enum:
MONTHLY = 0, YEARLY = 1, COMP = 2, LIFETIME = 3, WEEKLY = 4,
WEEK_PASS = 5, SINGLE_FLIGHT = 6, MONTHLY_FAMILY = 7,
YEARLY_FAMILY = 8, LIFETIME_FAMILY = 9, LIFETIME_FAMILY_UPGRADE = 10,
HOLIDAY_2020_MONTHLY = 11
On the wire, the string "BASELINE" maps to ProUserType.lifetime. Not "LIFETIME" — "BASELINE". The SwiftProtobuf NameMap maps proto wire names to Swift enum cases, and Flighty’s developers chose “BASELINE” as the wire name for the lifetime plan. You would never guess that from looking at traffic alone.
Similarly, the subscription status enum: ACTIVE = 0, LAPSED = 1, EXPIRED = 2, and the user type field uses plain strings on the wire: "PRO", "FREE", "ANONYMOUS". Those ones are at least self-explanatory.
2f · Source File Paths Leaked from Debug Info
The binary contained embedded Swift source file paths from the build machine:
/Volumes/workspace/repository/Modules/Sources/Paywall/Paywall V3/PaywallV3Coordinator.swift
/Volumes/workspace/repository/Modules/Sources/Paywall/Paywall V3/PaywallV3ViewModel.swift
/Volumes/workspace/repository/SharedModules/Sources/FlightyCore/Subscription/SubscriptionService.swift
/Volumes/workspace/repository/SharedModules/Sources/FlightyCore/Subscription/PurchaseFlow.swift
/Volumes/workspace/repository/SharedModules/Sources/FlightyCore/Subscription/PaymentQueueObserver.swift
/Volumes/workspace/repository/Modules/Sources/PromoCodes/LifetimePromoCodeManager.swift
/Volumes/workspace/repository/App Features/Search/Sources/SearchV2Feature/SearchPaywallDecider.swift
/Volumes/workspace/repository/App Features/OnLaunchPopups/Sources/OnLaunchPopups/Paywall On Launch/PaywallOnLaunchDecisionMaker.swift
These told us exactly how the codebase was organized: the Paywall module, SubscriptionService, PurchaseFlow, and PaywallDecider classes. This informed which fields in the protobuf schema were paywall-relevant.
Step 3 · Reconstructing the .proto Schema
All metadata and binary mappings are found. Now all that’s left is to rewrite it with our own mappings so we can easily modify it. We create a .proto file with the mappings, called flighty_reconstructed.proto.
The process:
- Start with each
FlightyAPI*class name → create amessageblock. - Use the wire field names and
_NameMapdata to assign field names. - Determine field numbers from the declaration order in the binary’s reflection metadata.
- Infer field types from wire type analysis of captured traffic (varint → int/bool/enum, length-delimited → string/bytes/sub-message, 32-bit → float, 64-bit → double).
- Cross-reference with the
_StorageClassfields to identify nested messages.
The reconstructed schema covered 24 entity types in SyncResponseData alone:
message SyncResponseData {
repeated RemoteAirport remote_airport = 1;
repeated RemoteAirline remote_airline = 2;
repeated RemoteConnectedFriendPushSetting remote_connected_friend_push_setting = 3;
repeated RemoteConnectedFriendRelationship remote_connected_friend_relationship = 4;
repeated RemoteConnection remote_connection = 5;
repeated RemoteHowDidYouHearAboutUsAnswer remote_how_did_you_hear_about_us_answer = 6;
repeated RemoteEmailForwardingAddress remote_email_forwarding_address = 7;
repeated RemoteProfile remote_profile = 8;
repeated RemotePushSetting remote_push_setting = 9;
repeated RemoteSingleFriendFlightPushSetting remote_single_friend_flight_push_setting = 10;
repeated RemoteSubscription remote_subscription = 11; // *** SUBSCRIPTION HERE ***
repeated RemoteTicket remote_ticket = 12;
repeated RemoteTripitAccount remote_tripit_account = 13;
repeated RemoteUser remote_user = 14; // *** USER TYPE HERE ***
repeated RemoteUserFlight remote_user_flight = 15;
// ... fields 16-24 for contacts, live activity, metro areas, etc.
}
Two paywall-critical fields: field 11 (RemoteSubscription) carries subscription status/expiration/product, and field 14 (RemoteUser) carries the user_type (PRO/FREE) and pro_user_type (BASELINE/WEEKLY/etc).
Step 4 · CLI Protobuf Decoder (flighty_proto_decoder.py)
With the schema reconstructed, I wrote a standalone Python decoder that could parse raw protobuf binary into human-readable output. This was not generated from a .proto file with protoc — it was hand-built as a raw wire-format parser with our reconstructed field mappings layered on top. (I did input all of my mappings into Claude and told it to make the files for me. I am quite lazy.)
Capabilities:
- Raw wire format parsing: varint decoding, length-delimited field extraction, 32-bit and 64-bit fixed fields, tag parsing (
field_number << 3 | wire_type). - Recursive sub-message resolution: When a length-delimited field is known to contain a sub-message (via our
FIELD_TYPE_MAP), the decoder recurses into it and applies the correct field name mapping. - Timestamp detection: Automatically detects Google Protobuf Timestamp sub-messages (field 1: seconds, field 2: nanos) and renders them as ISO 8601 dates.
- Enum resolution: Maps varint values to human-readable enum names (
0 → ACTIVE,1 → LAPSED,2 → EXPIRED;0 → PRO,1 → FREE). - Multiple input formats: binary files, hex strings, or base64 from stdin.
# Decode a captured sync response
python3 flighty_proto_decoder.py response.bin --type SyncResponseProto
# Decode base64 from clipboard
pbpaste | python3 flighty_proto_decoder.py --stdin --type RemoteSubscription
# Decode hex from mitmproxy
python3 flighty_proto_decoder.py --hex '0a0474657374' --type RemoteSubscription
# List all known message types and their fields
python3 flighty_proto_decoder.py --list-types
Step 5 · MITM Subscription Modifier (the fun part)
Once we could read the protobuf, the next step was to modify it in transit. Mitmproxy is a Python library that lets you make a man-in-the-middle proxy and dynamically read/write request data. It lets you use Python scripts to map out what to read/write, so I wrote a Flighty mitm script.
What it modifies:
-
RemoteSubscription(field 11):product_id→com.flightyapp.flighty.lifetimeexpiration_date→ 50 years from nowstatus→0(ACTIVE)autorenewing→true
-
RemoteUser(field 14):user_type(string field 2) →"PRO"pro_user_type(string field 7) →"BASELINE"(the wire name for lifetime)- Feature flags (field 8, repeated sub-messages) → replace any
"UPGRADE"values with"NONE"to suppress upgrade prompts in the toolbar
The modifier operates at the raw protobuf wire level. It does not use compiled protobuf libraries. Instead, it:
- Parses the
SyncResponsePrototop-level fields. - For each
field 2(SyncResponseData), it recurses in. - Inside SyncResponseData, it finds
field 11(RemoteSubscription) andfield 14(RemoteUser). - For each, it parses the sub-message fields, replaces the target fields with new encoded values, and preserves all other fields byte-for-byte.
- It re-encodes each modified sub-message with proper length-delimited framing and reassembles the complete response.
The encoding functions (encode_varint, encode_tag, encode_length_delimited, encode_string_field, encode_timestamp) are the inverse of the decoder and produce valid protobuf wire format.
# Auto-intercept and modify all sync responses
mitmproxy -s flighty_mitm_modifier.py
# Or use mitmdump for headless operation
mitmdump -s flighty_mitm_modifier.py
# Standalone: modify a captured file
python3 flighty_mitm_modifier.py --input response.bin --output modified.bin
The modifier also copies modified response bytes to the macOS clipboard (pbcopy) for quick inspection. Added in real-time because I got confused on something while debugging. Not actually a necessary feature.
Step 6 · Web-Based Decoder UI
To streamline the decode-inspect-modify cycle, I vibecoded a full web interface — a Python HTTP server with an embedded HTML/CSS/JS frontend that doubles as a mitmproxy addon. To be 100% honest, I didn’t write or design any of this part. I just asked Claude Code to do the entire part for me.
Two modes of operation:
- Paste-to-decode: Open
http://localhost:5555, paste hex or base64 protobuf data, select the message type, and get an instant decoded view with syntax highlighting. - Live capture feed: When loaded as a mitmproxy addon, it auto-captures every protobuf request and response from
api.flightyapp.comand displays them in a real-time scrolling feed with decoded JSON.
Architecture:
- A
ThreadedHTTPServeron port 5555 serves the UI and a REST API. POST /api/decodeaccepts{data, format, type, output}and returns decoded JSON or YAML-like output.GET /api/feedreturns the last 50 captured protobuf messages.POST /api/feed/clearclears the capture buffer.- The mitmproxy addon class (
FlightyProtoCapture) hooksrequest()andresponse()flows, filters forapi.flightyapp.com, and feeds raw bytes into the capture store. - The frontend uses JetBrains Mono / Inter, a dark theme with HSL-tuned colors, and custom syntax highlighting for keys, strings, numbers, enums, timestamps, and binary data.
# Full stack: decode + modify + web UI
mitmweb -s flighty_decoder_web.py -s flighty_mitm_modifier.py
# Standalone decoder (no proxy)
python3 flighty_decoder_web.py
# Then open http://localhost:5555
Step 7 · Verifying with Real Traffic
With all tools operational, I confirmed the following from actual intercepted Flighty sync responses.
SyncResponseProto {
field 1: SyncPagination { field 1: next_url (string) }
field 2: SyncResponseData { (repeated)
field 11: RemoteSubscription {
field 1: id (string)
field 2: product_id (string)
field 3: purchase_date (Timestamp { field 1: seconds })
field 4: expiration_date (Timestamp { field 1: seconds })
field 5: autorenewing (bool)
field 7: status (enum: 0=ACTIVE, 1=LAPSED, 2=EXPIRED)
field 9: created (Timestamp)
field 10: updated (Timestamp)
}
field 14: RemoteUser {
field 1: user_id (string)
field 2: user_type (string: "ANONYMOUS" | "PRO" | "FREE")
field 3: auth_token (string: JWT)
field 4: created (Timestamp)
field 5: updated (Timestamp)
field 7: pro_user_type (string: "NOT_DETERMINED" | "BASELINE" | "WEEKLY" | ...)
field 8: feature_flags (repeated sub-messages)
}
}
}
Note: actual field numbers for RemoteSubscription differed from what we initially estimated from binary ordering. purchase_date ended up on field 3 (not 5), expiration_date on field 4 (not 6), status on field 7 (not 4). This is exactly why traffic verification matters — binary analysis gives you the schema vocabulary, traffic confirms the exact field numbering.
Decoded subscription data:
remote_subscription:
id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
product_id: "com.flightyapp.flighty.weeklyPlan"
purchase_date:
_timestamp: "2026-04-30 12:34:56 UTC"
seconds: 1777700096
expiration_date:
_timestamp: "2026-05-07 12:34:56 UTC"
seconds: 1778304896
autorenewing: true
status: EXPIRED # <- the field we cared about
Artifacts Produced
| File | Purpose |
|---|---|
flighty_reconstructed.proto | Full reconstructed .proto schema (362 lines) — message definitions, enums, field numbers, all from binary analysis |
flighty_proto_decoder.py | CLI raw protobuf decoder with Flighty field mappings (559 lines) |
flighty_mitm_modifier.py | mitmproxy addon that rewrites subscription status, user type, and feature flags in transit (557 lines) |
flighty_decoder_web.py | Web UI + mitmproxy addon for live capture and paste-to-decode (1265 lines) |
retail-sync-response.json | Reference capture of a decoded sync response for comparison |
Key Takeaways
- No
.protofiles required. We reconstructed the entire schema from the compiled binary alone, using string extraction, reflection metadata, and traffic verification. - SwiftProtobuf leaves a rich metadata trail. The
_NameMapstructures,_ProtoNameProvidingprotocol conformances,_StorageClasspatterns, andAllCasesenum declarations all survive compilation and are recoverable withstrings. - Wire names ≠ Swift property names. The mapping
"BASELINE" → ProUserType.lifetimeis non-obvious and can only be found in the binary’s NameMap data. A real-world example of security-through-obscurity failing. - Subscription state was entirely client-trusted. The sync response delivers
RemoteSubscription.statusandRemoteUser.user_typeto the client, and the client renders the paywall based on these values. Modifying them in transit changes the app’s behavior immediately. - Effort from “binary blob” to “working exploit” was iterative but mechanical. Confirm format → extract metadata → build decoder → build modifier → verify. Tedious? Yes. But don’t stop researching at “the response is in binary”.
What I Redacted
- Real account identifiers, auth tokens, JWT contents, and any user-identifying fields from captured traffic.
- Specific bypass payloads or exact replay sequences that would turn this post into a copy-paste exploit kit.
- Any details that would help unpaid users abuse the live service today.