Web inspection was always a thing on iOS (tvOS, macOS) for WKWebView (if available) and JSContext. It was available for developer-provisioned apps built directly from Xcode for local development.

However, released versions of apps had no way to inspect dynamic web content or scripts, leaving developers and users to have to resort to more complicated workflows to get information that would otherwise be made available by Web Inspector.

From iOS 16.4 (and MacOS 13.3) Apple introduced isInspectable flag on WKWebView and on JSContext

How to enable inspection?

According to webkit.org:

let webConfiguration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.isInspectable = true

Or:

let jsContext = JSContext()
jsContext?.isInspectable = true

And from a desktop Safari's Develop menu you can access now the inspection.

(For iOS and iPadOS, you must also have enabled Web Inspector in the Settings app under Safari > Advanced > Web Inspector. For simulators it is always enabled.)

Flutter logo

The caveat

If you start your iOS development journey after iOS 16.4 you will rely on the current state of Web inspection.

As webkit.org also mentions, the isInspectable flag

defaults to false, and you can set it to true to opt-in to content being inspectable. This decision is made for each individual WKWebView and JSContext to prevent unintentionally making it enabled for a view or context you don’t intend to be inspectable

This will make you think that disabling the isInspectable flag is enough to restrict the access to your Webview/JSContext for 3rd-parties.

This assumption can make you lazy and you forget to obfuscate your sensitive Javascript code.

This is specially important if you develop (and distribute) a framework that has its business logic in Javascript (and even contains some sensitive data)

Before iOS 16.4 (as mentioned earlier) Web inspection was by default enabled for development-signed builds, so you were alerted immediately once you opened Safari's Develop menu. You had to make precautions against disassembling your code.

This is not the case anymore and your false assumption of safety can cause some pain later.

What's going on?

In a previous article I already talked about hooking into (aka. swizzling) methods.

From the moment isInspectable is a property on a class, we can use the same technique to override the “hardcoded” value by the 3rd-party framework (or even by an app on a jailbroken device).

We need to find a hooking point (preferable as late as possible, so our new value can not be overridden by the framework/app anymore), e.g. when the Webview loads an HTML string. (But this can be anything. Setting a certain value, calling a constructor, etc…)

extension WKWebView {

    @objc dynamic func _swizzled_loadHTMLString(_ string: String, baseURL: URL?) -> WKNavigation? {
        //this will do the magic
        if #available(iOS 16.4, *) {
            print("[SWIZZLING] Overriding isInspectable from loadHTML")
            self.isInspectable = true
        } else {
            // No flag on earlier version
        }
        
        // call the original method. Looks like an infinite loop, but the implementations are exchanged!
        return _swizzled_loadHTMLString(string, baseURL: baseURL)
    }
}

After we found the right method, we can exchange the implementations.

extension WKWebView {
    static func applySwizzling() {
        print("[SWIZZLING] applySwizzling")
        
        //original method
        let selector1 = #selector(WKWebView.loadHTMLString(_:baseURL:))
        //new method
        let selector2 = #selector(WKWebView._swizzled_loadHTMLString(_:baseURL:))
        
        let originalMethod = class_getInstanceMethod(WKWebView.self, selector1)!
        let swizzleMethod = class_getInstanceMethod(WKWebView.self, selector2)!
        //exchange implementations
        method_exchangeImplementations(originalMethod, swizzleMethod)
        
    }
}

We can call WKWebView.applySwizzling() to enable hooking as early as possible in the application life-cycle (e.g. in AppDelegate)

After applying the hooking, we are able to see the Web inspection in Safari again for all the Webview within our app. We can see all of ours, but also all the 3rd-party ones which were not meant to be seen by us.

Why is this a problem?

  1. Hidden parts can be discovered of any 3rd-party SDK while using them.
  2. On a jailbroken device we can open up all the Webviews (in any app) by applying the snippet above via a Jailbreak tweak.
  3. isInspectable is a public API, so swizzling it is not against the Apple Store guidelines. (So if we find a valid use-case for it, we are free to do it in release apps)

Can we avoid our secrets being exposed?

There are few tricks we can try. I am going to touch up on few of these.

Always obfuscate the Javascript code

This is the first and most important defense line. If the business logic is obfuscated and hard to read we can already make the bad actor's life difficult.

Also, don't store any secret inside Javascript.

Swizzle the swizzling

Unfortunately, we can not be faster within a framework than someone who is using our library within an app and can put hooking around application life-cycle methods.

However, if we are lucky we can hook into the right method in the right time, so every call to that method will arrive to us and we can decide how to continue.

    @objc dynamic func _swizzleSetter(_ val: Bool) {
        print("[INTERNAL SWIZZLING] isInspectable setter ", val)
        _swizzleSetter(val)
    }
    
    static func applySwizzling() {
        print("[INTERNAL SWIZZLING] applySwizzling")

        if #available(iOS 16.4, *) {
            let selector11 = #selector(setter: WKWebView.isInspectable)
            let selector21 = #selector(WKWebView._swizzleSetter(_:))
            let originalMethod = class_getInstanceMethod(WKWebView.self, selector11)!
            let swizzleMethod = class_getInstanceMethod(WKWebView.self, selector21)!
            method_exchangeImplementations(originalMethod, swizzleMethod)
        } else {
            // Fallback on earlier versions
        }
    }

In the sample code above we hook into the setter of isInspectable, so whenever an app-level hooking would try to set isInspectable to TRUE we can just reject it.

So in the first hooking sample snippet, whenever _swizzled_loadHTMLString hook calls self.isInspectable = true, that call will be delivered to _swizzleSetter and there we can decide what to do.

Devirtualizing Objective-C calls

When calling a method on a class in Objective-C, under the hood it uses objc_msgSend which will call (dispatch a message) the selected function via a selector on a specific object (with specific parameters).

Because of this technique the Obj-C methods are called “virtual” and they are dynamically resolved during runtime.

And this way of communication makes method swizzling work.

With bitcode manipulation we can bypass objc_msgSend and call the required function directly. This is called devirtualization.

There is a brilliant article from GuardSquare on the topic that explains how it works.

Note: Don't try this at home πŸ™‚… Try to use tools that make your life easier.

Conclusion

Obfuscate and keep challenging the conventional wisdom!

Support

Did you enjoy my story? There is more in the pipeline… πŸ˜‰

Do you want to know more insights? Would you like to discuss the used techniques, or would you like to see some part of the code? Then consider becoming a monthly supporter on Patreon via my Tweaked.Tech initiative.

Become a Patron Become a Patron

Why?

  • The articles are published on Patreon first.
  • There is a follow-up article that shares insights, used techniques, architecture, infrastructure.
  • You can get early access to projects in the Beta phase.
  • We can discuss your ideas, your doubts, and the current issues you face in your project.
  • You keep me going, it is a big boost to my motivation!

I really appreciate a one time support too, you can do it via Buy Me A Coffee.

Buy me a coffee Buy me a coffee

Thanks for reading my story, I am grateful that you were here until the end.
Stay tuned for the next one!