Where else besides SQL can injection happen in an iOS app: NSPredicate, JavaScript in a web view, URLs, and what is the common fix?

14.8.2 · L3 · Interview

Predict first

How do SQL and NSPredicate treat a hostile value when it is concatenated versus bound?

import Foundation
import SQLite3

var db: OpaquePointer?
sqlite3_open(":memory:", &db)
sqlite3_exec(db, "create table users(name text, secret text); insert into users values('alice','a-secret'),('bob','b-secret');", nil, nil, nil)
func rows(_ sql: String, bind: String? = nil) -> [String] {
    var st: OpaquePointer?
    guard sqlite3_prepare_v2(db, sql, -1, &st, nil) == SQLITE_OK else { return ["prepare error"] }
    defer { sqlite3_finalize(st) }
    if let b = bind { sqlite3_bind_text(st, 1, b, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) }
    var out: [String] = []
    while sqlite3_step(st) == SQLITE_ROW { out.append(String(cString: sqlite3_column_text(st, 0))) }
    return out
}
let input = "x' OR '1'='1"
print("SQL by concatenation:", rows("select secret from users where name = '\(input)'"))
print("SQL with a bound parameter:", rows("select secret from users where name = ?", bind: input))
var st: OpaquePointer?
_ = sqlite3_prepare_v2(db, "select 1; drop table users", -1, &st, nil)
print("rows after a stacked statement was prepared:", rows("select count(*) from users"))

let people: [NSDictionary] = [["name": "a", "owner": "alice"], ["name": "b", "owner": "bob"]]
let hostile = "zzz' OR owner != 'q"
let built = NSPredicate(format: "owner == '\(hostile)'")
print("NSPredicate built from a string matches:", people.filter { built.evaluate(with: $0) }.count)
let bound = NSPredicate(format: "owner == %@", hostile)
print("NSPredicate with %@ matches:", people.filter { bound.evaluate(with: $0) }.count)
  • SQL by concatenation: ["a-secret", "b-secret"] / SQL with a bound parameter: [] / rows after a stacked statement was prepared: ["2"] / NSPredicate built from a string matches: 2 / NSPredicate with %@ matches: 0
  • SQL by concatenation: [] / SQL with a bound parameter: ["a-secret", "b-secret"]
  • rows after a stacked statement was prepared: ["0"]

Predict first

Does interpolating into evaluateJavaScript run injected code, and do arguments?

import Foundation
import WebKit

@MainActor func run() async {
    let web = WKWebView(frame: .zero)
    web.loadHTMLString("<html><body></body></html>", baseURL: nil)
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    let hostile = "x'; window.pwned = 1; '"
    _ = try? await web.evaluateJavaScript("window.greeting = '\(hostile)'")
    let injected = try? await web.evaluateJavaScript("window.pwned") as? Int
    print("string interpolation ran injected code:", injected == 1)
    _ = try? await web.callAsyncJavaScript("window.greeting2 = name", arguments: ["name": hostile], in: nil, contentWorld: .page)
    let stored = try? await web.evaluateJavaScript("window.greeting2") as? String
    print("argument stored as plain text:", stored == hostile)
}
Task { @MainActor in await run(); exit(0) }
RunLoop.main.run()
  • string interpolation ran injected code: true / argument stored as plain text: true
  • string interpolation ran injected code: false / argument stored as plain text: false
  • string interpolation ran injected code: true / argument stored as plain text: false

Mental model. A form that says “write your name here” is data; if the clerk reads the name aloud as an instruction, you have handed the writer control. A parameter is a sealed box the clerk is not allowed to open.

Injection is always the same bug: data concatenated into a string that another interpreter then parses as code. SQL is the famous case and the fix is a bound parameter (Q331): with the input x' OR '1'='1 a concatenated query returned both users’ secrets while the bound version returned none, and a stacked statement (select 1; drop table users) did not delete the table because SQLite’s prepare runs only the first statement. The same shape appears in NSPredicate(format:), which parses its format string with its own grammar: building it with string interpolation let the hostile value zzz' OR owner != 'q add a clause and match every row (2), while NSPredicate(format: "owner == %@", value) treated the value as data and matched none (0). The same applies to Core Data fetch requests, NSExpression and key-path predicates built from user-typed keys.

Web views are the third interpreter. Interpolating a value into the script given to evaluateJavaScript let the input close the string literal and run window.pwned = 1 (checked on macOS WebKit; the iOS API is the same). Passing values as callAsyncJavaScript(_:arguments:in:contentWorld:) arguments stored the hostile string as plain text. The same family: URLs built by concatenation (use URLComponents, Q551), shell or Process arguments (pass an argument array, never a shell string), format strings (String(format:) with a user-controlled format), and HTML you render into a web view (escape it, or set textContent rather than markup). The common fix is a typed API that separates code from data, plus an allow-list for the pieces that must stay structural, such as sort keys or column names. Review any place where user text, deep link parameters (Q348) or server strings flow into one of these. Not run: the iOS web view (the check ran WebKit on macOS) and Core Data predicates against a store.

Concatenated input changes the query’s structure; bound input stays data.

The interpolated string ended the literal and executed; the argument form kept the text as data.

Common traps

  • Building an NSPredicate or script with string interpolation “because it is only an internal string”. Internal strings pass through deep links and server fields.
  • Escaping quotes by hand. Escaping rules are subtle per interpreter; use the parameter API.
  • Letting users choose sort or column names directly. Map them through an allow-list.

They might follow up with

  • Why does a bound SQL parameter work? The statement is parsed before the value exists, so the value cannot change its structure.
  • How do you pass data into a web view script safely? As arguments, or as a JSON string you decode inside the script.

Related questions

  • 14.8.1 What is NSSecureCoding, what attack does it prevent, and how do you unarchive data from an untrusted source?
  • 13.2.2 How do you use SQLite safely and quickly: bound parameters, indexes, and schema migrations?
  • 14.4.1 Why are deep links an attack surface, and how do you validate them?
  • 12.8.1 What goes wrong when you build a URL with string concatenation, and how do URLComponents, percent-encoding…

← What is NSSecureCoding, what attack does it prevent, and how do you…   How do you keep third-party code from being swapped under you… →