Is Adobe Acrobat: PDF edit, convert, sign tools safe?
Adobe Acrobat is medium risk. Acrobat's background worker checks every tab's hostname, not just PDF pages, against a remote list of 420 education domains. Matches go to storage key "cvEngSt" and to Adobe as a learner/non_learner tag, kept 365 days, no consent prompt.
Who publishes itAdobe, Inc. - 11 other listings from the same operator, none carrying a finding
Adobe, Inc. - 11 other listings from the same operator, none carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
10 other listings published from this account, 940k+ users between them, none of them carrying a finding.
Same operator - 1 listing
Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.
Shared hosts - 2 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Adobe Acrobat classifies you as learner or non-learner from every page you load
Acrobat's background worker checks every tab's hostname, not just PDF pages, against a remote list of 420 education domains.
Matches go to storage key "cvEngSt" and to Adobe as a learner/non_learner tag, kept 365 days, no consent prompt.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You finish loading a page, any website, in any tab.
No PDF needs to be present, and you do not need to open the extension.
The extension's background worker reads the page's hostname and checks it against a list of education-related domains that Adobe serves from its own CDN.
If the hostname is on the list, the extension writes it into local storage and re-runs a learner / non-learner classification for this install.
Observed during dynamic analysis: 420 entries in the education/LMS category, each with a match type (domain, keyword, pattern or regex) and a character-substituted value, plus a rules object carrying uniqueDomainThreshold=3, activeDayThreshold=4, engagementSpanDays=4 and windowDays=28.
The navigation listener and the per-URL handler, as shipped
The listener registration
sw_modules/student-detection/StudentDetectionModule.jschrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo?.status === "complete" && tab?.url) { this.processUrl(tab.url).catch((err) => { loggingApi.warn({ message: "Browser student detection failed for tab URL", error: err?.message || err?.stack, }); }); }});The per-URL handler
sw_modules/student-detection/StudentDetectionModule.jsasync processUrl(url) { if (!(await this.isEnabled())) return; // floodgate flag dc-cv-browser-student-detection if ((await getStudySpaceExperienceFromPreference()) === false) return; if (!(await isBrowserStudentDetectionAllowed())) return; // false for admin-deployed installs const hostname = HostnameNormalizer.hostnameFromUrl(url); // every completed navigation, any site if (!hostname) return; const config = await this.getConfig(); // remote content-signal-rules.json if (!config) return; let state = await this.windowStore.getState(); // chrome.storage.local key "cvEngSt" const detectionConfig = await getStudentDetectionConfig(); if (StudentClassifier.isWithinLearnerRetention(state, detectionConfig.learnerRetentionDays)) return; const match = this.matcher.findMatch(hostname, config); state = EngagementWindowStore.pruneWindow(state, config.rules.windowDays); if (match) { state = EngagementWindowStore.recordEngagement(state, match.registrableDomain); } await this.stateManager.applyClassification(state, config, { detectionConfig });}Record of education sites visited, kept 28 days. "tg"=classification, "rtEx"=expiry date, "evSn"=already sent to Adobe.
- Location
- chrome.storage.local, key "cvEngSt" (rolling window under field "rw", keyed by UTC date)
{ "tg": "non_learner", "idTs": undefined, "rtEx": undefined, "evSn": false, "cfgV": "1.0.0", "vEn": false, "rw": { "2026-08-29": [ "b9fw.6wte9wx", "jdtmtbtg61ae9wx" ] }}The hostnames are not stored in readable form. Each character is swapped through a fixed substitution table that ships inside the extension, so the storage value looks like noise but reverses with no key.
[ "coursera.org", "khanacademy.org"]Adobe Experience Edge returns an ECID identity payload, which the extension stores under the local-storage key "ECID" and replays on subsequent analytics calls.
- Content-Type
- application/json
{ "events": [ { "xdm": { "eventType": "web.webpagedetails.pageViews", "timestamp": "2026-08-30T00:17:22.481Z", "web": { "webPageDetails": { "name": "DCBrowserExt:Student:Status:Updated", "siteSection": "Acrobat Extension", "server": "www.adobe.com" } }, "_experience": { "analytics": { "customDimensions": { "props": { "prop14": "non_learner" } } } } }, "meta": { "state": { "cookiesEnabled": true, "domain": "adobe.com" } } } ], "query": { "identity": { "fetch": [ "ECID" ] } }}Reads the extension's "cvEngSt" state out of chrome.storage.local and reverses the character-substitution table so you can see which sites were recorded, what classification is currently held for your install, when it expires, and whether it has already been reported to Adobe's analytics endpoint.
- Chrome with the extension installed
- Node.js 18+ for the offline mode
// decode-cvengst.js — decode Adobe Acrobat extension "cvEngSt" engagement state.//// The extension stores the hostnames it matched against Adobe's qualifying// list under chrome.storage.local key "cvEngSt". Hostnames are written through// a fixed character-substitution table defined in common/domain-encoding.js,// so the raw storage value is not human-readable. This script reverses it.const PLAIN = "abcdefghijklmnopqrstuvwxyz0123456789.-";const CIPHER = "tubg6lxdq8jc1m9-vw.pfhskar0oi5zn372ye4";const DECODE = {};for (let i = 0; i < PLAIN.length; i += 1) DECODE[CIPHER[i]] = PLAIN[i];function decodeDomain(token) { return String(token) .toLowerCase() .split("") .map((ch) => DECODE[ch] || ch) .join("");}function decodeState(state) { const parsed = typeof state === "string" ? JSON.parse(state) : state; const window = parsed && parsed.rw ? parsed.rw : {}; const days = {}; for (const [dayKey, tokens] of Object.entries(window)) { if (Array.isArray(tokens)) days[dayKey] = tokens.map(decodeDomain); } return { classification: parsed && parsed.tg ? parsed.tg : "(none)", identifiedAt: parsed ? parsed.idTs : undefined, retentionExpiresAt: parsed ? parsed.rtEx : undefined, reportedToAnalytics: Boolean(parsed && parsed.evSn), rulesVersion: parsed ? parsed.cfgV : undefined, enterpriseInstall: Boolean(parsed && parsed.vEn), rollingWindow: days, };}// --- Browser use -----------------------------------------------------------// Paste everything above into the service-worker console of the extension// (chrome://extensions -> Adobe Acrobat -> "service worker"), then run://// chrome.storage.local.get("cvEngSt", (r) => console.log(decodeState(r.cvEngSt)));//// --- Node use --------------------------------------------------------------// Save the raw cvEngSt value to a file and run: node decode-cvengst.js state.jsonif (typeof process !== "undefined" && process.argv && process.argv[2]) { const fs = require("node:fs"); console.log(JSON.stringify(decodeState(fs.readFileSync(process.argv[2], "utf8")), null, 2));}if (typeof module !== "undefined") module.exports = { decodeDomain, decodeState };- 1Open chrome://extensions, enable Developer mode.
- 2Click Acrobat's "service worker" link.
- 3Paste the script into the console.
- 4Run: chrome.storage.local.get("cvEngSt", r=>console.log(decodeState(r.cvEngSt))).