Skip to content

MASTG-DEMO-0099: Runtime Monitoring of WebView File Access with Frida

Download MASTG-DEMO-0099 IPA Open MASTG-DEMO-0099 Folder Build MASTG-DEMO-0099 IPA

Sample

This demo uses the same sample as References to File Access in WebViews with radare2.

../MASTG-DEMO-0098/MastgTest.swift
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
import UIKit
import WebKit

struct MastgTest {

    private static let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
    private static let demoRoot = cacheDir.appendingPathComponent("demoRoot", isDirectory: true)
    private static let appDir = demoRoot.appendingPathComponent("app", isDirectory: true)
    private static let otherDir = demoRoot.appendingPathComponent("other", isDirectory: true)

    private static let indexURL = appDir.appendingPathComponent("index.html")
    private static let apiKeyURL = appDir.appendingPathComponent("api-key.txt")
    private static let otherURL = otherDir.appendingPathComponent("other.html")

    public static func mastgTest(completion: @escaping (String) -> Void) {
        createDirectories()
        createSecretFile()
        createOtherFile()
        createHtmlFile()

        DispatchQueue.main.async {
            showWebView(completion: completion)
        }
    }

    private static func showWebView(completion: @escaping (String) -> Void) {
        let configuration = WKWebViewConfiguration()
        let preferences = WKWebpagePreferences()

        // CONDITION 1: JavaScript allowed, default: true
        preferences.allowsContentJavaScript = true
        configuration.defaultWebpagePreferences = preferences

        // CONDITION 2: file:// URLs must have access to files.
        // This demo sets it to true so the fetch succeeds; change to false to block the fetch.
        configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
        configuration.setValue(false, forKey: "allowUniversalAccessFromFileURLs")

        let webView = WKWebView(frame: .zero, configuration: configuration)

        let vc = UIViewController()
        vc.view = webView

        guard let presenter = topViewController() else {
            completion("Failed to present, no view controller.")
            return
        }

        presenter.present(vc, animated: true) {
            //completion("Opening WebView...")
            completion(fileTreeString(at: demoRoot))

            // CONDITION 3: Overly broad file read access to demoRoot
            // JavaScript will be able to access any file within demoRoot
            // the content in the iframe will show
            webView.loadFileURL(indexURL, allowingReadAccessTo: demoRoot)

            // FIX: restrict allowingReadAccessTo to indexURL or appDir:
            // JavaScript will lose access to the files
            // the content in the iframe will be blocked
            // webView.loadFileURL(indexURL, allowingReadAccessTo: indexURL)

        }
    }

    private static func createDirectories() {
        try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true)
        try? FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true)
    }

    private static func fileTreeString(at url: URL, indent: String = "") -> String {
        var isDirectory: ObjCBool = false
        let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)

        guard exists else {
            return "\(indent)\(url.lastPathComponent) [missing]"
        }

        if !isDirectory.boolValue {
            return "\(indent)\(url.lastPathComponent)"
        }

        let children = (try? FileManager.default.contentsOfDirectory(
            at: url,
            includingPropertiesForKeys: [.isDirectoryKey],
            options: [.skipsHiddenFiles]
        ))?
        .sorted { $0.lastPathComponent < $1.lastPathComponent } ?? []

        var lines = ["\(indent)\(url.lastPathComponent)/"]

        for child in children {
            lines.append(fileTreeString(at: child, indent: indent + "    "))
        }

        return lines.joined(separator: "\n")
    }

    private static func createHtmlFile() {
        let htmlContent = """
        <!DOCTYPE html>
        <html>

        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Local File Access Demo</title>
        </head>

        <body>
            <h1>Local File Access Demo</h1>
            <p>This page attempts to read another local file using JavaScript.</p>

            <h3>JavaScript File Access via <code>fetch</code>:</h3>
            <p>Attempting to read: <code>./api-key.txt</code></p>
            <pre id="result">Loading...</pre>

            <script>
                async function readLocalSecret() {
                    const result = document.getElementById("result");
                    try {
                        const response = await fetch("./api-key.txt");
                        const text = await response.text();
                        result.textContent = text;
                        result.style.color = "green";
                    } catch (error) {
                        result.textContent = "BLOCKED: Failed to read local file: " + error;
                        result.style.color = "red";

                    }
                }

                readLocalSecret();
            </script>

            <h3>JavaScript File Access via <code>XMLHttpRequest</code>:</h3>
            <p>Attempting to read: <code>../other/other.html</code></p>
            <div id="result2" style="border: 1px solid #ccc; min-height: 220px;"></div>

            <script>
                function readLocalPage() {
                    const display = document.getElementById("result2");
                    const xhr = new XMLHttpRequest();

                    xhr.open("GET", "../other/other.html", true);

                    xhr.onload = function () {
                        if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText.length > 0)) {
                            const frame = document.createElement("iframe");
                            frame.width = "100%";
                            frame.height = "200";
                            frame.style.border = "0";
                            frame.srcdoc = xhr.responseText;

                            display.innerHTML = "";
                            display.appendChild(frame);
                        } else {
                            display.textContent = "BLOCKED: Failed with status " + xhr.status;
                            display.style.color = "red";
                        }
                    };

                    xhr.onerror = function () {
                        display.textContent = "BLOCKED: Failed to read local file.";
                        display.style.color = "red";
                    };

                    xhr.send();
                }

                setTimeout(readLocalPage, 500);
            </script>

            <h3>Direct Embedding into <code>&lt;iframe src=</code>:</h3>
            <p>Attempting to read: <code>../other/other.html</code></p>
            <iframe src="../other/other.html" width="100%" height="200"></iframe>

        </body>

        </html>
        """
        try? htmlContent.write(to: indexURL, atomically: true, encoding: .utf8)
    }

    private static func createOtherFile() {
        let otherHtmlContent = """
        <!DOCTYPE html>
        <html>
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Other File</title>
        </head>
        <body style="background-color: green;">
            <h4>Loaded from sibling folder</h4>
            <p>If you see this green iframe, the iframe loaded.</p>
        </body>
        </html>
        """
        try? otherHtmlContent.write(to: otherURL, atomically: true, encoding: .utf8)
    }

    private static func createSecretFile() {
        try? "MASTG_API_KEY=072037ab-1b7b-4b3b-8b7b-1b7b4b3b8b7b".write(to: apiKeyURL, atomically: true, encoding: .utf8)
    }

    private static func topViewController(base: UIViewController? = nil) -> UIViewController? {
        let root = base ?? UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .first { $0.isKeyWindow }?.rootViewController

        if let nav = root as? UINavigationController {
            return topViewController(base: nav.visibleViewController)
        }
        if let tab = root as? UITabBarController {
            return topViewController(base: tab.selectedViewController)
        }
        if let presented = root?.presentedViewController {
            return topViewController(base: presented)
        }
        return root
    }
}

Steps

  1. Install the app on a device ( Installing Apps).
  2. Make sure you have Frida (iOS) installed on your machine and the frida-server running on the device.
  3. Launch the MASTestApp on the device.
  4. Run run.sh to attach Frida to the running app.
  5. Click the Start button in the app to trigger the WebView configuration.
  6. Stop the script by pressing Ctrl+C.
1
2
#!/bin/bash
frida -n MASTestApp -l script.js -o output.txt
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
if (ObjC.available) {
    // 1. Hooking Simple Setters (Booleans)
    // We can use simpler swizzling here for stability
    const WKPreferences = ObjC.classes.WKPreferences;
    const WKWebViewConfiguration = ObjC.classes.WKWebViewConfiguration;
    const WKWebView = ObjC.classes.WKWebView;

    // Helper to hook boolean setters
    function hookBoolMethod(klass, selector, label) {
        if (!klass) {
            console.log(`[!] Skipping ${label}, class is unavailable.`);
            return;
        }

        const method = klass[selector];
        if (!method || !method.implementation) {
            console.log(`[!] Skipping ${label}, selector ${selector} is unavailable.`);
            return;
        }

        const oldImpl = method.implementation;

        method.implementation = ObjC.implement(method, function (self, sel, value) {
            console.log(`[${label}] = ${value}`);
            oldImpl(self, sel, value);
        });
    }

    hookBoolMethod(WKPreferences, "- _setAllowFileAccessFromFileURLs:", "WKPreferences: allowFileAccess");
    hookBoolMethod(WKWebViewConfiguration, "- _setAllowUniversalAccessFromFileURLs:", "WKWebViewConfig: universalAccess");
    hookBoolMethod(WKPreferences, "- setJavaScriptEnabled:", "WKPreferences: jsEnabled");

    // 2. Hooking loadFileURL (The tricky one)
    if (!WKWebView) {
        console.log("[!] Skipping WKWebView loadFileURL hook, class is unavailable.");
    } else {
        const loadMethod = WKWebView["- loadFileURL:allowingReadAccessToURL:"];
        if (!loadMethod || !loadMethod.implementation) {
            console.log("[!] Skipping WKWebView loadFileURL hook, selector is unavailable.");
        } else {
            const oldLoadImpl = loadMethod.implementation;

            loadMethod.implementation = ObjC.implement(loadMethod, function (self, sel, fileURLPtr, readAccessPtr) {
                try {
                    // In ObjC.implement, Frida automatically wraps pointers as ObjC.Object if possible,
                    // but we use ObjC.Object() here to ensure we can call methods on them.
                    const fileURL = new ObjC.Object(fileURLPtr);
                    const readAccess = new ObjC.Object(readAccessPtr);

                    console.log("[WKWebView] loadFileURL called");
                    // Use absoluteString safely
                    console.log("  fileURL: " + fileURL.absoluteString().toString());
                    console.log("  allowingReadAccessTo: " + readAccess.absoluteString().toString());
                } catch (e) {
                    console.log("[!] Error reading URLs: " + e);
                }

                // Always call the original implementation and return its value
                return oldLoadImpl(self, sel, fileURLPtr, readAccessPtr);
            });
        }
    }

    console.log("[+] Hooks deployed successfully.");

} else {
    console.log("Objective-C runtime is not available.");
}

The Frida script hooks several WebKit APIs at runtime and logs their arguments when they are invoked:

  • WKPreferences _setAllowFileAccessFromFileURLs: to detect when the app enables access from file:// pages to other local files.
  • WKWebViewConfiguration _setAllowUniversalAccessFromFileURLs: to detect when the app allows file:// pages to access any origin.
  • WKPreferences setJavaScriptEnabled: to determine whether JavaScript execution is enabled in the WebView.
  • WKWebView loadFileURL:allowingReadAccessToURL: to identify when the app loads local files into a WebView and to capture both the loaded file and the granted read access scope.

Observation

The output shows that the application enables file access from file:// URLs in the WebView and loads a local HTML file from a demo directory under the app's caches directory.

output.txt
1
2
3
4
5
6
[+] Hooks deployed successfully.
[WKPreferences: allowFileAccess] = 1
[WKWebViewConfig: universalAccess] = 0
[WKWebView] loadFileURL called
  fileURL: file:///Users/user/Library/Developer/CoreSimulator/Devices/196D4426-FBB3-4AD8-B1FA-B09E13C6B6E0/data/Containers/Data/Application/028A3B5E-BFE9-4C33-B07F-AA84E9570298/Library/Caches/demoRoot/app/index.html
  allowingReadAccessTo: file:///Users/user/Library/Developer/CoreSimulator/Devices/196D4426-FBB3-4AD8-B1FA-B09E13C6B6E0/data/Containers/Data/Application/028A3B5E-BFE9-4C33-B07F-AA84E9570298/Library/Caches/demoRoot/

The logs indicate that:

  • allowFileAccessFromFileURLs is set to 1.
  • allowUniversalAccessFromFileURLs remains 0 (disabled).
  • The WebView loads a local file such as .../Library/Caches/demoRoot/index.html.
  • allowingReadAccessTo is set to the demoRoot directory under the app's caches directory .../Library/Caches/demoRoot/.

Evaluation

The test fails because the application enables file access from file:// URLs for a WKWebView that loads local file:// content from the app's caches directory.

Specifically:

  • allowFileAccessFromFileURLs is set to true, allowing JavaScript running in a file:// page to access other local files within the granted read scope.
  • The WebView loads a local HTML file and grants read access to the demoRoot directory under the application's caches directory.

These settings weaken the isolation normally applied to local content and increase the impact of WebView vulnerabilities. If attacker-controlled JavaScript executes in the local page context, it may access files within the granted read scope and potentially exfiltrate them to remote servers.