Skip to content

MASTG-DEMO-0145: DOM Inspection Using evaluateJavaScript Without Content World Isolation

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

Sample

This sample demonstrates a WKWebView that reads sensitive content from the DOM (a recipient account number) using evaluateJavaScript(_:completionHandler:) without specifying a content world. The script runs in the .page world, where the prototype chain is shared with page JavaScript. A malicious page can override document.querySelector before the call runs:

// Attacker controlled page script
document.querySelector = function(selector) {
    return { textContent: "ATTACKER_CONTROLLED" };
};

After this override, the evaluateJavaScript call returns the attacker value instead of the real DOM content. The native code receives and acts on poisoned data.

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
import UIKit
import WebKit

// SUMMARY: This sample demonstrates DOM inspection using evaluateJavaScript without
// a content world parameter. The scripts run in the page world (.page), where the
// prototype chain is shared with page JavaScript. A malicious page can override
// document.querySelector or other built-ins before these calls run, causing the app
// to receive attacker-controlled values instead of real DOM content.

class MastgTest: NSObject, WKNavigationDelegate {
    private var webView: WKWebView?
    private static var currentTest: MastgTest?
    private var completion: ((String) -> Void)?

    @inline(never) @_optimize(none)
    public static func mastgTest(completion: @escaping (String) -> Void) {
        DispatchQueue.main.async {
            let test = MastgTest()
            currentTest = test
            test.showWebView(completion: completion)
        }
    }

    private func showWebView(completion: @escaping (String) -> Void) {
        self.completion = completion

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

        let html = """
        <html>
        <body>
            <div id="recipient_account_number">ACC_9876543210</div>
        </body>
        </html>
        """

        webView.navigationDelegate = self
        webView.loadHTMLString(html, baseURL: nil)
    }

    public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {

        // Maliciously injected code by an attacker. It overwrites the recipient data
        webView.evaluateJavaScript("""
            document.querySelector = function(selector) {
                return { textContent: "ATTACKER_CONTROLLED" };
            };
        """)

        // FAIL: [MASTG-TEST-0379] evaluateJavaScript runs in the page world.
        // A malicious page can override document.querySelector before this executes:
        // document.querySelector = () => ({ textContent: "ATTACKER_CONTROLLED" })
        webView.evaluateJavaScript("document.querySelector('#recipient_account_number').textContent",
                                   completionHandler: { [weak self] value, _ in
            let account = value as? String ?? "unknown"
            self?.completion?("Recipient Account Number: \(account)")
        })

        /*
        // PASS: Using WKContentWorld.defaultClient (or a custom world) provides isolation.
        // The script runs in a separate world where document.querySelector is the original
        // built-in, even if the page world has overridden it.
        webView.evaluateJavaScript("document.querySelector('#recipient_account_number').textContent",
                                   in: nil,
                                   in: .defaultClient) { [weak self] result in
            switch result {
            case .success(let value):
                let account = value as? String ?? "unknown"
                self?.completion?("Recipient Account Number: \(account)")
            case .failure:
                self?.completion?("Error: Failed to retrieve account number")
            }
        }
        */
    }
}

Steps

  1. Use Exploring the App Package to extract the app. The main binary is ./Payload/MASTestApp.app/MASTestApp.
  2. Use radare2 (iOS) with the -i option to run this script.
evaluate_js_no_world.r2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
e asm.bytes=false
e scr.color=false
e scr.interactive=false
e asm.var=false

?e List all uses of the 'evaluateJavaScript:completionHandler:' selector:
f~evaluateJavaScript

?e

?e xrefs to 'evaluateJavaScript:completionHandler:':
axt @ reloc.fixup.evaluateJavaScript:completionHa

?e

?e Code snippet at first call site (attacker-injected prototype override):
pd 15 @ 0x1000047dc

?e
?e Code snippet at second call site (reads recipient account number):
pd 43 @ 0x100004820
run.sh
1
2
#!/bin/bash
r2 -q -e bin.relocs.apply=true -i evaluate_js_no_world.r2 -A MASTestApp > output.txt

Observation

The script finds two xrefs to evaluateJavaScript:completionHandler:, both inside MastgTest.webView(_:didFinish:). The disassembly at each call site is shown below.

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
69
70
71
72
List all uses of the 'evaluateJavaScript:completionHandler:' selector:
0x10000ad00 38 str.evaluateJavaScript:completionHandler:
0x1000144a8 8 reloc.fixup.evaluateJavaScript:completionHa

xrefs to 'evaluateJavaScript:completionHandler:':
sym.MASTestApp.MastgTest.webView.allocator.didFinish...ySo05WKWebF0C_So12WKNavigationCSgtFTf4ndn_n 0x100004804 [DATA:r--] ldr x1, reloc.fixup.evaluateJavaScript:completionHa
sym.MASTestApp.MastgTest.webView.allocator.didFinish...ySo05WKWebF0C_So12WKNavigationCSgtFTf4ndn_n 0x1000048b0 [DATA:r--] ldr x1, reloc.fixup.evaluateJavaScript:completionHa

Code snippet at first call site (attacker-injected prototype override):
|           0x1000047dc      adrp x8, 0x10000a000
|           0x1000047e0      add x8, x8, 0x3f0                         ; 0x10000a3f0 ; "                       document.querySelector = function(selector) {\n                           return { textContent: "ATTACKER_CONTROLLED" };\n                       };"
|           0x1000047e4      sub x8, x8, 0x20
|           0x1000047e8      mov x9, 0x3f                              ; '?'
|           0x1000047ec      movk x9, 0xd000, lsl 48
|           0x1000047f0      add x0, x9, 0x69
|           0x1000047f4      orr x1, x8, 0x8000000000000000
|           0x1000047f8      bl sym.imp.Foundationbool_...ridgeToObjectiveCSo8NSStringCyF_ ; Foundationbool(...ridgeToObjectiveCSo8NSStringCyF)
|           0x1000047fc      mov x21, x0
|           0x100004800      adrp x23, sym.__PROTOCOL_WKScriptMessageHandler ; 0x100014000
|           0x100004804      ldr x1, [x23, 0x4a8]                      ; [0x10000ad00:8]=0x657461756c617665 ; "evaluateJavaScript:completionHandler:" ; char *selector
|           0x100004808      mov x0, x19                               ; void *instance
|           0x10000480c      mov x2, x21
|           0x100004810      mov x3, 0
|           0x100004814      bl sym.imp.objc_msgSend                   ; void *objc_msgSend(void *instance, char *selector)

Code snippet at second call site (reads recipient account number):
|           0x100004820      adrp x8, 0x10000a000
|           0x100004824      add x8, x8, 0x4a0                         ; 0x10000a4a0 ; "document.querySelector('#recipient_account_number').textContent"
|           0x100004828      sub x8, x8, 0x20
|           0x10000482c      orr x1, x8, 0x8000000000000000
|           0x100004830      mov x0, 0x3f                              ; '?'
|           0x100004834      movk x0, 0xd000, lsl 48
|           0x100004838      bl sym.imp.Foundationbool_...ridgeToObjectiveCSo8NSStringCyF_ ; Foundationbool(...ridgeToObjectiveCSo8NSStringCyF)
|           0x10000483c      mov x21, x0
|           0x100004840      adrp x0, segment.__DATA_CONST             ; 0x100010000
|           0x100004844      add x0, x0, 0x758                         ; 0x100010758
|                                                                      ; aav.0x100010758
|           0x100004848      mov w1, 0x18
|           0x10000484c      mov w2, 7
|           0x100004850      bl sym.imp.swift_allocObject
|           0x100004854      mov x22, x0
|           0x100004858      add x0, x0, 0x10
|           0x10000485c      mov x1, x20
|           0x100004860      bl sym.imp.swift_unknownObjectWeakInit
|           0x100004864      adrp x8, sym.MASTestApp.MastgTest.mastg.allocator_...FZ_ ; 0x100004000
|           0x100004868      add x8, x8, 0x910
|           0x10000486c      stp x8, x22, [arg_20h]
|           0x100004870      adrp x8, segment.__DATA_CONST             ; 0x100010000
|           0x100004874      ldr x8, [x8, 0x50]                        ; [0x100010050:8]=0
|                                                                      ; reloc._NSConcreteStackBlock
|           0x100004878      str x8, [sp]
|           0x10000487c      adrp x8, sym.imp.SwiftUI.Font.system.design.C9.Design_...FZ_ ; 0x100009000
|           0x100004880      ldr d0, [x8, 0x810]                       ; [0x100009810:8]=0x42000000 ; section.3.__TEXT.__const
|                                                                      [03] -r-x section size 420 named 3.__TEXT.__const
|           0x100004884      str d0, [arg_8h]
|           0x100004888      adrp x8, sym.MASTestApp.MastgTest.mastg.allocator_...FZ_ ; 0x100004000
|           0x10000488c      add x8, x8, 0x5c0
|           0x100004890      adrp x9, segment.__DATA_CONST             ; 0x100010000
|           0x100004894      add x9, x9, 0x770
|           0x100004898      stp x8, x9, [arg_10h]
|           0x10000489c      mov x0, sp
|           0x1000048a0      bl sym.imp._Block_copy
|           0x1000048a4      mov x20, x0
|           0x1000048a8      ldr x0, [arg0]                            ; void *arg0
|           0x1000048ac      bl sym.imp.swift_release                  ; void swift_release(void *arg0)
|           0x1000048b0      ldr x1, [x23, 0x4a8]                      ; [0x10000ad00:8]=0x657461756c617665 ; "evaluateJavaScript:completionHandler:" ; char *selector
|           0x1000048b4      mov x0, x19                               ; void *instance
|           0x1000048b8      mov x2, x21
|           0x1000048bc      mov x3, x20
|           0x1000048c0      bl sym.imp.objc_msgSend                   ; void *objc_msgSend(void *instance, char *selector)
|           0x1000048c4      mov x0, x20
|           0x1000048c8      bl sym.imp._Block_release

Evaluation

The test case fails because the app calls evaluateJavaScript:completionHandler: to read security-sensitive DOM content in the page world.

The output shows two call sites. The first (0x100004804) is the attacker-injected prototype override that is part of this demo's setup and not the app's production logic. At 0x1000047e0, add x8, x8, 0x3f0 resolves to the string "document.querySelector = function(selector) { return { textContent: \"ATTACKER_CONTROLLED\" }; };" in the read-only data section. This string is bridged to an NSString (stored in x21), then passed as x2 to objc_msgSend at 0x100004814 with x3 = 0 (no completion handler), confirming this is the page-level override.

The second call site (0x1000048b0) is the security-relevant one. At 0x100004824, add x8, x8, 0x4a0 resolves to "document.querySelector('#recipient_account_number').textContent". After Swift string boxing (sub, orr) and bridging to NSString via Foundationbool_...ridgeToObjectiveCSo8NSStringCyF_, the result is stored in x21 at 0x10000483c. At 0x100004840 to 0x1000048a0, a completion block is built and heap-copied via _Block_copy; the result lands in x20. Then at 0x1000048b0, ldr x1, [x23, 0x4a8] loads the selector evaluateJavaScript:completionHandler: into x1, and:

  • x0x19 (the WKWebView instance)
  • x2x21 ("document.querySelector('#recipient_account_number').textContent")
  • x3x20 (the completion block)

The objc_msgSend at 0x1000048c0 therefore calls [webView evaluateJavaScript:@"document.querySelector('#recipient_account_number').textContent" completionHandler:block]. Because the selector is evaluateJavaScript:completionHandler: rather than the content-world variant evaluateJavaScript:inFrame:inContentWorld:completionHandler:, the script runs in the .page world. Since the first call site already ran in the same world and overrode document.querySelector, this call receives the attacker-controlled value instead of the real DOM content.