Series · 3 partsBrowser-to-Native Trust BoundariesPart 1 · You are here
- 1The Extension Was Sandboxed. The Native Host Was Not.You are here
- 2The Page Never Had Permission. The Extension Did.
- 3The Package Was Signed. The Update Was Still a Security Decision.
The browser boundary in 60 seconds
A browser extension can be correctly packaged, signed, reviewed, and isolated from a web page while still exposing a dangerous path into the operating system.
Native Messaging is intentionally powerful. A browser launches a registered native application as a separate process and exchanges length-prefixed JSON messages over standard input and output. The native host can then reach files, devices, credentials, sockets, child processes, and local management interfaces that ordinary web content cannot.
That design does not create a vulnerability by itself. The vulnerability appears when several individually valid decisions are treated as one authorization:
- a page is allowed to run a content script;
- the content script may message the extension service worker;
- the service worker may connect to a registered native host;
- the native host manifest allows that extension ID;
- the host accepts a JSON operation;
- the operating system permits the resulting effect.
None of those decisions proves that the original page, frame, user gesture, object, path, or operation was authorized.
The central research question is:
Where does web-controlled intent become a native operating-system effect, and which component makes the final authorization decision?
This write-up builds a repeatable method for answering that question without turning a local proof into an unsafe real-world exploit.
One feature, five different principals
Chrome documents content scripts as isolated from the page’s JavaScript environment. That is an execution isolation property. Chrome’s own messaging guidance separately says content scripts are less trustworthy than the extension service worker and that messages from them should be treated as attacker-crafted.
The distinction matters. An isolated content script may still read page-controlled DOM values,
receive window.postMessage events, or be triggered on a compromised origin. If the service worker
forwards its message without validating sender and the requested operation, the isolated world
becomes a relay rather than a security boundary.
What Native Messaging actually guarantees
The platform provides useful controls, but each control answers a narrow question.
| Mechanism | What it establishes | What it does not establish |
|---|---|---|
Extension nativeMessaging permission | The extension may use the Native Messaging API | That every extension component should invoke every native operation |
Host allowed_origins / allowed_extensions | The named extension ID may start the host | Which page, frame, tenant, or user workflow originated the request |
| Isolated content-script world | Page JavaScript cannot directly read the content script’s variables | That data derived from the page is trustworthy |
sender.id and sender.url | Browser-observed sender context for a message | That an operation, path, or object is authorized |
| Length-prefixed JSON | A transport format with message boundaries | A safe schema, semantic authorization, replay protection, or resource limits |
| Native process UID | The OS identity under which the host executes | That the browser caller should inherit all of that identity’s access |
| Browser extension review | A distribution and policy checkpoint | The correctness of the separately installed native application |
Chrome launches the native host as a separate process and communicates over stdin/stdout. Messages are UTF-8 JSON prefixed by a 32-bit length in native byte order. Chrome currently documents a 64 MiB browser-to-host limit and a 1 MiB host-to-browser limit. Those are transport ceilings, not safe application limits. A host expecting a 2 KiB command envelope should enforce 2 KiB.
Chrome passes the calling extension origin as the first host argument. Firefox uses a related but different contract and host-manifest key. Cross-browser compatibility code must not silently collapse those identities or assume serialization behavior is identical: Chrome extension messaging uses JSON serialization while other implementations may use structured clone.
The vulnerability pattern: origin laundering
The most common failure is not a memory-corruption bug. It is origin laundering:
untrusted page value
→ content-script object
→ extension message
→ native JSON request
→ local file or process effect
At every arrow, the next component sees a request from a more trusted principal:
- the service worker sees its own content script;
- the browser sees an installed extension;
- the native host sees an allowed extension origin;
- the operating system sees a local native process.
If the original page and resource authorization are not carried and revalidated, trust increases while context disappears.
This creates several recurring vulnerability classes:
| Class | Dangerous design | Resulting risk |
|---|---|---|
| Confused deputy | Any content script message is forwarded to the host | A page borrows extension and host authority |
| Missing object authorization | The caller supplies an arbitrary path, device ID, profile, or account | Cross-user or cross-tenant access |
| Argument injection | The host concatenates message fields into a shell or command line | Native code execution under the host identity |
| Path traversal / link following | A relative path is joined and opened without final-object validation | Read or write outside the intended root |
| Replay | A privileged request has no nonce, expiry, or state binding | A captured message repeats a prior effect |
| Parser asymmetry | Browser and host coerce types or sizes differently | Validation occurs on a different object than execution |
| Lifecycle confusion | Long-lived ports retain state across navigation or frame replacement | New page context inherits old authorization |
| Installation boundary failure | A writable manifest or host path redirects the registered binary | Local persistence or privilege crossing |
A bounded synthetic lab
The lab needs no production extension and no third-party target. It uses:
- one unpacked Manifest V3 extension;
- one local page at
https://lab.invalidor a loopback test origin; - one native host registered only for the lab extension ID;
- one canary directory containing non-sensitive test files;
- one operation that reads a named canary;
- one independent observer that records which file was actually opened.
The unsafe service worker below illustrates the trust loss:
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type !== "native") return;
chrome.runtime.sendNativeMessage(
"com.jankesec.native_lab",
request.payload,
(response) => sendResponse(response),
);
return true;
});
It checks a routing label and nothing else. It does not validate the sending extension component, page origin, frame, operation, object, size, state, or user gesture.
A deliberately vulnerable synthetic host makes the second failure explicit:
from pathlib import Path
def handle(message):
if message.get("op") == "read_file":
return {"data": Path(message["path"]).read_text()}
return {"error": "unknown operation"}
This is not a complete exploit. It is a minimal authorization oracle. The positive test requests
/tmp/nmh-lab/canary.txt. The negative test requests a different canary outside the approved root.
The finding exists only if the second request reaches the host and the independent file observer
confirms that the wrong object was opened.
Do not demonstrate impact with real browser data, SSH keys, password stores, or user documents. The vulnerability is the unauthorized object transition, not the sensitivity of a borrowed file.
Inventory before interaction
Start with static evidence. Record both extension and host installation state before sending a message.
Extension inventory
| Field | Evidence |
|---|---|
| Extension ID and install source | Browser extension page, enterprise policy, signed package metadata |
| Manifest version and update URL | manifest.json and managed-extension policy |
| Permissions | permissions, optional_permissions, host_permissions |
| Content-script reach | Match patterns, excluded matches, frames, execution world |
| External callers | externally_connectable IDs and page patterns |
| Message listeners | onMessage, onConnect, external variants, port names |
| Native operations | Every connectNative and sendNativeMessage call site |
| Sender checks | sender.id, URL origin, tab, frame, document, incognito state |
| State binding | Gesture, nonce, expiry, selected object, active tab, tenant |
Native host inventory
| Field | Evidence |
|---|---|
| Manifest location and owner | OS path or registry key, ownership and ACL |
| Host executable path | Absolute resolved path and final file identity |
| Allowed extension IDs | Exact allowed_origins or allowed_extensions values |
| Runtime identity | UID, token, integrity level, sandbox, service account |
| Parser | Length handling, UTF-8 decoding, JSON type checks, maximum size |
| Dispatcher | Operation allowlist and default-deny behavior |
| Object authorization | How resource IDs become paths, devices, or accounts |
| Process creation | Shell use, argument arrays, environment, working directory |
| Output behavior | Data classification, truncation, errors, stderr logging |
| Update path | Installer identity, signature verification, writable parent paths |
This inventory frequently closes false positives. A host may look privileged but expose only a fixed status query. Conversely, a user-level host may reach browser profiles, developer credentials, cloud CLIs, and local sockets that create a high-impact path.
Preserve the browser context in the request
The service worker is the first reliable policy point. It should turn browser-observed facts into a small request contract rather than forward a page-defined object.
const ALLOWED_ORIGINS = new Set(["https://lab.invalid"]);
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
const origin = sender.url ? new URL(sender.url).origin : "";
const validSender =
sender.id === chrome.runtime.id &&
sender.tab?.id !== undefined &&
sender.frameId === 0 &&
ALLOWED_ORIGINS.has(origin);
if (!validSender || message?.type !== "read-canary") {
sendResponse({ ok: false, error: "denied" });
return false;
}
const request = {
version: 1,
operation: "canary.read",
resource: String(message.resource),
origin,
tabId: sender.tab.id,
documentId: sender.documentId,
nonce: crypto.randomUUID(),
expiresAt: Date.now() + 5_000,
};
chrome.runtime.sendNativeMessage(
"com.jankesec.native_lab",
request,
sendResponse,
);
return true;
});
This is stronger, not complete. The native host still cannot blindly trust every field merely because the extension created it. The contract needs a fixed schema, bounded values, a short lifetime, replay tracking where effects matter, and an operation-to-resource authorization table.
Avoid using user-supplied paths in the browser contract. Use stable resource identifiers such as
canary-a and resolve them inside the host:
RESOURCES = {
"canary-a": Path("/tmp/nmh-lab/canary-a.txt"),
"canary-b": Path("/tmp/nmh-lab/canary-b.txt"),
}
def handle(message):
if set(message) != {
"version", "operation", "resource", "origin",
"tabId", "documentId", "nonce", "expiresAt",
}:
return {"ok": False, "error": "schema"}
if message["version"] != 1 or message["operation"] != "canary.read":
return {"ok": False, "error": "operation"}
path = RESOURCES.get(message["resource"])
if path is None:
return {"ok": False, "error": "resource"}
return {"ok": True, "data": path.read_text()}
For higher-impact operations, a JSON field claiming origin is not proof. Use a brokered session
established from browser-observed state, bind approval to the resolved operation and object, and
keep the native capability narrower than the host’s ambient OS identity.
Parse the transport as hostile input
A native host parser should read exactly four header bytes, reject oversized messages before allocation, read exactly the declared body, decode strict UTF-8, require a JSON object, validate types, and produce one framed response.
import json
import struct
import sys
MAX_REQUEST = 16 * 1024
def read_exact(stream, count):
chunks = bytearray()
while len(chunks) < count:
part = stream.read(count - len(chunks))
if not part:
raise EOFError("truncated native message")
chunks.extend(part)
return bytes(chunks)
def read_message():
header = read_exact(sys.stdin.buffer, 4)
length = struct.unpack("@I", header)[0]
if length == 0 or length > MAX_REQUEST:
raise ValueError("invalid message length")
payload = read_exact(sys.stdin.buffer, length)
value = json.loads(payload.decode("utf-8", errors="strict"))
if not isinstance(value, dict):
raise TypeError("message must be an object")
return value
Important parser tests include:
- zero, one-byte, truncated, and oversized headers;
- declared length shorter or longer than the actual body;
- invalid UTF-8;
- duplicate or unexpected fields;
- arrays or scalars instead of an object;
- numbers where a string identifier is expected;
- deeply nested JSON and excessive collection counts;
- multiple frames in one stream;
- clean EOF versus mid-frame EOF;
- stdout contamination by debug logs.
Debug output belongs on stderr. One accidental print to stdout corrupts the framing channel.
Authorization must survive every hop
Evidence matrix
A defensible result separates observed fact, inference, positive execution, negative control, and remaining uncertainty.
| Claim | Positive evidence | Negative control | Strong conclusion |
|---|---|---|---|
| The page can reach the extension listener | Browser-observed message with tab, frame, document, and origin | Same message from a non-matching origin is rejected | Reachability is limited to the recorded sender set |
| The extension can start the host | Host process creation correlated to request ID | Unlisted extension ID receives an access denial | Host manifest filters extension identity |
| The host exposes an operation | Valid schema returns the expected canary result | Unknown operation and extra field are rejected | Dispatcher is explicit and default-deny |
| Resource authorization works | Allowed resource ID opens the mapped canary | Traversal, absolute path, symlink, and unknown ID fail | Caller cannot select an arbitrary object through tested variants |
| Replay is controlled | Fresh nonce succeeds once | Same nonce and expired request fail with no effect | Tested privileged request is single-use within its lifetime |
| Native authority is bounded | Runtime identity and system call trace match expected object | Observer confirms protected and unrelated objects remain untouched | Demonstrated effect is limited to the tested capability |
| Cleanup is complete | Lab manifest, extension, canaries, process, and logs are removed | Reconnection attempt fails after cleanup | Test state no longer exposes the lab bridge |
Do not claim that a denied JSON response proves no effect occurred. Verify the canary, filesystem event, process tree, or device state independently.
Test matrix
| Layer | Test | Expected secure result |
|---|---|---|
| Web origin | Same message from allowed and unallowed origins | Only the exact allowed origin proceeds |
| Frame | Top frame versus embedded or stale frame | Policy rejects disallowed frame/document context |
| Extension sender | Content script, extension page, external extension, external web page | Each sender class has a separate explicit policy |
| Message schema | Missing, extra, wrong-type, nested, oversized values | Parser rejects before operation resolution |
| Operation | Known read, unknown operation, deprecated version | Only current allowlisted operations exist |
| Resource | Known ID, arbitrary path, traversal, symlink, race target | Final opened object remains inside authorized set |
| Process | Argument arrays versus shell string | No attacker-controlled shell interpretation |
| Lifecycle | Navigation, port reconnect, worker restart, browser restart | Authorization does not survive its bound context |
| Replay | Duplicate nonce and expired message | No second effect |
| Installation | Writable manifest, writable parent, redirected host path | Integrity check or ACL prevents substitution |
| Output | Oversized response, stderr noise, stdout noise, malformed JSON | Bounded failure without protocol desynchronization |
| Cleanup | Remove extension or host registration | Browser can no longer start the host |
Hardening order
Fix the highest-leverage boundary first:
- Remove unnecessary Native Messaging access. A capability that is not installed cannot be reached through a future extension bug.
- Reduce content-script and host reach. Narrow match patterns, frames, host permissions, external callers, and optional permissions.
- Validate browser-observed sender context. Do not accept page-provided origin or tab claims.
- Define a versioned operation schema. Reject unknown keys, wrong types, excessive size, and deprecated operations.
- Resolve identifiers inside the trusted component. Page-supplied paths, commands, URLs, and device handles are authority injection.
- Separate policy from execution. The native dispatcher authorizes a named operation and resource before calling a small implementation.
- Avoid shells and ambient administrator identity. Use fixed executables, argument arrays, sanitized environments, controlled working directories, and the least privileged runtime.
- Bind high-impact actions to state. User gesture, selected object, nonce, expiry, and approval must describe the same request.
- Verify the effect independently. Record the expected object change and prove that adjacent objects remained unchanged.
- Protect the installation path. Host manifests, registry keys, executable paths, update packages, and parent directories are part of the security boundary.
Detection and forensic signals
Useful telemetry exists on both sides of the boundary:
- extension installation source, ID, version, permission changes, and update events;
connectNative/sendNativeMessageoperation name and request identifier;- native host process parent, command line, origin argument, UID, executable hash, and signature;
- manifest or registry changes for Native Messaging host registration;
- child processes spawned by the host;
- file, socket, device, credential, or configuration effects attributed to the host process;
- denied operations by gate and reason;
- abnormal message size, parse failure, reconnect rate, and replay detection;
- unexpected host execution when the browser is idle or no approved workflow exists.
Logs should record identifiers and decisions, not secret payloads. Native messages may contain tokens, file content, account names, or local paths. Evidence collection must not create a second data-exposure path.
Reporting the finding
A useful finding title describes the lost boundary:
A browser content script could cause the Native Messaging host to read an object outside the approved resource set because the extension forwarded an attacker-controlled path and the host performed no object authorization.
The report should include:
- exact extension and native host versions;
- installation and runtime identity;
- reachable sender contexts;
- minimal request schema;
- approved and unauthorized canary objects;
- positive effect evidence;
- negative controls;
- maximum demonstrated impact without using real sensitive data;
- root cause at both the extension and native host;
- remediation that changes the operation contract, not only a string filter;
- cleanup proof.
Severity follows the reachable effect, not the presence of nativeMessaging. A status-only host
may be informational. A user-level host that reads developer credentials may be high impact. A
system service reachable through a broad dispatcher may cross a privilege boundary.
Final model
Native Messaging is secure when the browser extension and native host behave as one narrow, versioned authorization protocol:
browser-observed sender
→ allowed operation
→ trusted resource resolution
→ bounded native identity
→ independently verified effect
The browser sandbox remains valuable. The extension ID remains valuable. The host manifest remains valuable. But none of them authorizes an arbitrary native effect.
The bridge is trustworthy only when the original web context survives long enough for the native side to make a deliberate, testable, and observable decision.
How current is this note?
The latest source-review, content-update, or publication date is shown.
Primary public records were checked. Environment-specific behavior remains outside the claim unless separately reproduced.
