On November 4, the security firm Sekoia published a report on a campaign it calls Poisoned Source. For about a week, from August 27 to September 3, 2026, a web analytics tracker used by thousands of mostly Ukrainian sites carried extra code. That code fingerprinted each visitor's device and could serve an exploit chain for iPhones or Android phones without the visitor tapping anything.

The same day, CISA added one of the Apple bugs in those chains, CVE-2024-27834, to its Known Exploited Vulnerabilities catalog. Apple fixed that bug in May 2024.

Most coverage stops at "update your phone", which is good advice for owners and no help to the person building the app. If you ship an iOS app, you have web content inside it or a website in front of it, and that content runs on the same engine these exploits attacked. Below is what the report confirms, what it does not say, and a review you can run on your own app in about an hour.

What is confirmed

The facts below come from Sekoia's report, with patch details from Apple's security pages. Sekoia attributes the activity to APT29 with moderate to high confidence.

DetailWhat the report says
FoundMid-October 2026, published November 4, 2026
DeliveryInjected JavaScript on hacked sites, lookalike domains, and the compromised Plerdy tracker
Tracker filea.plerdy[.]com/public/js/click/main.js
Fingerprinting61 collectors producing 394 JSON fields, sent to a server that decides whether to return exploit code
iOS 16.7 chainCVE-2025-43529 and CVE-2024-27834, aimed at stealing cookies
iOS 18.5 chainCVE-2025-43529, CVE-2026-20700, CVE-2025-14174, CVE-2025-43510 and CVE-2025-43520, aimed at messages, Keychain entries, photos, health data and more
Android chainChrome 139 to 145 except 143, CVE-2026-3910 and CVE-2025-27038
PersistenceNone of the payloads install persistent software
Zero-daysNone. All of the flaws already had fixes.

Sekoia's own line is the one to keep in mind: "a user running an up-to-date operating system is safe from these attacks." The report also has limits. It does not explain how the attackers got into Plerdy, and nothing in it accuses Plerdy of anything beyond being the compromised party. It describes visitors loading sites in a browser. It does not report any app's web view being hit.

The targets also matter. The hacked sites and tracker customers lean heavily toward Ukraine and a few specific sectors, so this was not a mass attack on app users. The reason to care is the method, because a third-party tag is a method that can reach any site, and a copy of DarkSword, the exploit kit behind the iOS 18.5 chain, was uploaded to VirusTotal in September. Sekoia expects this kind of kit reuse to become more common.

Why this reaches app developers

Three of the iOS bugs here live in WebKit according to Apple's security notes: CVE-2025-43529, CVE-2025-14174 and CVE-2024-27834. The other three sit in dyld and the kernel, and the chain uses them after it gets a foothold in web content.

WKWebView renders pages with the system's WebKit. Your app does not bundle a copy of the engine, so you cannot ship a patched one. A user on an old iOS version gets an old engine in Safari and in your in-app web view alike. Since no report describes this chain running through a WKWebView, I would call that exposure unproven. I would still treat it as something to reduce, because the fix on your side costs little.

The other route is your own site. Your landing page, help center, pricing page and checkout probably carry a few tags added by whoever ran marketing that month. Each tag is code from another company, running on your domain, for every visitor, and it can change without a deploy on your side. That is the exact position Plerdy's customers were in.

AI-built apps tend to carry more of this. A founder who wraps a web app with Capacitor, or drops a WebView into an Expo project, ends up with a screen full of remote JavaScript that nobody on the team reviewed line by line. The same applies to a paywall or onboarding flow served from a hosted page.

Step 1: find every place your app shows web content

Start with a search of the repo. This covers native code and the common cross-platform wrappers:

grep -rnE "WKWebView|WebView\(|WebPage\(|SFSafariViewController|ASWebAuthenticationSession|loadHTMLString|react-native-webview|@capacitor/core|cordova" \
  --include="*.swift" --include="*.m" --include="*.ts" --include="*.tsx" \
  --include="*.js" --include="package.json" \
  --exclude-dir=node_modules --exclude-dir=Pods --exclude-dir=.build .

Write the results into a short table with four columns: the screen, the URL it loads, who controls that URL, and whether a logged-in session reaches it. Terms and privacy pages are usually harmless. The rows that deserve attention are logged-in pages, payment pages, and anything loading a URL that comes from your server or from user content.

Step 2: list the third-party scripts on those pages

Open each page in desktop Safari, open Web Inspector, and paste this in the console. It lists every script the page loaded and which host served it:

performance.getEntriesByType("resource")
  .filter(r => r.initiatorType === "script")
  .map(r => new URL(r.name).host)
  .reduce((seen, host) => seen.add(host), new Set())

To check the page as your app sees it, set isInspectable to true on the web view in a debug build. It defaults to false and needs iOS 16.4 or later. Then pick the view from Safari's Develop menu and run the same snippet. Keep the setting behind a compiler flag so it never reaches release:

#if DEBUG
if #available(iOS 16.4, *) {
    webView.isInspectable = true
}
#endif

For each host, ask who added it and whether anyone still reads the data it collects. Trackers often outlive the campaign they were added for. Removing a tag is one of the cheapest security fixes you can make, and it needs no app update.

If you ever used Plerdy

Check whether the tag was live between August 27 and September 3, 2026. If it was, visitors to your pages received the altered file during that week. Sekoia is working with national CERTs on victims, so if your site was affected, contact your national CERT. You can also search a site export, tag manager dump or CSP report log for the domains Sekoia names:

grep -rnEi "flux-telemetry|starx-net|clouds-stacks|ads-it-assets|adv-static|wpvortex-mod|assets-int|theowo\.club|america250\.holiday" ./site-export

A clean result only tells you those names are absent from what you searched. The report says more indicators are available through Sekoia's threat intelligence product.

Step 3: limit what third-party scripts can do on your site

Two browser features apply here, and they cover different cases.

Subresource Integrity makes the browser refuse a script whose hash does not match the one in your HTML. For a file you pin, a hijacked copy simply fails to load. Generate the hash with the command from MDN, then add integrity and crossorigin:

cat widget.js | openssl dgst -sha384 -binary | openssl base64 -A

<script src="https://cdn.example.com/widget.js"
        integrity="sha384-YOUR_HASH_HERE"
        crossorigin="anonymous"></script>

The server must send CORS headers, or the load fails. The catch is that most analytics vendors update their tag whenever they like, so a pinned hash breaks on their next release. For those vendors your choices are to self-host a reviewed copy, to accept the risk knowingly, or to drop the tag.

Content Security Policy limits where scripts can load from and where they can send data. It would not have stopped a modified file on an allowed host, though it can stop that file from pulling a second script from a host you never listed. Roll it out in report-only mode first so you can see what would break:

Content-Security-Policy-Report-Only:
  script-src 'self' https://cdn.example.com;
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'none'

Once the reports are quiet, rename the header to Content-Security-Policy. On login and checkout pages I would go further and allow no third-party scripts at all.

Step 4: tighten the web view itself

Pick the right component first. If a screen only displays a page, and your app does not need to touch it, SFSafariViewController is the better choice. Apple's documentation says your app cannot see the interactions and has no access to the website data, so a bad page has less of your app to work with. It still uses the system engine and will not protect an unpatched device.

Keep WKWebView for pages you own. Then give it as little as possible:

import WebKit

func makeRestrictedWebView() -> WKWebView {
    let config = WKWebViewConfiguration()

    // Cookies die with the web view instead of living on disk.
    config.websiteDataStore = .nonPersistent()

    // Only navigate within domains listed under WKAppBoundDomains.
    config.limitsNavigationsToAppBoundDomains = true

    return WKWebView(frame: .zero, configuration: config)
}

App-Bound Domains needs a WKAppBoundDomains array in Info.plist with up to 10 domains. Once you opt in, JavaScript injection, custom style sheets, cookie manipulation and message handlers are denied except on those domains, and the flag above makes a navigation elsewhere fail. WebKit's own write-up has a limit worth knowing: the check applies to the top-level frame, so third-party iframes can still appear inside an allowed page. SwiftUI's newer WebPage has the same setting on its configuration.

A non-persistent store will sign people out when the view goes away. That suits a one-off payment or support flow and doesn't suit a web app that users stay logged in to, so decide per screen.

To cover the iframe gap and block scripts from anywhere but your CDN, add a content rule list. WebKit evaluates rules in order, so the exception goes last:

let rules = """
[
  { "trigger": { "url-filter": ".*",
                 "resource-type": ["script"],
                 "load-type": ["third-party"] },
    "action": { "type": "block" } },
  { "trigger": { "url-filter": "^https://cdn\\\\.example\\\\.com/" },
    "action": { "type": "ignore-previous-rules" } }
]
"""

WKContentRuleListStore.default().compileContentRuleList(
    forIdentifier: "first-party-scripts",
    encodedContentRuleList: rules
) { list, error in
    guard let list else { return }
    webView.configuration.userContentController.add(list)
}

Load the page after the list is added, or the first load runs without it. Test the failure path too. If compiling fails and your code carries on, you have a web view with no rules and no warning.

Check who is calling your native bridge

A JavaScript bridge is where a compromised page turns into a problem for your app. Any script in any frame that can reach window.webkit.messageHandlers can call you, unless you check. WKScriptMessage gives you the sending frame, so check it before you act:

func userContentController(
    _ controller: WKUserContentController,
    didReceive message: WKScriptMessage
) {
    let frame = message.frameInfo
    guard frame.isMainFrame,
          frame.securityOrigin.protocol == "https",
          frame.securityOrigin.host == "app.example.com" else { return }

    // Treat message.body as untrusted input. Validate types and values.
}

This check does not help if the malicious script runs in your own main frame, which is what a hijacked tag does. So keep the bridge small. Expose narrow actions like "close this screen" and never a general call that returns a token or runs an arbitrary native action. If an AI tool generated your bridge, read it for a catch-all handler, since that is the first thing to remove.

Step 5: shorten what a stolen session is worth

The iOS 16.7 chain went after cookies so the attackers could reuse sessions, with webmail as the example in coverage. Your server decides how long a stolen cookie stays useful. Keep session lifetimes short for web flows inside apps, require a fresh login for changes to email, password and payment details, and make sure a user can sign out of all devices. None of this needs an app update, and it helps against every kind of session theft.

The iOS 18.5 chain reached Keychain entries, and at that depth your app has no defense of its own. What you can control is how much a stolen token buys, so prefer short-lived access tokens with server-side revocation over a long-lived token that opens the whole account.

Step 6: nudge users on old iOS versions

You can't update anyone's phone, and raising your deployment target won't solve it. Setting a minimum of iOS 18 still admits iOS 18.5, the exact version this chain targeted. Patch level is what counts here, so check it at runtime and show a soft message before sensitive web content:

import Foundation

// Example floor. Review it against Apple's security releases page.
func isBelowSecurityFloor() -> Bool {
    let v = ProcessInfo.processInfo.operatingSystemVersion
    let current = (v.minorVersion, v.patchVersion)

    switch v.majorVersion {
    case 27...: return false
    case 26:    return current < (3, 1)
    case 18:    return current < (7, 6)
    case 16:    return current < (7, 16)
    default:    return true
    }
}

The 18.7.6 and 26.3.1 floors come from iVerify's advice during the DarkSword coverage in March. Apple's security releases page currently lists iOS 27.2 and 26.7.2 from October 26, plus iOS 16.7.16 from May as the newest release for iPhone 8 and iPhone X. Those phones can go no further than iOS 16, so a message there should be informative and never blocking.

Keep the wording plain, something like "Your iPhone is missing security updates. Update in Settings before signing in here." Let people continue. For an app used by people likely to be targeted, such as journalists or staff in conflict regions, WKWebpagePreferences.isLockdownModeEnabled can turn on Lockdown Mode for a web view even when the device setting is off. Google suggested Lockdown Mode for people who can't update when it wrote up DarkSword. Test the page with it on, because it can change how a site behaves.

Step 7: look at your own test devices

This one gets missed. Plenty of iOS developers keep an old iPhone on iOS 16 or an early iOS 18 build to test their minimum deployment target. That device is the exact target profile for these chains. If it is signed in to your personal email, your Apple Account or App Store Connect, and you browse on it, it holds more than a test device should.

Use a separate Apple Account on old test hardware. Don't browse the open web on it, and don't sign in to work mail or admin tools there. CISA gave US federal agencies until November 18, 2026 to deal with CVE-2024-27834, a bug Apple patched back in 2024.

The same problem in your native dependencies

An analytics SDK in your Swift package list has the same shape as a tag on a page. The vendor's code runs with your app's permissions, and a new version can change what it does. Commit Package.resolved, read release notes before you bump a version, and remove SDKs nobody reads the data from. A smaller dependency list also makes App Store privacy details easier to keep accurate.

A one-hour review you can run today

  1. Run the repo search and list every web surface with its URL and owner.
  2. Run the console snippet on each logged-in or payment page and write down the hosts.
  3. Remove every tag that nobody can justify, and keep login and checkout free of third-party scripts.
  4. Add a report-only CSP to your site and pin hashes for files you control.
  5. Move display-only pages to SFSafariViewController.
  6. Add App-Bound Domains and a rule list to the web views you keep.
  7. Check the origin in every message handler, and remove any bridge call that returns a token.
  8. Confirm isInspectable is only on in debug builds.
  9. Shorten web session lifetimes on the server.
  10. Add the OS floor message and put it on your calendar to review after each Apple security release.
  11. Sign your old test devices out of anything that matters.

If you ship Android as well, the same site review applies, and the Chrome range in the report (139 to 145, except 143) tells you which browser versions the chain looked for.

A kit for repeating the review

A review like this is easy to do once and easy to forget by the next release. If you use Claude Code on an iOS project, Claude Code + Xcode: The Production iOS Workflow Kit is a paid kit from this blog's author. It has copy-ready templates, including a privacy and security rule, six commands such as /ios-review and /ios-release, and ten iOS agents. It gives Claude Code a repeatable workflow, and its own page says it can't guarantee security or App Store approval, so you still review the plan and the diff.

Sources