Case File 02 · Reverse Engineering

Trying to Secure Your App Paywalls? Don’t Do What Flighty Did

A practical lesson on why “the response looks encrypted” almost never means the app is secure, and a step-by-step walkthrough of how I reconstructed Flighty’s protobuf schema from a stripped Mach-O binary and rewrote subscription state in transit.

Published May 7, 2026 Reverse Engineering iOS · Protobuf Public write-up

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

Key Insight

The paywall bug hunt usually fails at the same point: people stop at the encoding layer.

The correct mental model is:

  1. Transport format is not security.
  2. Readable UI state still comes from parsable server data.
  3. Authorization mistakes can survive any wire format.
Just because the response isn’t readable does not mean the app is secure.

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

Method · 6 Steps

Use this sequence when you hit an “encoded” paywall response.

Identify protocol hints

Look at headers, endpoint behavior, and payload characteristics. Check for binary responses, content-type hints, repeated field structures, and varint-like patterns.

Confirm it is serialization, not encryption

Attempt deterministic decode paths (protobuf, compression, framing). If parse attempts produce structured fields, you are in business logic territory again.

Build / borrow a parser

Start with raw field extraction (field number, wire type, value). Add semantic mappings iteratively (field_n -> meaning) as you observe traffic.

Map paywall-relevant fields

Track subscription status, product IDs, timestamps, and entitlement switches. Compare before/after account states (free, trial, paid, expired).

Test trust boundaries

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.

Read the binary for clues

Strings, offsets, file formats. Files with purchase/entitlement related data (Subscriptions.js, RestorePurchases.swift, etc.).

Common Anti-Patterns

Anti-Patterns · Avoid
  • “Binary equals encrypted, give up.”
  • “Client UI says premium, so backend must be secure.”
  • “We migrated to protobuf, therefore paywall is safer.”
  • “No .proto files 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 .app directory contained SwiftProtobuf_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

shellbash
strings Flighty | grep -i 'FlightyAPI' | head -40

This revealed every protobuf message class that SwiftProtobuf generated in the app:

output · message types
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:

shellbash
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.

shell · iterative discoverybash
# 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:

output · wire field names
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:

output · paywall-relevant names
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:

shellbash
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

shellbash
strings Flighty | grep 'com.flightyapp.flighty' | grep -i 'plan\|lifetime\|family\|pass\|flight\|holiday' | sort -u
output
family.monthlyPlan
family.upgrade.lifetime
family.yearlyPlan
holiday2020Monthly
lifetime
singleFlight
weekPass

Combined with standard naming conventions, the full product ID set:

product IDs
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).

shellbash
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:

enum · ProUserType
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
Wire Name ≠ Swift Case

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:

output · leaked source paths
/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:

  1. Start with each FlightyAPI* class name → create a message block.
  2. Use the wire field names and _NameMap data to assign field names.
  3. Determine field numbers from the declaration order in the binary’s reflection metadata.
  4. 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).
  5. Cross-reference with the _StorageClass fields to identify nested messages.

The reconstructed schema covered 24 entity types in SyncResponseData alone:

flighty_reconstructed.protoprotobuf
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.
shell · usagebash
# 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:

  1. RemoteSubscription (field 11):
    • product_idcom.flightyapp.flighty.lifetime
    • expiration_date → 50 years from now
    • status0 (ACTIVE)
    • autorenewingtrue
  2. 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:

  1. Parses the SyncResponseProto top-level fields.
  2. For each field 2 (SyncResponseData), it recurses in.
  3. Inside SyncResponseData, it finds field 11 (RemoteSubscription) and field 14 (RemoteUser).
  4. For each, it parses the sub-message fields, replaces the target fields with new encoded values, and preserves all other fields byte-for-byte.
  5. 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.

shell · mitmproxybash
# 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:

  1. 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.
  2. Live capture feed: When loaded as a mitmproxy addon, it auto-captures every protobuf request and response from api.flightyapp.com and displays them in a real-time scrolling feed with decoded JSON.

Architecture:

  • A ThreadedHTTPServer on port 5555 serves the UI and a REST API.
  • POST /api/decode accepts {data, format, type, output} and returns decoded JSON or YAML-like output.
  • GET /api/feed returns the last 50 captured protobuf messages.
  • POST /api/feed/clear clears the capture buffer.
  • The mitmproxy addon class (FlightyProtoCapture) hooks request() and response() flows, filters for api.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.
shell · run itbash
# 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.

wire format · confirmedprotobuf
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:

output · decoded subscriptionyaml
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

FilePurpose
flighty_reconstructed.protoFull reconstructed .proto schema (362 lines) — message definitions, enums, field numbers, all from binary analysis
flighty_proto_decoder.pyCLI raw protobuf decoder with Flighty field mappings (559 lines)
flighty_mitm_modifier.pymitmproxy addon that rewrites subscription status, user type, and feature flags in transit (557 lines)
flighty_decoder_web.pyWeb UI + mitmproxy addon for live capture and paste-to-decode (1265 lines)
retail-sync-response.jsonReference capture of a decoded sync response for comparison

Key Takeaways

  1. No .proto files required. We reconstructed the entire schema from the compiled binary alone, using string extraction, reflection metadata, and traffic verification.
  2. SwiftProtobuf leaves a rich metadata trail. The _NameMap structures, _ProtoNameProviding protocol conformances, _StorageClass patterns, and AllCases enum declarations all survive compilation and are recoverable with strings.
  3. Wire names ≠ Swift property names. The mapping "BASELINE" → ProUserType.lifetime is non-obvious and can only be found in the binary’s NameMap data. A real-world example of security-through-obscurity failing.
  4. Subscription state was entirely client-trusted. The sync response delivers RemoteSubscription.status and RemoteUser.user_type to the client, and the client renders the paywall based on these values. Modifying them in transit changes the app’s behavior immediately.
  5. 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

Public-Safe Version
  • 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.
Back to Blog