GeckoView:MediaPermission trusts child-supplied uri for camera/mic prompt origin and SitePermissions storage key (Android, variant of bmo-2003171)
Categories
(GeckoView :: General, defect, P1)
Tracking
(firefox152 wontfix, firefox153+ fixed, firefox154+ fixed)
People
(Reporter: tjr, Assigned: owlish)
References
()
Details
(5 keywords, Whiteboard: [fxdroid][adv-main153+r])
Attachments
(6 files)
|
2.20 KB,
text/html
|
Details | |
|
90 bytes,
text/plain
|
Details | |
|
6.00 KB,
patch
|
tjr
:
sec-approval+
|
Details | Diff | Splinter Review |
|
1.83 KB,
text/plain
|
Details | |
|
1.75 KB,
patch
|
tjr
:
sec-approval+
|
Details | Diff | Splinter Review |
|
48 bytes,
text/x-phabricator-request
|
RyanVM
:
approval-mozilla-beta+
tjr
:
sec-approval+
|
Details | Review |
On Android, the GeckoViewPermission JSWindowActor parent (mobile/shared/actors/GeckoViewPermissionParent.sys.mjs) forwards aMessage.data verbatim to the Java EventDispatcher for the GeckoView:MediaPermission and GeckoView:MediaRecordingStatusChanged messages. The data.uri field is supplied by the content process (the legitimate caller sends window.document.documentURI) and is never re-derived from the parent-authoritative this.browsingContext.currentWindowGlobal.documentPrincipal. GeckoSession.java passes message.getString("uri") straight to PermissionDelegate.onMediaPermissionRequest, and android-components' SitePermissionsFeature uses permissionRequest.uri.getOrigin() as (a) the lookup key into the persistent SitePermissionsStorage, (b) the storage key when persisting the user's decision, and (c) the origin string interpolated into the trusted-UI dialog title ("Allow %1$s to use your camera and microphone?").
A compromised content process can therefore send the actor a GeckoView:MediaPermission query with a forged uri pointing at any origin. If the forged origin has a stored "Remember decision → Allow" entry (e.g. https://meet.google.com), SitePermissionsFeature.handleNoRuledFlow calls permissionRequest.grant() with no prompt, MediaCallback.grant() resolves the EventCallback back into the attacker's content process, the attacker then sends GeckoView:AddCameraPermission (which the parent grants for the attacker's real principal) and completes its own getUserMedia, obtaining the camera/microphone stream with zero user interaction. If no stored grant exists, the user sees a permission dialog whose title shows the forged origin while the address bar shows the attacker's site; granting it delivers the stream to the attacker and persists the grant under the forged origin. The same actor lets content send GeckoView:MediaRecordingStatusChanged with devices: [] to suppress the recording indicator.
This is the same bug class as bmo-2003171 (sec-high, csectype-sandbox-escape); that fix removed the GeckoView:ContentPermission handler from receiveMessage but left MediaPermission and MediaRecordingStatusChanged child-reachable with their data fields un-rewritten. Desktop is not affected: browser/actors/WebRTCParent.sys.mjs:98 overwrites data.origin with this.manager.topWindowContext.documentPrincipal.origin before prompting, which is exactly the re-derivation GeckoView is missing. The vulnerable path is gated to MOZ_WIDGET_ANDROID and could not be exercised on the Linux runner (the actor is not registered there); this report is valid_unverified_cross_os based on source inspection of every hop.
Build Info
- Branch: asan-3
- Revision: 6bf7902103ad0177a215d8cb494fcb923f5c3e57
- Timestamp: 2026-06-18T07:11:31+00:00
Affected Code
File: mobile/shared/actors/GeckoViewPermissionParent.sys.mjs, line 57-78
receiveMessage(aMessage) {
debug`receiveMessage ${aMessage.name}`;
switch (aMessage.name) {
case "GeckoView:AddCameraPermission": {
return this.addCameraPermission();
}
case "GeckoView:MediaPermission": {
return this.eventDispatcher.sendRequestForResult(
"GeckoView:MediaPermission",
aMessage.data // child-controlled; data.uri not overwritten
);
}
case "GeckoView:MediaRecordingStatusChanged": {
return this.eventDispatcher.sendRequest(
"GeckoView:MediaRecordingStatusChanged",
aMessage.data // child-controlled; devices not rewritten
);
}
}
return super.receiveMessage(aMessage);
}
File: mobile/shared/components/geckoview/GeckoViewStartup.sys.mjs, line 60-69
GeckoViewPermission: {
parent: {
esModuleURI: "resource:///actors/GeckoViewPermissionParent.sys.mjs",
},
child: {
esModuleURI: "resource:///actors/GeckoViewPermissionChild.sys.mjs",
},
allFrames: true,
includeChrome: true,
}, // no matches:/remoteTypes:/messageManagerGroups: — any content window can instantiate
File: mobile/shared/actors/GeckoViewPermissionProcessChild.sys.mjs, line 146-154
const response = await this.getActor(window).getMediaPermission({
uri: window.document.documentURI, // self-asserted by content process
video: constraints.video
? sources.filter(source => source.type === "videoinput")
: null,
audio: constraints.audio
? sources.filter(source => source.type === "audioinput")
: null,
});
File: mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java, line 1149-1175
} else if ("GeckoView:MediaPermission".equals(event)) {
final GeckoBundle[] videoBundles = message.getBundleArray("video");
final GeckoBundle[] audioBundles = message.getBundleArray("audio");
...
delegate.onMediaPermissionRequest(
GeckoSession.this,
message.getString("uri"), // child-supplied, no validation
videos,
audios,
new PermissionCallback("media", callback));
}
.collect { permissionRequest ->
val origin: String = permissionRequest.uri?.getOrigin().orEmpty() // child-supplied
if (origin.isEmpty()) {
permissionRequest.consumeAndReject()
} else {
if (permissionRequest.permissions.all { it.isSupported() }) {
onContentPermissionRequested(
permissionRequest,
origin,
)
} else {
permissionRequest.consumeAndReject()
}
}
}
internal suspend fun onContentPermissionRequested(
permissionRequest: PermissionRequest,
origin: String,
...
): SitePermissionsDialogFragment? {
...
val permissionFromStorage = withContext(coroutineScope.coroutineContext) {
storage.findSitePermissionsBy(origin, private = tab.content.private) // LOOKUP by forged origin
}
val prompt = if (shouldApplyRules(permissionFromStorage)) {
handleRuledFlow(permissionRequest, origin)
} else {
handleNoRuledFlow(permissionFromStorage, permissionRequest, origin)
}
...
}
internal fun handleNoRuledFlow(
permissionFromStorage: SitePermissions?,
permissionRequest: PermissionRequest,
origin: String,
): SitePermissionsDialogFragment? {
return if (shouldShowPrompt(permissionRequest, permissionFromStorage)) {
createPrompt(permissionRequest, origin) // DIALOG titled with forged origin
} else {
val status = if (permissionFromStorage.isGranted(permissionRequest)) {
permissionRequest.grant() // SILENT GRANT — no prompt
ALLOWED
} else {
permissionRequest.reject()
BLOCKED
}
...
}
}
internal fun createSinglePermissionPrompt(
context: Context,
origin: String,
permissionRequest: PermissionRequest,
@StringRes titleId: Int,
...
): SitePermissionsDialogFragment {
val trimmedOrigin = trimOriginHttpsSchemeAndPort(origin)
val title = context.getString(titleId, trimmedOrigin) // "Allow <forged> to use your camera and microphone?"
...
return SitePermissionsDialogFragment.newInstance(
sessionId = currentSessionId,
title = title,
...
)
}
File: browser/actors/WebRTCParent.sys.mjs, line 89-108
case "webrtc:Request": {
let data = aMessage.data;
...
data.origin = this.manager.topWindowContext.documentPrincipal.origin; // desktop correctly re-derives
...
prompt(this, this.getBrowser(), data);
break;
}
The parent actor forwards child-controlled data without rewriting uri; the Java and Kotlin layers consume it for the security decision.
Exploit Chain
- Attacker achieves arbitrary code execution in a Firefox-for-Android content process hosting https://attacker.example (standard renderer-RCE precondition for Content→Parent IPC bugs).
- Attacker's content process obtains the GeckoViewPermission JSWindowActorChild via windowGlobalChild.getActor("GeckoViewPermission") — registration has no matches:/remoteTypes: filter so this succeeds for any web window.
- Attacker calls actor.sendQuery("GeckoView:MediaPermission", { uri: "https://meet.google.com/", video: [<device>], audio: [<device>] }) with a forged uri naming an origin the user has previously granted camera/microphone with "Remember decision".
- GeckoViewPermissionParent.receiveMessage (parent process) forwards aMessage.data verbatim to eventDispatcher.sendRequestForResult("GeckoView:MediaPermission", aMessage.data) without overwriting data.uri from documentPrincipal.
- GeckoSession.java reads message.getString("uri") and calls delegate.onMediaPermissionRequest(session, "https://meet.google.com/", videos, audios, callback); GeckoEngineSession wraps this into GeckoPermissionRequest.Media(uri, ...) and dispatches onContentPermissionRequest.
- SitePermissionsFeature derives origin = permissionRequest.uri.getOrigin() = "https://meet.google.com", calls storage.findSitePermissionsBy(origin) → returns the user's stored ALLOWED record. shouldShowPrompt() returns false (doNotAskAgain), so handleNoRuledFlow calls permissionRequest.grant() with NO prompt.
- GeckoPermissionRequest.Media.grant() invokes PermissionCallback.grant(video, audio) → EventCallback.sendSuccess({video: id, audio: id}) → the actor query resolves in the attacker's content process with the selected device IDs.
- Attacker sends actor.sendQuery("GeckoView:AddCameraPermission"); the parent adds the one-shot MediaManagerVideo permission for this.browsingContext.top.currentWindowGlobal.documentPrincipal.origin = https://attacker.example.
- Attacker's content process completes its own pending getUserMedia by firing getUserMedia:response:allow with its real callID (the legitimate Android handler GeckoViewPermissionProcessChild already does exactly this in-content). MediaManager checks the MediaManagerVideo permission (just added for attacker.example) and delivers the camera+microphone MediaStream to attacker.example's window.
- Attacker sends actor.sendAsyncMessage("GeckoView:MediaRecordingStatusChanged", { devices: [] }); parent forwards verbatim; GeckoSession.java calls delegate.onRecordingStatusChanged(session, []) and Fenix hides the recording indicator.
- Variant (no stored grant): step 6 instead calls createPrompt(permissionRequest, origin), showing a trusted-chrome dialog titled "Allow meet.google.com to use your camera and microphone?" while the address bar shows attacker.example. If the user clicks Allow (+Remember), the stream goes to attacker.example and storeSitePermissions persists ALLOWED under request.uri.getOrigin() = the forged origin.
IPC Path
- GeckoViewPermission JSWindowActor
- Protocol: PWindowGlobal
- Parent process: Main
- Child process: Content
- Message flow: Content -> Main: JSWindowActor sendQuery("GeckoView:MediaPermission", {uri: <forged>, video, audio})
Steps to Reproduce
- [UNVERIFIED on this Linux runner — target is MOZ_WIDGET_ANDROID; the GeckoViewPermission actor is not registered on Linux (confirmed: NotFoundError: No such JSWindowActor 'GeckoViewPermission'). Steps below are for an Android GeckoView/Fenix --enable-fuzzing build.]
- Apply source_patch.diff (adds FuzzingFunctions.actorQuery, an XRE_IsContentProcess()-guarded helper that does windowGlobalChild.getActor(name).sendQuery(msg, data) — attacker-capability simulation only).
- Build GeckoView + Fenix with --enable-fuzzing; set pref fuzzing.enabled=true.
- Pre-seed a stored grant for a victim origin: in Fenix visit any page on https://example.org that calls getUserMedia, grant Camera+Microphone, tick 'Remember decision'. Confirm Settings → Site permissions → Exceptions lists example.org = Camera/Microphone Allowed.
- Serve testcase.html from a different HTTPS origin (e.g. https://attacker.test) and load it in Fenix.
- Expected (silent-grant path): adb logcat shows ACTOR_QUERY_RESOLVED: {"video":"<id>","audio":"<id>"} with no permission dialog shown; the page's <video> element starts rendering the front camera; no recording indicator is shown after the MediaRecordingStatusChanged step.
- Variant (spoofed prompt): clear the example.org exception, reload. A dialog titled 'Allow example.org to use your camera and microphone?' appears while the address bar shows attacker.test. Granting it delivers the stream to attacker.test and writes example.org → Camera/Microphone Allowed into Site Permissions storage.
Security Impact
- Severity: High
- Attacker capability: A compromised Android content process can (a) obtain the camera and microphone MediaStream for its own origin with no user interaction by forging the uri field of GeckoView:MediaPermission to match any origin the user has previously granted with 'Remember decision', because SitePermissionsFeature keys its persistent-permission lookup on the child-supplied uri; (b) when no stored grant exists, present the trusted-chrome camera/microphone permission dialog with an arbitrary forged origin in the title, and have the resulting grant (and persisted exception) attributed to the forged origin while the stream is delivered to the attacker; and (c) suppress the recording-in-use indicator via GeckoView:MediaRecordingStatusChanged with devices:[]. This is the same impact category ('Triggering UI interactions in trusted UI / approving a permission') that made bmo-2003171 sec-high, with an additional zero-interaction silent-grant primitive.
- Preconditions: Renderer RCE in a Firefox-for-Android content process (standard Content→Parent threat model). For the zero-interaction silent-grant path, the user must have at some point granted camera/microphone to at least one origin with 'Remember decision' (and Fenix must hold the Android CAMERA/RECORD_AUDIO app permissions, which follows from that prior grant). The spoofed-prompt path has no further precondition. Android only (MOZ_WIDGET_ANDROID); desktop's WebRTCParent.sys.mjs overwrites the origin and is not affected.
ASAN Report
UNVERIFIED: cross-OS target (Android / MOZ_WIDGET_ANDROID); see analysis.
Non-crash logic vulnerability (sandbox escape — unvalidated IPC pass-through). No ASAN report applicable.
Linux runner output (confirms actor is Android-only and patch is content-process-guarded):
[Child 1405280: Main Thread]: E/console error: "[poc] real origin = http://localhost:42011; forging uri = https://example.org/"
ACTOR_QUERY: GetActor(GeckoViewPermission) failed
[Child 1405280: Main Thread]: E/console error: "[poc] MediaPermission rejected: NotFoundError: FuzzingFunctions.actorQuery: No such JSWindowActor 'GeckoViewPermission'"
| Reporter | ||
Comment 1•3 months ago
|
||
| Reporter | ||
Comment 2•3 months ago
|
||
| Reporter | ||
Comment 3•3 months ago
|
||
| Reporter | ||
Comment 4•3 months ago
|
||
| Reporter | ||
Comment 5•3 months ago
|
||
| Reporter | ||
Comment 6•3 months ago
|
||
| Reporter | ||
Updated•3 months ago
|
| Reporter | ||
Updated•3 months ago
|
| Reporter | ||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Comment 8•3 months ago
|
||
| Reporter | ||
Comment 10•3 months ago
|
||
This is an automated comment. To help with triage and priority, we are experimenting with an automated exploitation analysis.
A proof-of-concept was attempted, but controllable exploitation could not be demonstrated. This is not proof that it is unexploitable; the result is inconclusive, but it does not seem straightforward. We attempted an out of bounds write, triggered via a WebGPU compute dispatch in the GPU process, where a validation gap lets an attacker-controlled workgroup-size product drive the JIT'd dispatch's stack displacement; this was reproduced under Mesa lavapipe with the frame displacement tracking the chosen product. However every variant either faulted cleanly at the displaced guard page, which ASAN classifies as a stack-overflow reject rather than decoding a controlled write, or on the product-scaled heap allocation path produced near-NULL rejects or silent corruption inside uninstrumented driver JIT code, so no ASAN-reported write landing in attacker-chosen memory could be shown in this harness.
If this analysis is demonstrably incorrect, please needinfo :tjr.
| Assignee | ||
Comment 11•2 months ago
|
||
Comment on attachment 9602237 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: Fairly easily
- Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
- Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: release
- If not all supported branches, which bug introduced the flaw?: N/a
- Do you have backports for the affected branches?: Yes
- If not, how different, hard to create, and risky will they be?:
- How likely is this patch to cause regressions; how much testing does it need?: Unlikely; our regular automation tests checks uri correctness, I manually tested the fix on a fuzzing build
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
| Assignee | ||
Updated•2 months ago
|
| Assignee | ||
Comment 12•2 months ago
•
|
||
.
| Reporter | ||
Updated•2 months ago
|
| Reporter | ||
Updated•2 months ago
|
| Reporter | ||
Updated•2 months ago
|
Comment 13•2 months ago
|
||
Comment 14•2 months ago
|
||
Comment 15•2 months ago
|
||
Please add an uplift request for beta
| Assignee | ||
Comment 16•2 months ago
•
|
||
Comment on attachment 9602237 [details]
(secure)
Beta/Release Uplift Approval Request
- User impact if declined/Reason for urgency: This patch eliminates security vulnerability where an attacker can obtain control over the device's camera and/or microphone, and the user won't be able to prevent it or even potentially notice it.
- Is this code covered by automated tests?: Yes
- Has the fix been verified in Nightly?: No
- Needs manual test from QE?: No
- If yes, steps to reproduce:
- List of other uplifts needed: None
- Risk to taking this patch: Low
- Why is the change risky/not risky? (and alternatives if risky): The patch introduces a change where the uri for the media permission requests is now supplied by the parent process. The correctness of the new way of obtaining the URI was verified by the existing automated tests and manually by me.
- String changes made/needed: n/a
- Is Android affected?: Yes
Comment 17•2 months ago
|
||
Comment on attachment 9602237 [details]
(secure)
Approved for 153.0b13. For future reference, however, Lando is the preferred way to request uplifts now.
Updated•2 months ago
|
Comment 18•2 months ago
|
||
| uplift | ||
Updated•2 months ago
|
Updated•2 months ago
|
| Assignee | ||
Updated•2 months ago
|
Updated•2 months ago
|
Updated•17 days ago
|
Updated•17 days ago
|
Description
•