Encrypted on the App Store, readable in a terminal
App Store encryption protects the Mach-O executable, not the React Native bundle beside it. Hermes bytecode isn't encryption either, and every string the app was compiled with is still in there.
One line in that scan report looked like a dead end:
INFO Binary is Encrypted (FairPlay DRM)
The app binary is encrypted with FairPlay DRM.
This may limit dynamic analysis capabilities.
Every word of it is true. It's also the kind of line most people read as "this one was hard, move on". That same app gave us 84 endpoints and 116 findings, and the most sensitive thing in the report came out of a file no DRM had touched.
What FairPlay actually protects
App Store DRM encrypts the Mach-O executable, which is the compiled machine code at the core of the app. That's the job. It stops somebody lifting your binary, re-signing it and shipping it as their own, and it makes disassembling your own code harder.
It doesn't encrypt anything else. Images, plists, config files and, for a growing share of apps, a JavaScript bundle all ship as ordinary files. They have to. The OS reads them at runtime, and nobody is going to pay the cost of decrypting every asset on every launch.
So "the binary is encrypted" and "the app's contents are readable" are both true. They're just about two different files.
Bytecode is not encryption
This was a React Native app, so the logic ships as Payload/<App>.app/main.jsbundle, sitting inside the IPA with nothing protecting it.
A release build of that file isn't JavaScript source. React Native compiles it to Hermes bytecode, and you can't read bytecode line by line. That's a genuine limit. It just isn't the limit the scanner's line was about, because compiling something isn't the same as encrypting it. Hermes keeps every string the app was compiled with in a plain string table next to the code. Hostnames, paths, API keys, feature flags, error messages. All of it sitting there in the clear.
Part of this app's own API surface came out of that table: its Maps Places calls and its geocoding provider. Run strings over the bundle and you'll get them, glued to whatever string sat next to them in the table, because Hermes stores its strings without separators. That's a parsing problem rather than a concealment one, and sorting the app's strings from the libraries' strings is what the reasoning stage is for.
The two files together are the interesting part. React Native, and anything else that ships a scripted bundle, moves the application's logic out of the compiled binary and into a resource the OS reads at runtime. App Store encryption is unchanged and still does its job: it protects the executable. It's just that the executable stopped being where the good stuff is.
What the reasoning layer had to do
A list of 84 URLs isn't a report. Three things had to happen to it, and they apply to any finding that comes out of a bundle.
Sort the app's surface from everyone else's. Most of those 84 were never the app's. Font foundry licences. Adobe XMP namespaces. Apple's OCSP responders. A placeholder URL reading example.invalid. Documentation links for bundled libraries. Put that into a report next to real endpoints and the reader stops reading. Filtering it is the difference between 84 rows and a handful worth looking at.
Rank endpoints by what they can do. A licence URL and a storage bucket are both "discovered endpoints". They aren't the same kind of thing, and leaving that judgement to whoever reads the list is how a report gets skimmed. The classification stage exists to make the call explicit.
Say what you can't establish. Static analysis has a boundary. You can extract a URL with total certainty. You cannot see, from the client, whether the server behind it checks anything. A report that blurs those two claims isn't more useful for the blurring.
What to do about it
If your app ships a JS bundle, treat its contents as public, whether the release ships readable JavaScript or Hermes bytecode.
That has consequences past the obvious one about secrets:
- Don't put an authorisation decision in the client. Gating a feature in the bundle isn't access control, it's a suggestion. The bundle tells anyone who looks exactly which calls the gated path would make.
- Assume your internal hostnames are known. Staging endpoints, admin paths, internal service names. A bundle containing those is an inventory of your infrastructure, handed over.
- Minify, and be honest about what it buys. Minification raises the effort of reading the bundle. It doesn't remove a string. Every endpoint and every key comes through intact.
- Test what you shipped, not what you wrote. Your repository isn't the artefact. Unzip your own IPA and run
stringsovermain.jsbundle. It takes a minute, and it's the only way to know.
Encryption at the executable level is no defence for an app whose interesting parts live somewhere else. The scan line was right: dynamic analysis really is harder against an encrypted binary. It just wasn't the limitation that mattered.
