├── .gitignore ├── LICENSE.md ├── README.md ├── index.js ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /npm-debug.log 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 NowSecure, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # frida-uiwebview 2 | 3 | Inspect and manipulate UIWebView-hosted GUIs through [Frida](https://www.frida.re). 4 | 5 | ## Example 6 | 7 | ```js 8 | import ui from 'frida-uikit'; 9 | import web from 'frida-uiwebview'; 10 | 11 | const webView = await ui.get(node => node.type === 'UIWebView'); 12 | 13 | const loginButton = await web.get(webView, node => node.text === 'Log in to Spotify'); 14 | loginButton.click(); 15 | ``` 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | let DUMP_DOM_SCRIPT, SET_ELEMENT_TEXT_SCRIPT, CLICK_ELEMENT_SCRIPT, TAP_ELEMENT_SCRIPT, GET_ELEMENT_RECT_SCRIPT, IS_ELEMENT_VISIBLE_SCRIPT; 2 | const pendingBlocks = new Set(); 3 | const MAX_RETRIES = 3; 4 | const RETRY_DELAY = 300; 5 | 6 | export function get(webViewNode, predicate, options) { 7 | return new Promise(function (resolve, reject) { 8 | let tries = 0; 9 | async function tryResolve() { 10 | const layout = await WebNode.fromWebView(webViewNode.instance, options); 11 | if (layout !== null) { 12 | const node = layout.find(predicate); 13 | if (node !== null) { 14 | resolve(node); 15 | return; 16 | } 17 | } 18 | 19 | // TODO: configurable timeout and retry interval 20 | tries++; 21 | if (tries < 40) { 22 | setTimeout(tryResolve, 500); 23 | } else { 24 | reject(new Error('Timed out')); 25 | } 26 | } 27 | return tryResolve(); 28 | }); 29 | } 30 | 31 | export function WebNode(data, webView, options) { 32 | this._webView = webView; 33 | this._options = getOptions(options); 34 | 35 | for (let key in data) { 36 | if (data.hasOwnProperty(key) && key !== 'children') { 37 | this[key] = data[key]; 38 | } 39 | } 40 | 41 | this.children = data.children.map(childData => { 42 | return new WebNode(childData, webView, options); 43 | }); 44 | } 45 | 46 | WebNode.fromWebView = async function (webView, options) { 47 | const mergedOptions = getOptions(options); 48 | const result = await perform(webView, DUMP_DOM_SCRIPT, mergedOptions); 49 | return new WebNode(result, webView, mergedOptions); 50 | }; 51 | 52 | function getOptions(options) { 53 | const merged = { 54 | enableJavascript: false 55 | }; 56 | 57 | Object.assign(merged, options); 58 | 59 | return merged; 60 | } 61 | 62 | WebNode.prototype = { 63 | forEach(fn) { 64 | fn(this); 65 | this.children.forEach(child => child.forEach(fn)); 66 | }, 67 | find(predicate) { 68 | if (predicate(this)) { 69 | return this; 70 | } 71 | 72 | const children = this.children; 73 | for (let i = 0; i !== children.length; i++) { 74 | const child = children[i].find(predicate); 75 | if (child !== null) { 76 | return child; 77 | } 78 | } 79 | 80 | return null; 81 | }, 82 | async setText(text) { 83 | return perform(this._webView, SET_ELEMENT_TEXT_SCRIPT, this._options, { 84 | ref: this.ref, 85 | text: text 86 | }); 87 | }, 88 | async click() { 89 | return perform(this._webView, CLICK_ELEMENT_SCRIPT, this._options, { 90 | ref: this.ref 91 | }); 92 | }, 93 | async tap() { 94 | return perform(this._webView, TAP_ELEMENT_SCRIPT, this._options, { 95 | ref: this.ref 96 | }); 97 | }, 98 | async getRect() { 99 | const result = await perform(this._webView, GET_ELEMENT_RECT_SCRIPT, this._options, { 100 | ref: this.ref 101 | }); 102 | return result.rect; 103 | }, 104 | async isVisible() { 105 | const result = await perform(this._webView, IS_ELEMENT_VISIBLE_SCRIPT, this._options, { 106 | ref: this.ref 107 | }); 108 | return result.visible; 109 | } 110 | }; 111 | 112 | function perform(webView, script, options, params) { 113 | const paramsString = (params !== undefined) ? `, ${JSON.stringify(params)}` : ''; 114 | const scriptString = `JSON.stringify((${script}).call(this${paramsString}));`; 115 | 116 | if ('evaluateJavaScript_completionHandler_' in webView && 117 | 'configuration' in webView) { 118 | // WKWebView 119 | return new Promise((resolve, reject) => { 120 | const completionHandler = new ObjC.Block({ 121 | retType: 'void', 122 | argTypes: ['object', 'pointer'], 123 | implementation: function (rawResult, error) { 124 | pendingBlocks.delete(completionHandler); 125 | 126 | if (!error.isNull()) { 127 | const err = new ObjC.Object(error); 128 | reject(new Error(err.toString())); 129 | return; 130 | } 131 | try { 132 | const result = parseResult(rawResult); 133 | resolve(result); 134 | } catch (e) { 135 | reject(e); 136 | } 137 | } 138 | }); 139 | pendingBlocks.add(completionHandler); 140 | if (isMainThread()) { 141 | fireEvaluation(); 142 | } else { 143 | ObjC.schedule(ObjC.mainQueue, fireEvaluation); 144 | } 145 | 146 | function fireEvaluation (retries = MAX_RETRIES) { 147 | if (webView.isLoading()) { 148 | if (retries - 1 <= 0) { 149 | pendingBlocks.delete(completionHandler); 150 | reject(new Error('WKWebView not ready')); 151 | } else { 152 | setTimeout(() => { 153 | ObjC.schedule(ObjC.mainQueue, () => fireEvaluation(retries - 1)); 154 | }, RETRY_DELAY); 155 | } 156 | return; 157 | } 158 | if (options.enableJavascript) { 159 | webView.configuration().preferences().setJavaScriptEnabled_(true); 160 | } 161 | webView.evaluateJavaScript_completionHandler_(scriptString, completionHandler); 162 | } 163 | }); 164 | } else if ('stringByEvaluatingJavaScriptFromString_' in webView) { 165 | // UIWebView 166 | return new Promise((resolve, reject) => { 167 | if (isMainThread()) { 168 | evaluateJavascript(); 169 | } else { 170 | ObjC.schedule(ObjC.mainQueue, () => { 171 | evaluateJavascript(); 172 | }); 173 | } 174 | 175 | function evaluateJavascript() { 176 | const rawResult = webView.stringByEvaluatingJavaScriptFromString_(scriptString); 177 | try { 178 | const result = parseResult(rawResult); 179 | resolve(result); 180 | } catch (e) { 181 | reject(e); 182 | } 183 | } 184 | }); 185 | } else { 186 | throw new Error(`Unsupported kind of webview: ${webView.$className}`); 187 | } 188 | } 189 | 190 | function isMainThread() { 191 | return ObjC.classes.NSThread.isMainThread(); 192 | } 193 | 194 | function parseResult(rawResult) { 195 | const strResult = rawResult.toString(); 196 | if (strResult.length === 0) { 197 | throw new Error('UIWebView not ready'); 198 | } 199 | const result = JSON.parse(strResult); 200 | if (result.error) { 201 | const e = result.error; 202 | throw new Error(e.message + ' at: ' + e.stack); 203 | } 204 | return result; 205 | } 206 | 207 | DUMP_DOM_SCRIPT = `function dumpDom() { 208 | var elementByRef = {}; 209 | var nextRef = 1; 210 | var ignoredElementNames = { 211 | 'link': true, 212 | 'meta': true, 213 | 'script': true, 214 | 'style': true, 215 | 'title': true 216 | }; 217 | 218 | window._fridaElementByRef = elementByRef; 219 | 220 | try { 221 | return dumpElement(document.documentElement); 222 | } catch (e) { 223 | return { 224 | error: { 225 | message: e.message, 226 | stack: e.stack 227 | } 228 | }; 229 | } 230 | 231 | function dumpElement(element) { 232 | var ref = nextRef++; 233 | elementByRef[ref] = element; 234 | 235 | var name = element.localName; 236 | 237 | var data = { 238 | ref: ref, 239 | name: name, 240 | className: element.className, 241 | children: [] 242 | }; 243 | 244 | if (element.id) { 245 | data.id = element.id; 246 | } 247 | 248 | if (name === 'input') { 249 | data.type = element.type || 'text'; 250 | data.fieldName = element.name; 251 | } 252 | 253 | if (name === 'input' || name === 'button') { 254 | data.enabled = !element.disabled; 255 | } 256 | 257 | if (name === 'input' && element.placeholder) { 258 | data.placeholder = element.placeholder; 259 | } 260 | 261 | var i; 262 | 263 | var childNodes = element.childNodes; 264 | for (i = 0; i !== childNodes.length; i++) { 265 | var childNode = childNodes[i]; 266 | if (childNode.nodeType === Node.TEXT_NODE) { 267 | var text = data.text || ''; 268 | text += childNode.wholeText; 269 | data.text = text; 270 | } 271 | } 272 | 273 | var childElements = element.children; 274 | if (childElements !== undefined) { 275 | var children = data.children; 276 | for (i = 0; i !== childElements.length; i++) { 277 | var childElement = childElements[i]; 278 | if (!ignoredElementNames[childElement.localName]) { 279 | children.push(dumpElement(childElement)); 280 | } 281 | } 282 | } 283 | 284 | return data; 285 | } 286 | }`; 287 | 288 | SET_ELEMENT_TEXT_SCRIPT = `function setElementText(params) { 289 | try { 290 | var element = window._fridaElementByRef[params.ref]; 291 | element.value = params.text; 292 | var changeEvent = new Event('change'); 293 | var inputEvent = new Event('input'); 294 | element.dispatchEvent(changeEvent); 295 | element.dispatchEvent(inputEvent); 296 | return {}; 297 | } catch (e) { 298 | return { 299 | error: { 300 | message: e.message, 301 | stack: e.stack 302 | } 303 | }; 304 | } 305 | }`; 306 | 307 | CLICK_ELEMENT_SCRIPT = `function clickElement(params) { 308 | try { 309 | var element = window._fridaElementByRef[params.ref]; 310 | element.disabled = false; 311 | element.click(); 312 | return {}; 313 | } catch (e) { 314 | return { 315 | error: { 316 | message: e.message, 317 | stack: e.stack 318 | } 319 | }; 320 | } 321 | }`; 322 | 323 | TAP_ELEMENT_SCRIPT = `function tapElement(params) { 324 | try { 325 | var element = window._fridaElementByRef[params.ref]; 326 | element.disabled = false; 327 | var identifier = Date.now(); 328 | fire(element, 'touchstart', identifier); 329 | fire(element, 'touchend', identifier); 330 | return {}; 331 | } catch (e) { 332 | return { 333 | error: { 334 | message: e.message, 335 | stack: e.stack 336 | } 337 | }; 338 | } 339 | 340 | function fire(element, type, identifier) { 341 | var touch = document.createTouch(window, element, identifier, 0, 0, 0, 0); 342 | 343 | var touches = document.createTouchList(touch); 344 | var targetTouches = document.createTouchList(touch); 345 | var changedTouches = document.createTouchList(touch); 346 | 347 | var event = document.createEvent('TouchEvent'); 348 | event.initTouchEvent(type, true, true, window, null, 0, 0, 0, 0, false, false, false, false, touches, targetTouches, changedTouches, 1, 0); 349 | element.dispatchEvent(event); 350 | } 351 | }`; 352 | 353 | GET_ELEMENT_RECT_SCRIPT = `function getElementRect(params) { 354 | var element = window._fridaElementByRef[params.ref]; 355 | var rect = element.getBoundingClientRect(); 356 | return { 357 | rect: [[rect.left, rect.top], [rect.width, rect.height]] 358 | }; 359 | }`; 360 | 361 | /* 362 | * Adapted from: http://stackoverflow.com/a/15203639/5418401 363 | */ 364 | IS_ELEMENT_VISIBLE_SCRIPT = `function isElementVisible(params) { 365 | var element = window._fridaElementByRef[params.ref]; 366 | var rect = element.getBoundingClientRect(); 367 | if (rect.width === 0 || rect.height === 0) { 368 | return { 369 | visible: false 370 | }; 371 | } 372 | 373 | var vWidth = window.innerWidth || document.documentElement.clientWidth; 374 | var vHeight = window.innerHeight || document.documentElement.clientHeight; 375 | if (rect.right < 0 || rect.bottom < 0 || rect.left > vWidth || rect.top > vHeight) { 376 | return { 377 | visible: false 378 | }; 379 | } 380 | 381 | var efp = function(x, y) { 382 | return document.elementFromPoint(x, y) 383 | }; 384 | 385 | var rcx = rect.left + rect.width / 2; 386 | var rcy = rect.top + rect.height / 2; 387 | 388 | var elementsAround = [ 389 | efp(rect.left, rect.top), 390 | efp(rect.right, rect.top), 391 | efp(rect.right, rect.bottom), 392 | efp(rect.left, rect.bottom), 393 | efp(rcx, rcy) 394 | ]; 395 | 396 | var anyCornerVisible = false; 397 | 398 | elementsAround.forEach(function (testElement) { 399 | if (testElement === null) { 400 | return; 401 | } 402 | 403 | anyCornerVisible = anyCornerVisible || 404 | element.contains(testElement) || 405 | testElement.contains(element); 406 | }); 407 | 408 | return { 409 | visible: anyCornerVisible 410 | }; 411 | }`; 412 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frida-uiwebview", 3 | "version": "4.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frida-uiwebview", 3 | "version": "4.0.0", 4 | "description": "Inspect and manipulate UIWebView-hosted GUIs through Frida", 5 | "main": "index.js", 6 | "type": "module", 7 | "files": [ 8 | "/index.js" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/nowsecure/frida-uiwebview.git" 13 | }, 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/nowsecure/frida-uiwebview/issues" 17 | }, 18 | "homepage": "https://github.com/nowsecure/frida-uiwebview#readme" 19 | } 20 | --------------------------------------------------------------------------------