分析一次有意思的需求——HTML代碼注入

有個朋友問了我一個問題:javascript

他們經過WKWebView,訪問了一個其餘的頁面,而後但願原生得到用戶的輸入信息。css

其實,我以前接觸WKWebView並很少,可是這個問題我以爲頗有意思。這篇文章即是我解決這個問題的所有思路,與最終的解決辦法。html

思路分析 與 代碼實踐

這個問題其實很具象了,就是但願原生得到H5的用戶輸入內容(這樣子感受有些不地道-_-)前端

接下來咱們就須要分析這個需求了。java

首先咱們先須要抓住兩個點,1個是H5,1個是原生。git

因此這個問題如今被我拆分出了1個額外的問題github

HTML可否和原生交互?如何進行?

這個問題實際上是一個很關鍵的問題,由於咱們只有實現了原生和HTML的交互,才能得到相關信息(這裏咱們假定網頁是咱們本身寫的,徹底受操縱於咱們本身)web

因而便搜尋資料,發現WKWebView提供了一個很方便的交互渠道WKScriptMessageHandler,咱們經過對WKWebView進行相關的定製操做即可以解決。數組

咱們先建立一個工程,這裏我把整個工程命名爲InjectHTML瀏覽器

爲了防止循環引用,咱們先構建一箇中間層ScriptHandler

import Foundation
import WebKit
// 這裏咱們使用一箇中間層來解除循環WebView和Controller間的循環引用問題
class ScriptHandler: NSObject, WKScriptMessageHandler {

    weak var delegate: WKScriptMessageHandler?

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        delegate?.userContentController(userContentController, didReceive: message)
    }

    init(delegate: WKScriptMessageHandler? = nil) {
        self.delegate = delegate
    }
}
複製代碼

在ViewController中的代碼

import UIKit
import WebKit
class ViewController: UIViewController {
    var webview: WKWebView!

    static let scriptKey = "InjectHTML"

    override func viewDidLoad() {
        super.viewDidLoad()
        //初始化 Configuration
        let configuration = WKWebViewConfiguration()
        configuration.userContentController = WKUserContentController()
        // 給Configuration 增長一個js script處理器
        // 採用了中間層的因素,避免循環引用致使沒法釋放問題
        configuration.userContentController.add(ScriptHandler(delegate: self), name: ViewController.scriptKey)

        webview = WKWebView(frame: view.bounds, configuration: configuration)

        view.addSubview(webview)
        // 設置導航處理器
        webview.navigationDelegate = self
        // 咱們先從本地讀網頁方便自我改動測試
        let fileURL = Bundle.main.url(forResource: "index", withExtension: "html")
        // 加載網頁
        webview.loadFileURL(fileURL!, allowingReadAccessTo: fileURL!)
    }
}

extension ViewController: WKScriptMessageHandler {
    // 遵照協議
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // 因html可能傳遞多種類型名稱,咱們這裏須指定key
        if message.name == ViewController.scriptKey {
            guard let dic = message.body as? [String: String] else {
                return
            }
            // 交給真實處理解析函數
            receiveInputValue(para: dic)
        }
    }
    // 解析函數能夠負責更具體的內容,由於demo,故此只是打印
    func receiveInputValue(para: [String: String]) {
        let title = para["title"] ?? "無值"

        let message = para["message"] ?? "無值"

        let `id` = para["id"] ?? "無值"

        print("title: \(title)")
        print("message: \(message)")
        print("id: \(id)")
    }
}
// 遵循導航協議,方便咱們知道什麼時候網頁加載完成
extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {

    }
}
複製代碼

這樣子咱們的ViewController 就構建完成,訪問了一個本地index.html的網頁。

咱們在項目中新建一個html文件(從外界拖入也可)

html頁面只用於測試,所以咱們也就不作相似自適應之類的css樣式了

html代碼以下:

<html>
    <head>
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--添加一個button按鈕用以測試點擊事件-->
        <button onclick="onClickTest()">Test Button</button>
    </body>
    <script type="text/javascript"> function onClickTest() { // 注意,這裏是須要注意的是咱們在ViewController中定義的Script Name須要做爲messageHandler的一個屬性 window.webkit.messageHandlers.InjectHTML.postMessage({title: 'test title', message:'test message', id: 'test id'}) // 若咱們未註冊此名稱,則沒法觸發對應回調,postMessage中的參數可傳爲任意的,但咱們在原生中定義爲字典了,則咱們在這裏須要傳入字典 window.webkit.messageHandlers.InjectHTMLS.postMessage({title: 'test title', message:'test message', id: 'test id'}) } </script>
</html>
複製代碼

其中對應部分均已加上註釋,接下來咱們來跑一遍測試結果:

title: test title

message: test message

id: test id

當咱們點擊WebView上的按鈕時候,咱們能夠打印出來對應的js端返回結果,說明成功了。

因此,這個需求咱們能夠說解析了3分之1了。


接下來咱們須要繼續分析了,這個需求須要咱們能監控全部的輸入控件。

因此咱們須要從網頁端剖析了,問題迴歸HTML端。

咱們繼續拆解問題,咱們既然要監控全部的輸入控件,那麼咱們首先就得知道,咱們可否得到全部的輸入控件(甚至說,咱們可否得到頁面的全部控件,而後進行遍歷過濾也能夠)

HTML如何獲取指定元素

這個問題,經過搜索與詢問前端朋友得知:

HTML提供了對應的Api,直接獲取指定標籤的內容,由於咱們是要得到輸入框,輸入框在HTML中的標籤是input。因此咱們得到頁面中全部輸入框元素的方法就已經出來了

// 得到全部input數組
var inputs = document.getElementsByTagName("input")
複製代碼

那麼問題就迎刃而解了,而後咱們仍是須要測試一下,畢竟萬一這個方法很差用怎麼辦

這裏就不在手機端進行測試,由於若是模擬器仍是比較費事的,而且咱們若是想打印console日誌的話,看起來也不那麼容易,所以咱們將接下來的測試網頁步驟,直接挪到Google Chrome上。

這裏仍是給出對應的html測試代碼

<html>
    <head>
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--添加一個button按鈕用以測試點擊事件-->
        <button onclick="onClickTest()">Test Button</button>

        <input type="text" placeholder="Test Input1">
        <input type="text" placeholder="Test Input2">
    </body>
    <script type="text/javascript"> // 得到全部input數組 var inputs = document.getElementsByTagName("input"); // 打印input數組 console.log(inputs) </script>
</html>
複製代碼

而後使用瀏覽器(下文瀏覽器均爲Google Chrome)打開。

咱們按Command+Shift+c便可打開頁面審查和網頁控制檯,在這裏咱們看見了咱們執行網頁的log,打印結果以下

log打印

說明咱們獲取input成功了

接下來咱們只須要進行過濾便可得到咱們須要的頁面元素了(如過濾checkbox之類的)

而後接下來新的問題來了

JS代碼如何動態添加事件

咱們已經經過js代碼得到input了,因此問題就變到了如何添加點擊事件。畢竟原生的輸入框還有至少監聽輸入事件,或者監聽輸入完成類的,那麼JS端理應也存在。

這裏感謝菜鳥教程,查詢到了一個函數addEventListener

這個函數的功能就是給HTTP的DOM元素增長對應的事件,也就是說咱們能夠經過這個方法額外的增長點擊事件。

同時,新增長的事件並不會覆蓋原有事件,一個元素能夠擁有多個一樣的事件(如一個按鈕能夠同時出發兩個onClick事件)

這個函數給了咱們新的天地啊。所以咱們能夠給input增長事件,這裏我選擇了兩個事件,一個是input事件(輸入框文字發生變化),一個是change事件(輸入框失去焦點)

咱們修改上文的html代碼,修改結果以下:

<html>
    <head>
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--添加一個button按鈕用以測試點擊事件-->
        <button onclick="onClickTest()">Test Button</button>
        <!--這裏給input增長一些回調-->
        <input type="text" placeholder="Test Input1" onchange="onChange()" oninput="onInput()">
        <input type="text" placeholder="Test Input2" onchange="onChange()" oninput="onInput()">
    </body>
    <script type="text/javascript"> // 這裏是爲了測試原有對應事件是否會被覆蓋 function onChange(input) { console.log("原有 失去焦點"); console.log(input); } // 這裏是爲了測試原有對應事件是否會被覆蓋 function onInput(input) { console.log("原有 鍵盤輸入"); console.log(input); } </script>
    <script type="text/javascript"> function demoOnchange(input) { console.log("失去焦點"); console.log(input); } function demoOnInput(input) { console.log("鍵盤輸入"); console.log(input); } function demoSet() { var inputs=document.getElementsByTagName("input"); for(var i=0;i < inputs.length;i++) { var input = inputs[i]; // 這裏咱們增長一些過濾條件,由於咱們有時並不須要全部的input,這裏我只是容許了text(文字輸入框) if(input.type=="text") { input.addEventListener('change', demoOnchange); input.addEventListener('input', demoOnInput) } } } demoSet(); </script>
</html>
複製代碼

繼續進入瀏覽器測試網址,咱們能夠看到,當咱們輸入的時候,同時觸發了兩個回調,說明後期注入有效

接下來咱們須要繼續考慮,咱們須要能讓原生接收到對應事件啊

原生如何收到對應事件

咱們在上文中已經知道了js如何調用原生,那麼這一步也就咱們也就能夠直接經過結合即可以實現了。

這一步仍是更改html代碼

<html>
    <head>
        <!--HTML頁面內容需告知爲utf8,不然會出現亂碼問題-->
        <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
        <title>注入測試網頁</title>
    </head>
    <body bgcolor="#FFFFFF">
        <h1>Test for inject js</h1>
        <!--添加一個button按鈕用以測試點擊事件-->
        <button onclick="onClickTest()">Test Button</button>
        <!--這裏給input增長一些回調-->
        <input type="text" placeholder="Test Input1" onchange="onChange()" oninput="onInput()">
        <input type="text" placeholder="Test Input2" onchange="onChange()" oninput="onInput()">
    </body>
    <script type="text/javascript"> // 這裏是爲了測試原有對應事件是否會被覆蓋 function onChange(input) { console.log("原有 失去焦點"); console.log(input); } // 這裏是爲了測試原有對應事件是否會被覆蓋 function onInput(input) { console.log("原有 鍵盤輸入"); console.log(input); } </script>
    <script type="text/javascript"> function demoOnchange(input) { // 這裏,input是一個點擊事件,target纔是真正的input元素,咱們能夠經過此任意獲取input的相關信息,如class,id以及其餘的一些信息等等 window.webkit.messageHandlers.InjectHTML.postMessage({title: '輸入框失去焦點', message:input.target.value, id: input.target.id}); } function demoOnInput(input) { window.webkit.messageHandlers.InjectHTML.postMessage({title: "輸入框正在輸入", message:input.target.value, id: input.target.id}); } function demoSet() { var inputs=document.getElementsByTagName("input"); for(var i=0;i < inputs.length;i++) { var input = inputs[i]; // 這裏咱們增長一些過濾條件,由於咱們有時並不須要全部的input,這裏我只是容許了text(文字輸入框) if(input.type=="text") { input.addEventListener('change', demoOnchange); input.addEventListener('input', demoOnInput) } } } demoSet(); </script>
</html>
複製代碼

從新啓動剛纔的項目App,在輸入框中輸入,能夠看到控制檯中返回了數據

title: 輸入框正在輸入

message: 1

id:

title: 輸入框失去焦點

message: 1

id:

咱們的打印出來了。到這裏咱們自有網頁的測試所有經過,那麼就剩下最後一步了,如何在三方網頁上執行?

JS代碼注入

WKWebView既然能讓js調用OC,那麼OC可否調用js代碼呢?

答案是能夠的,WKWebView提供給咱們原生的方法能夠動態的執行js代碼

webview.evaluateJavaScript(someTest, completionHandler: closure)
複製代碼

這個方法可讓咱們動態的執行由原生生成的js代碼。

那麼咱們須要執行什麼方法呢?

天然就是咱們上述研究出來的獲取HTML的元素並增長事件回調的事情啊。

這裏咱們在項目中新建一個js文件,方便咱們之後修改js代碼,名稱就叫Inject.js

從項目中的html文件中把以下方法複製進來

function demoOnchange(input) {
    window.webkit.messageHandlers.InjectHTML.postMessage({title: "輸入框失去焦點", message:input.target.value, id: input.target.id});
}
function demoOnInput(input) {
    window.webkit.messageHandlers.InjectHTML.postMessage({title: "輸入框正在輸入", message:input.target.value, id: input.target.id});
}
function demoSet() {
    var inputs=document.getElementsByTagName("input");
    for(var i=0;i < inputs.length;i++) {
        var input = inputs[i];
                
        if(input.type=="text") {
            input.addEventListener('change', demoOnchange);
            input.addEventListener('input', demoOnInput)
        }
    }
}
demoSet();
複製代碼

而後咱們決定採用直接從項目中讀取文件的形式將js文件轉成字符串,咱們在ViewController中的

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

函數中加入js代碼注入,使咱們每次加載成功網頁都注入對應的js代碼

完整函數以下:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    guard let jsString = try? String(contentsOfFile: Bundle.main.path(forResource: "Inject", ofType: "js") ?? "") else {
            // 沒有讀取出來則不執行注入
        return
    }
    // 注入語句
    webView.evaluateJavaScript(jsString, completionHandler: { _, _ in
        print("代碼注入成功")
    })
}
複製代碼

咱們將應用程序中的index.html網頁中對應的js代碼刪除。使這個網頁更像是一個三方網頁(不會主動支持該功能)。這裏,就再也不詳述HTML代碼了。

咱們執行測試後發現,測試網頁回調成功

三方網頁集成測試

當咱們以三方網頁測試的時候,便會發現這樣或者那樣的問題。

咱們以 掘金 爲例,試試咱們的自有腳本。咱們的js代碼在掘金網上的登陸居然失效了。

可是做爲程序,函數是具備冪等性的(在相同狀況下進行無限次的操做,結果必定相同)。那麼只能是咱們的環境出現了問題,而不是咱們的js代碼失敗了。

因此咱們須要觀察環境究竟哪裏出現問題了。

首先在咱們的自有測試網站上,輸入框是直接就存在的。可是在掘金上卻不是,它須要咱們點擊登陸按鈕後做爲彈框出現。而咱們的js代碼注入是在網頁渲染完成,所以咱們接下來嘗試給咱們的頁面增長一個按鈕。點擊的時候再進行js代碼注入

爲了簡化代碼,咱們給ViewController加入了一個導航欄,並在導航欄右上角增長一個注入按鈕。(導航控制器經過StoryBoard增長)

咱們在ViewController的viewDidLoad中增長以下代碼:

self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "注入", style: .plain, target: self, action: #selector(injectJS))
複製代碼

同時在類中增長以下方法

@objc func injectJS() {
    guard let jsString = try? String(contentsOfFile: Bundle.main.path(forResource: "Inject", ofType: "js") ?? "") else {
        // 沒有讀取出來則不執行注入
        return
    }
    // 注入語句
    webview.evaluateJavaScript(jsString, completionHandler: { _, _ in
        print("代碼注入成功")
    })
}
複製代碼

從新運行代碼

發現頭部被擋在導航欄下面了-_-

繼續改尺寸(代碼就不列出來了)

這陣咱們點擊網頁上的登陸按鈕,隨後點擊導航欄上注入

而後再進行輸入,咱們就能夠看見控制檯打印出咱們正在輸入的帳戶名和密碼了-_-

若是你問我怎麼自動注入?我給的思路就是獲取到登陸的元素,而後再多作一步回調,咱們能夠先注入一次js再注入咱們的目標js。

可是具體怎麼實現麼,很差意思,不說了。

總結

其實這篇文章的主要目的介紹如何分析具體的需求,並將其轉換爲代碼。根本在於從一個相對於具象的需求中,咱們一步一步抽離問題,組成更細小的問題的組合,而後逐步的去實驗小問題的可行性,最終完成複雜問題

全部的需求都可以如此實現,只是有的咱們能夠實現,有的問題分析到最後發現,細節沒法實現或實現起來成本太高(好比平安銀行的自動識別手機殼顏色)。

當我寫完這篇文章的demo的時候發現,咱們的用戶信息實在是太容易泄漏了,好比個人掘金帳戶和密碼,若是在別人app上的內嵌網頁登陸的話,不是直接就泄漏了嗎(手動滑稽)。

ps:這篇文章能夠有個別名:震驚!!!你還敢在你的手機上登陸帳戶麼

本文所述demo連接:

github.com/chouheiwa/I…

參考:

菜鳥教程

結尾

本文純屬娛樂寫出,三方網站也只是以有腳本爲例注入測試。並沒有任何破解成分。

另外你們若是有什麼更好的建議或意見,也能夠評論指出。

推廣一波我的的公衆號與博客

公衆號
相關文章
相關標籤/搜索