July 22, 2026
Unpacking the Prisma Browser extension with a 32-byte table
Prisma Browser is Palo Alto Networks’ Chromium-based enterprise browser, sold as part of Prisma SASE and shipped as a desktop browser, a…

By user32
10 min read
Prisma Browser is Palo Alto Networks' Chromium-based enterprise browser, sold as part of Prisma SASE and shipped as a desktop browser, a browser extension, and a mobile app. It began as the Talon Enterprise Browser, built by the Israeli startup Talon Cyber Security, which Palo Alto bought in late 2023. It carried the name Prisma Access Browser for a while after that, which is still the term in most of the documentation URLs and older collateral. The product enforces data-loss, access, and web-security policy inside the client, including on devices the company does not manage, so web and SaaS activity can be controlled without a VPN. Most of that enforcement lives in a bundled browser extension, and on disk that extension ships as scrambled binary instead of readable JavaScript.
I took it apart because I wanted a hard target and I was curious what the extension actually contained. The work split in two: recover the extension source and prove the recovery was complete, then read what the recovered code hooks into.
This covers one local install, statically. The full descramble table and a working unpacker are left out.
What was analyzed
Field Value Product Prisma Browser, build 149.26.3.156 Extension Prisma Browser Extension, version 1.6861.0 Recovered tree 649 files, about 44.9 MB (523 JS bundles, 46 HTML, 41 PNG, a few others)
No usable .map files shipped in the tree. Reading the code meant working from the manifest, the file inventory, the small injected scripts, string tables, schema fragments, and targeted excerpts from minified bundles. background.js alone is 6.4 MB.
Recovering the source
Three formats that looked alike
The install holds three sets of scrambled files that all look like encrypted browser state at first glance. Separating them was most of the work.
- The installed extension runtime files, encrypted at rest per Chromium hash block.
- The downloaded bundled CRX wrapper, scrambled with a static native transform.
- The browser's Talon/KDK/DPAPI-style local-state encryption, covering the password store, prefs, DOM and value storage, and sync.
chrome.dll contains strings and callsites for all three, which makes it easy to spend a day on the wrong one. The source came out of the second path. The downloaded CRX carries the same extension payload as the installed runtime behind a reversible transform, so the profile-bound encryption on the installed files never had to be broken.
The file sizes give it away
The installed runtime files carry ordinary extension filenames such as 10051.js, but the payloads are high-entropy binary full of null bytes and match no archive or script format. Their sizes follow one formula.
container size = 16,384 + block_hash_count * 4,112container size = 16,384 + block_hash_count * 4,112That maps onto _metadata/computed_hashes.json, which Chromium ships with the extension and which stores expected SHA-256 hashes of each file's plaintext in 4,096-byte blocks. A one-block file comes to 16,384 + 1*4,112 = 20,496 bytes. A thirteen-block file comes to 69,840. The record count tracks Chromium's plaintext hash blocks, so the runtime files are structured containers keyed to those blocks, and computed_hashes.json can validate any plaintext tree recovered later. The second half of that made the eventual result checkable.
A plaintext copy in the cache
Before finding the wrapper transform, I looked for plaintext already sitting in local browser caches and found one body: background.js in the Service Worker ScriptCache, at offset 25, 6,414,165 bytes, SHA-256 4d67…14d7. It validated against Chromium's block hashes.
That proved a decrypted body existed locally and gave me an independent copy of background.js to check any later recovery against. It yielded one file plus some already-plaintext assets, and digging through cache artifacts until enough code falls out is not a method I wanted to rely on.
Finding the transform
Static triage of chrome.dll turns up a tempting mix of strings: TalonDecryptFileFinished, TALON_ENC_PREAMBLE, Cr24, CrOD, and Failed to read scrambled extension. The KDK/DPAPI-looking helper had call contexts in password_store, local_key_provider, encrypted_json_prefs, and sync. That is a real local-state encryption path, but nothing connected it to the extension wrapper, and treating it as the extension decryptor would have been a guess.
The extension-relevant functions were elsewhere. The reader at 0x18346bb60 checks for Cr24 or CrOD magic, requires version 3, bounds a metadata-size field, streams the body in 4,096-byte chunks, and runs a BoringSSL digest and signature path. So the native side expects a recognizable CRX container at some point. The downloaded .crx does not start with Cr24. It starts with 4f 50 5c 6d.
The string that resolved it was Failed to read scrambled extension. Its function at 0x183142360 runs a loop over the read buffer, XORing every byte against a 32-byte table indexed by i & 0x1f.
for (size_t i = 0; i < buffer_len; i++) {
buffer[i] ^= table[i & 0x1f];
}for (size_t i = 0; i < buffer_len; i++) {
buffer[i] ^= table[i & 0x1f];
}The same table serves every install and sits in the binary that reads it, so the wrapper comes apart without key material, profile state, or a runtime broker. I recorded the table by address, length, and hash, and it appears here masked.
table VA: 0x18f6d6394
table length: 32 bytes
table sha256: 10bd2f04884927b111badcb5a19dee79acd10987cb6c5cee799d705f4a1187ef
table[0:8]: 0c 22 6e 59 64 76 a3 0b
table[8:32]: [redacted]table VA: 0x18f6d6394
table length: 32 bytes
table sha256: 10bd2f04884927b111badcb5a19dee79acd10987cb6c5cee799d705f4a1187ef
table[0:8]: 0c 22 6e 59 64 76 a3 0b
table[8:32]: [redacted]Checking the header
Applying only the first eight bytes of the table to the wrapper produces the magic the reader wants.
scrambled mask result ASCII
0x4f ^ 0x0c = 0x43 C
0x50 ^ 0x22 = 0x72 r
0x5c ^ 0x6e = 0x32 2
0x6d ^ 0x59 = 0x34 4scrambled mask result ASCII
0x4f ^ 0x0c = 0x43 C
0x50 ^ 0x22 = 0x72 r
0x5c ^ 0x6e = 0x32 2
0x6d ^ 0x59 = 0x34 4With the full table applied locally, the header parses as CRX3.
43 72 32 34 -> "Cr24"
03 00 00 00 -> version 3
45 02 00 00 -> header size 0x245 = 581
50 4b 03 04 -> ZIP local file header at offset 593 (12 + 581)43 72 32 34 -> "Cr24"
03 00 00 00 -> version 3
45 02 00 00 -> header size 0x245 = 581
50 4b 03 04 -> ZIP local file header at offset 593 (12 + 581)Applying the same prefix to an installed runtime file, 10051.js, produces nothing recognizable. That negative control is what rules out the runtime containers being the same scrambled CRX split across files.
Validating the extracted files
A correct-looking header proves little, since a wrong transform can still get lucky at offset zero. The success condition was the whole chain: version, header size, ZIP offset, ZIP signature, then per-file validation against Chromium's metadata.
Validation ran file by file and block by block against Chromium's expected plaintext hashes, covering background.js across its full length and 13048.js across 123 blocks. Extraction checked for path traversal. Five ZIP entries fall outside computed_hashes.json and are the ones you would expect: manifest.json, the three icons, and verified_contents.json. The recovered background.js hash matched the ScriptCache body found earlier, which is a second source agreeing on the same 6.4 MB file.
downloaded CRX 11c0457fedc30b72389b1653c3c5754050cad1377135e692556016206a0dd070
XOR table (32B) 10bd2f04884927b111badcb5a19dee79acd10987cb6c5cee799d705f4a1187ef
descrambled CRX 1715ff2d90003a43e090ddcd47c10466aa22ea250a87fef769d93a0ed9b20a9c
ZIP payload 732c981767771e2cef9248d7f26b9bc8ea2b603150f3d73bac48fea1cb70ff49
background.js 4d6764f61bc03a98fec8448647d9236c9cc8c3734e1deeb4e4ef2f104ea314d7
files written 649
computed_hashes 644 valid / 0 invalid / 0 missingdownloaded CRX 11c0457fedc30b72389b1653c3c5754050cad1377135e692556016206a0dd070
XOR table (32B) 10bd2f04884927b111badcb5a19dee79acd10987cb6c5cee799d705f4a1187ef
descrambled CRX 1715ff2d90003a43e090ddcd47c10466aa22ea250a87fef769d93a0ed9b20a9c
ZIP payload 732c981767771e2cef9248d7f26b9bc8ea2b603150f3d73bac48fea1cb70ff49
background.js 4d6764f61bc03a98fec8448647d9236c9cc8c3734e1deeb4e4ef2f104ea314d7
files written 649
computed_hashes 644 valid / 0 invalid / 0 missingWhat stayed unsolved
The installed runtime containers, with their 16,384-byte header and 4,112-byte records, were characterized but not decrypted in place. Their cipher parameters, key material, profile binding, and any runtime-broker step are still open. That does not weaken the recovery, since every hash-tracked file in the installed metadata validated against plaintext taken from the downloaded CRX. How the browser re-encrypts the extension after install is a separate question that this work does not answer.
What the extension does
Once the tree is readable, the extension turns out to hold a browser-resident policy layer: injected into every page at document_start, holding broad browser privileges, and backed by data-loss classification, prompt collectors for named AI products, credential interception, screen and session recording, an event schema, extension-risk checks, and cloud scanning endpoints. For an enterprise secure browser that is the expected shape. What decryption changes is that the shape can be inspected, because provider names, category names, event contexts, endpoint builders, and policy UI strings all sit in the client.
The manifest
The manifest fixes the extension's position before any minified code is interpreted. The main content script loads early on every page.
{
"run_at": "document_start",
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"]
}{
"run_at": "document_start",
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"]
}document_start puts it in the page lifecycle ahead of the page's own application code. The permissions requested are wide.
["management","downloads","tabs","talon","history","proxy","webRequest",
"webRequestBlocking","offscreen","identity","identity.email","cookies",
"browsingData","clipboardRead","declarativeNetRequest","scripting",
"desktopCapture","system.cpu","system.memory","privacy","nativeMessaging"]["management","downloads","tabs","talon","history","proxy","webRequest",
"webRequestBlocking","offscreen","identity","identity.email","cookies",
"browsingData","clipboardRead","declarativeNetRequest","scripting",
"desktopCapture","system.cpu","system.memory","privacy","nativeMessaging"]Host access covers everything.
["<all_urls>", "chrome://*/*"]["<all_urls>", "chrome://*/*"]proxy, webRequestBlocking, privacy, nativeMessaging, desktopCapture, clipboardRead, cookies, history, and management together with <all_urls> is close to the maximum a Chromium extension can ask for. The manifest also declares a bridge scoped to Strata Cloud Manager, injects webstore.js into the Chrome Web Store, and names 18 externally connectable extension IDs, so the extension is built to exchange messages with a fixed set of other extensions.
Sensitive-data categories in the client
The inventory turned up 457 distinct sensitive-data category strings, repeated across background.js, content.js, approvedUrl.js, dashboardBridge.js, and diagnostics.js. They cover conventional PII, regional address formats, financial identifiers, healthcare categories, and developer and cloud credentials.
e.CREDIT_CARD_NUMBER="CREDIT_CARD_NUMBER",
e.API_ACCESS_TOKEN="API_ACCESS_TOKEN",
e.SECRET_KEY___AWS_ACCESS_KEY_ID="SECRET_KEY___AWS_ACCESS_KEY_ID",
e.SECRET_KEY___AWS_SECRET_ACCESS_KEY="SECRET_KEY___AWS_SECRET_ACCESS_KEY",
e.SECRET_KEY___GOOGLE_CLOUD_ACCESS_KEY_ID="SECRET_KEY___GOOGLE_CLOUD_ACCESS_KEY_ID",
e.SECRET_KEY___RSA_PRIVATE_KEY="SECRET_KEY___RSA_PRIVATE_KEY"e.CREDIT_CARD_NUMBER="CREDIT_CARD_NUMBER",
e.API_ACCESS_TOKEN="API_ACCESS_TOKEN",
e.SECRET_KEY___AWS_ACCESS_KEY_ID="SECRET_KEY___AWS_ACCESS_KEY_ID",
e.SECRET_KEY___AWS_SECRET_ACCESS_KEY="SECRET_KEY___AWS_SECRET_ACCESS_KEY",
e.SECRET_KEY___GOOGLE_CLOUD_ACCESS_KEY_ID="SECRET_KEY___GOOGLE_CLOUD_ACCESS_KEY_ID",
e.SECRET_KEY___RSA_PRIVATE_KEY="SECRET_KEY___RSA_PRIVATE_KEY"On its own a category string could be a UI label. What makes it part of the data model is that these strings sit beside typed event contexts (DLPContext, ClipboardContext, FileContext, GenAIPromptContext) and beside endpoint construction that routes scans to Palo Alto cloud DLP.
cloudScanningBaseUrl: `${s(e)}/v1/inline`
// s(e) resolves to, e.g.:
"https://api.dlp.paloaltonetworks.com"
"https://apigov.dlp.pubsec-cloud.paloaltonetworks.com"cloudScanningBaseUrl: `${s(e)}/v1/inline`
// s(e) resolves to, e.g.:
"https://api.dlp.paloaltonetworks.com"
"https://apigov.dlp.pubsec-cloud.paloaltonetworks.com"The routing function branches by region, with separate paths for US, GOV, EU (emea-oauth), AU, JP, and a staging host. That gives a specific place to check whether scanned content follows a tenant's expected region. Where each detection actually runs, locally or in the cloud, is decided at runtime and is not fixed in the assets.
DLP events carry a set of context fields. The recovered ones include live-scanning state, a Microsoft Information Protection matched label, scan engine identity, matched categories, source URL, source element selector, byte length, and a trace ID.
{json:"liveScanning",...}, {json:"mipMatchedLabel",...},
{json:"scanEngine",...}, {json:"sensitiveDataCategories",...},
{json:"sourceElementSelector",...}, {json:"sourceUrl",...}, {json:"traceId",...}{json:"liveScanning",...}, {json:"mipMatchedLabel",...},
{json:"scanEngine",...}, {json:"sensitiveDataCategories",...},
{json:"sourceElementSelector",...}, {json:"sourceUrl",...}, {json:"traceId",...}File scanning separates its failure states, among them content-based, malicious, scan-blocked, cloud-scan timeout, file-size, file-type, and service-unavailable. An operator can tell a policy match apart from a scanner that timed out.
AI prompt handling
The extension hooks named AI products rather than blocking their domains or watching every text box on the web. The files involved are genAIPromptControl.js, openaiInjected.js, microsoftInjected.js, slackInjected.js, copilotWebSocketHandler.js, and webSocketPromptCollectorBridge.js.
genAIPromptControl.js names its targets and the input surfaces it watches.
var t_=function(_){return _.ChatGPT="chatgpt",_.Gemini="gemini",
_.Claude="claude",_.Perplexity="perplexity",_}({});
const s_='textarea, [contenteditable="true"], [role="textbox"]',
S_=["send","submit"],
C_=["retry","regenerate","try again","resend","rewrite"];var t_=function(_){return _.ChatGPT="chatgpt",_.Gemini="gemini",
_.Claude="claude",_.Perplexity="perplexity",_}({});
const s_='textarea, [contenteditable="true"], [role="textbox"]',
S_=["send","submit"],
C_=["retry","regenerate","try again","resend","rewrite"];The Copilot path wraps WebSocket itself. It inspects outbound sends, pulls prompt-like fields out of JSON payloads, forwards them, and can suppress the send.
const t=["text","content","prompt","query","userInput","userMessage"];
e.send=function(...e){
const s=e[0], i="string"==typeof s ? extractPromptLikeText(s) : void 0;
if("string"==typeof i){
n.postMessage({source:"talon-ws-prompt-collector",
type:"promptCollectionOverWS", text:i, wsUrl:r,
pageUrl:n.location.href}, "*");
if(n.__CopilotWebSocketPromptCollectorBlockSend__) return;
}
return c.apply(this,e)
}const t=["text","content","prompt","query","userInput","userMessage"];
e.send=function(...e){
const s=e[0], i="string"==typeof s ? extractPromptLikeText(s) : void 0;
if("string"==typeof i){
n.postMessage({source:"talon-ws-prompt-collector",
type:"promptCollectionOverWS", text:i, wsUrl:r,
pageUrl:n.location.href}, "*");
if(n.__CopilotWebSocketPromptCollectorBlockSend__) return;
}
return c.apply(this,e)
}A bridge in page context type-checks those messages and relays them to the extension runtime. Three small scripts pull tenant and account identity out of the pages themselves: the OpenAI account id from client-bootstrap JSON, the Microsoft user email from O365ShellContext, and the Slack workspace domain from localConfig_v2 in localStorage. Policy can ask which tenant, workspace, or account a prompt belongs to, and read the prompt text, across DOM text boxes, send and retry buttons, and WebSocket frames.
Screen capture and session recording
The files are screenCapturePreventionDialog.js, screenshotBlock.js, watermark.js, sessionRecorder.js, recordingIframe.js, and eventRecorder.js, with recordingIframe.html declared as a sandboxed page. The manifest grants desktopCapture.
The screen-capture dialog has four states: blocked, request permission, admin approved, and share anyway. A runtime message sits behind the exception path.
chrome.runtime.sendMessage({ type: A.Go.ScreenshotProtectionBypass, tabId: u, tabUrl: w })chrome.runtime.sendMessage({ type: A.Go.ScreenshotProtectionBypass, tabId: u, tabUrl: w })The recording side is an rrweb-style DOM recorder with node mirrors, mutation observation, canvas and cross-origin-iframe options, and snapshot and checkout controls. It masks input values instead of capturing them raw.
function f({element:e,maskInputOptions:_,tagName:t,type:o,value:n,maskInputFn:s}) {
let i=n||"";
const r=o&&P(o);
return (_[t.toLowerCase()]||r&&_[r]) && (i=s?s(i,e):"*".repeat(i.length)), i
}function f({element:e,maskInputOptions:_,tagName:t,type:o,value:n,maskInputFn:s}) {
let i=n||"";
const r=o&&P(o);
return (_[t.toLowerCase()]||r&&_[r]) && (i=s?s(i,e):"*".repeat(i.length)), i
}When recording starts, which fields get masked by default, and where recordings go are policy decisions and are not fixed in the assets. The assets establish that a full DOM recorder ships inside the extension.
Password manager and WebAuthn
The password-manager surface carries message names for the whole credential lifecycle: GetSecretsMessage, CreateSecretMessage, DeleteSecretMessage, ImportPasswordsMessage, ExportPasswordsMessage, PromotePasswordToVaultMessage, ShareSecretMessage, and others. background.js reads and clears Chrome's own password-saving setting through chrome.privacy.services.passwordSavingEnabled, which fits a product steering users off browser password saving and into a managed vault.
webauthnInterceptor.js hooks the credential API directly.
const p_=navigator.credentials;
Object.defineProperty(p_,"create",{...})
Object.defineProperty(p_,"get",{...})const p_=navigator.credentials;
Object.defineProperty(p_,"create",{...})
Object.defineProperty(p_,"get",{...})It reads publicKey ceremony options, can refuse the ceremony by policy, and posts ceremony data back into page context.
if(I.blocked){
const _=I.shouldRegister
? "Passkey registration is missing MFA credentials"
: "Passkey registration is blocked by policy";
throw new DOMException(_,"NotAllowedError")
}
// elsewhere:
window.postMessage({ type:d_, policyResult:E, ceremonyData:{
timestamp:Date.now(), url:window.location.href,
publicKeyOptions:_, credential:A.toJSON() } }, window.location.origin)if(I.blocked){
const _=I.shouldRegister
? "Passkey registration is missing MFA credentials"
: "Passkey registration is blocked by policy";
throw new DOMException(_,"NotAllowedError")
}
// elsewhere:
window.postMessage({ type:d_, policyResult:E, ceremonyData:{
timestamp:Date.now(), url:window.location.href,
publicKeyOptions:_, credential:A.toJSON() } }, window.location.origin)If another extension has already locked navigator.credentials, the interceptor logs the conflicting descriptor rather than assuming its hook took. Passkey registration and login are visible to extension-side policy, with user-facing events including PasskeyLoginBlocked, IdpLoginBlocked, and AppLoginBlocked. The ceremonyData payload carries a timestamp, the page URL, the full publicKeyOptions, and the serialized credential.
The event schema
The extension bundles OpenTelemetry collector code for logs, metrics, and traces, posting to an otel-v2 gateway path. The schema defines 18 typed contexts.
browserExtension, certificate, classification, clipboard, content, device,
dlp, extensionRisk, file, genAIPrompt, incident, network, page,
passwordManager, process, rceExploit, tampering, userbrowserExtension, certificate, classification, clipboard, content, device,
dlp, extensionRisk, file, genAIPrompt, incident, network, page,
passwordManager, process, rceExploit, tampering, userEvent names cover file upload and download, clipboard copy and paste, prompt scanning and blocking, printing, malicious-page blocks, extension risk and policy blocks, external application launch, tab-capture sharing, and endpoint-profiler traces. Typed contexts exist for sensitive content, credential workflows, local process state, and installed extensions, so the events this schema can carry go well past page metadata. Which contexts get populated on a given event depends on tenant policy; the field definitions all ship in the client.
Extension control
The management permission, webstore.js injected into the Chrome Web Store, the externally connectable IDs, and the extensionRisk context together make extension installation something the product decides on. The event strings cover the path from ExtensionStartScan to ExtensionScanDecision to ExtensionBlockedByRisk and ExtensionBlockedByPolicy, with separate install outcomes for the browser and the standalone extension in ExtensionInstallSuccessPB and ExtensionInstallSuccessPABX. Scoring inputs live server-side. The client carries the scan trigger, the decision event, and the block paths.
Device posture and services
posture.js models device management state, disk encryption, firewall, screen lock, password policy, system integrity, remote desktop connections, and a CrowdStrike ZTA score. Browser decisions can read those values.
The endpoint builders name the services the browser depends on. Enforcement and telemetry traffic goes to talon-sec.com rather than paloaltonetworks.com, which matters when scoping egress allowlists or TLS inspection.
webriskBaseUrl: "https://riskapi.talon-sec.com"
releasesBaseUrl: "https://releases.talon-sec.com"
sentryBaseUrl: "https://gateway.gs.talon-sec.com/sentry"
pushMessagesUrl: `${gateway}/push`
tlsTrustConfigBaseUrl: o("pki",e)
endpointProfilerBaseUrl: `${t}/endpoint-profiler`
smbManagerUrl: `${t}/smb-manager`
csZtaJWKUrl: "https://assets-public.falcon.crowdstrike.com/zta/jwk.json"
// URL reputation:
new URL("https://classifier-auf.talon-sec.com/url/lookup/talon")webriskBaseUrl: "https://riskapi.talon-sec.com"
releasesBaseUrl: "https://releases.talon-sec.com"
sentryBaseUrl: "https://gateway.gs.talon-sec.com/sentry"
pushMessagesUrl: `${gateway}/push`
tlsTrustConfigBaseUrl: o("pki",e)
endpointProfilerBaseUrl: `${t}/endpoint-profiler`
smbManagerUrl: `${t}/smb-manager`
csZtaJWKUrl: "https://assets-public.falcon.crowdstrike.com/zta/jwk.json"
// URL reputation:
new URL("https://classifier-auf.talon-sec.com/url/lookup/talon")The extension's update_url points at extensions.talon-sec.com, and the manifest carries a custom talon permission.
Why the code was readable
The two protections on these assets do different work. The wrapper scramble uses one static table, identical for every install, stored in the binary that reads it, so it cannot keep the contents secret from anyone willing to open a decompiler. Tampering gets caught elsewhere: the native reader runs a BoringSSL digest and signature check, verified_contents.json and computed_hashes.json ship with the extension, and a 211-byte JWT sits beside the CRX on disk. What the scramble buys is that a downloaded .crx is not a valid CRX to anything that finds it, and cannot be unpacked, edited, and repacked without working out the transform first. The installed runtime containers are a separate protection again, and they did not come apart the same way.
The extension was readable at all because of how the product has to work. Classifying sensitive data on the client means shipping the category list to the client. Reading prompts before they are sent means knowing which providers to hook and which DOM and WebSocket shapes to read. Routing scans by region means holding the regional endpoints. Whatever the browser decides at the endpoint, the endpoint has to be told first, which puts the decision logic on a machine the user controls. Scrambling the assets sets the price of reading that logic, and in this build the price was one decompiled function and a 32-byte table.