从不受信任的应用获取OnePlus 15的root权限

Lobsters Hottest 新闻

摘要

安全研究人员发现OnePlus 15中存在两个漏洞,这些漏洞可串联起来允许不受信任的应用获取root权限;OnePlus已为许多设备修复此问题。

<p><a href="https://lobste.rs/s/a5rcxy/getting_root_on_oneplus_15_from_untrusted">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/29 18:03

# 从不受信任应用获取 OnePlus 15 root 权限:通过音频调试服务与厂商 HAL | nns.ee 来源:https://blog.nns.ee/2026/09/24/oneplus-root 我从 OnePlus 3T 起就开始使用 OnePlus 手机。目前我使用的是 OnePlus 15(CPH2747),运行 OxygenOS 16,已使用数月,整体体验相当满意。我决定稍微探究一下其固件内容,特别想了解纯净安装的固件中是否存在任何允许我从 Play Store 之外安装的普通应用最终以 root 权限运行的组件。结果确实存在!我发现了两个独立漏洞,可以组合形成一条相当简洁的利用链:`untrusted_app`(不受信任应用)-> uid 0(root),具备全部 Linux 能力权限,可从一个完全无需特殊权限的普通可安装 APK 实现。 关于方法的说明:我的 OP15 是日常主力机,因此在开发漏洞利用程序时,我不希望预先获取 root 或改变其状态。碰巧我手边有一台已解锁且因无关原因获取过 root 的旧款 OnePlus 12 Pro(CPH2581),我便在该设备上进行逆向工程和漏洞利用开发。一旦我制作出可运行的概念验证(PoC),便将完全相同且未修改的 APK 安装到我的 OP15 上,并且一次就成功了。因此,这篇分析主要针对 OP15,但大多数 Ghidra 截图和设备命令来自 OP12。OnePlus 随后确认该漏洞影响了多个软件版本下的众多 OnePlus 和 OPPO 设备,但尚未提供受影响设备或软件版本的完整列表。不过,我可以确认,至少在 OnePlus 15 上,这些问题已在版本 `16.0.10.500 (EX01)` 中修复。 **2026-09-28 更新**:OnePlus 安全团队已联系并确认,在仍在维护的设备中,有 151 款设备已收到补丁,另有 18 款待发布。完整更新如下(https://blog.nns.ee/2026/09/24/oneplus-root/#2026-09-28-update)。 ### Android 沙箱机制快速入门 (https://blog.nns.ee/2026/09/24/oneplus-root#a-quick-primer-on-android-sandboxing) 在 Android 上,一个正常安装的应用(无论来自 Play Store、侧载等)运行在一个称为 `untrusted_app`(不受信任应用)的 SELinux 域中。其限制相当严格——它只能与少数系统 Binder 服务通信,读取自身的数据目录,若获得权限则可使用摄像头等,除此之外功能有限。本地权限提升的常见目标就是突破 `untrusted_app`,进入具有 uid 0(root)和更宽松 SELinux 域的位置。 Android 是一个复杂的系统,存在*大量*系统 Binder 服务,每个都是潜在的攻击面。OxygenOS 在 AOSP 服务之上增加了自己的服务。我认为 AOSP 服务很可能比 OxygenOS 服务受到过更多关注,因此我将主要精力放在了这部分。 ### 漏洞 1:AtlasService 允许任何应用以 root 身份运行命令 (https://blog.nns.ee/2026/09/24/oneplus-root#bug-1-atlasservice-lets-any-app-run-commands-as-root) AtlasService 是 OxygenOS 的一项功能,据我所知,用于收集遥测和调试事件。它以 root 身份运行,并接受来自任何进程的 Binder 调用。我不完全确定 "Atlas" 具体指什么,该进程本身称为 `atlasservice`,相关库是 `libatlasservice.so`。我未能找到太多相关文档。 查看 `BnAtlasService::onTransact`,有几个事务代码。有趣的是代码 `2`,对应 `setEvent(String8 name, String8 value)`。对调用者没有任何权限检查——任何 UID 都可以调用它。 `setEvent` 通过一系列注册的监听器分发事件。大多数监听器执行的是常规操作——记录日志、上传数据等——但其中一个监听器 `OplusAtlasLogWriter::handleEvent` 对事件名进行字符串比较,匹配 `atlas_event_multimedia_audio_dumpsys`。如果事件名匹配,它会调用 `dumpsysAudioInfo`,该函数启动一个工作线程执行以下操作: ``` property_set("oplus.audio.dumpinfo.type", value); // 攻击者控制 property_set("ctl.start", "audiodumpinfo"); ``` `ctl.start=audiodumpinfo` 告知 `init` 启动一个名为 `audiodumpinfo` 的服务。查看 `/system_ext/etc/init/audiodumpinfo.rc`: ``` service audiodumpinfo /system_ext/bin/audioDumpInfo class main user root group root system everybody sdcard_rw seclabel u:r:dumpstate:s0 disabled oneshot ``` 因此 `init` 以 uid 0 和 `dumpstate` SELinux 域启动 `/system_ext/bin/audioDumpInfo`。那么 `audioDumpInfo` 如何处理我们攻击者控制的属性值呢?它调用 `GetProperty("oplus.audio.dumpinfo.type")` 并将结果直接放入字符串缓冲区: ``` /data/persist_log/TMP/audio_dumpsys/<我们控制的值>/ ``` 然后它逐级遍历路径,对于每个尚不存在的组件,创建目录并执行 `system("chmod 777 " + prefix)`。我想你已经看出问题所在了。我们提供的值未经转义,直接作为 shell 命令传入 `system()` 函数。我们只需注入 `;`、某些命令以及 `#` 注释掉剩余部分。Android 属性值上限为 92 字节。这对于完整命令来说相当紧张,但对于 `sh&1|log -t AtlasOut;#` 则完全足够。 这意味着任何应用都可以基本上以 uid 0 身份运行命令。SELinux 上下文最终为 `u:r:dumpstate:0`,虽然这不是完全无限制的 root(dumpstate 的策略相当严格),但它具有 uid 0,能够读写大部分 `/data`,并且可以调用众多信任 uid-0 调用者的厂商 Binder 服务。这引出了第二个漏洞。 ### 漏洞 2:olc2 HAL 的 `doShell` 字面意义上为你运行 shell (https://blog.nns.ee/2026/09/24/oneplus-root#bug-2-olc2-hal-doshell-literally-runs-a-shell-for-you) OxygenOS 搭载了一个名为 `vendor.oplus.hardware.olc2.IOplusLogCore/default` 的厂商 HAL,运行于 `/odm/bin/hw/vendor.oplus.hardware.olc2-V3-service`,并暴露一个 Binder 接口。该服务在事务代码 `6` 处有一个名为 `doShell(String cmd)` 的方法。查看其实现确实是“我也不知道我期待什么”的时刻。Ghidra 对 OlcHwService::doShell 的反编译显示 uid 检查和到 /vendor/bin/sh 的 execl 调用 (https://blog.nns.ee/img/2026-09-24-oneplus-root/olc-doshell.png): ``` if (getCallingUid() == 0) { __android_log_print(3, "OLC_HAL", "olc doShell(%s) called", cmd); pid = fork(); if (pid == 0) { execl("/vendor/bin/sh", "sh", "-c", cmd, NULL); // ... } } ``` 唯一的门槛是 `getCallingUid() == 0`。如果你是 uid 0,它会为你运行任意 shell 命令。代码 `7` 处还有一个 `doShellBlocking`,功能相同,但如果你需要退出状态,它会使用 `waitpid()`。 使其对我们有趣且有用的部分是子进程继承的 SELinux 域。当 `init` 启动 olc2 HAL 服务时,其 SELinux 类型为 `hal_oplus_olc_aidl_default`。然后策略中的 `type_transition` 规则将该 HAL 的执行子进程送入 `vendor_qti_init_shell`: ``` type_transition hal_oplus_olc_aidl_default vendor_qti_init_shell_exec:process vendor_qti_init_shell ``` 而 `vendor_qti_init_shell` 的 `CapBnd = 0x1ffffffffff`,意味着它拥有*所有*能力权限。`CAP_SYS_MODULE`、`CAP_SYS_RAWIO`、`CAP_SYS_PTRACE` 等。这……比 dumpstate 多得多。Dumpstate 的能力集更具限制性,无法加载内核模块等操作。就 Linux CAPs 而言,`vendor_qti_init_shell` 本质上是一个无限制的 shell,仅受 SELinux 约束。 ### 组合利用 (https://blog.nns.ee/2026/09/24/oneplus-root#chaining-them) 这两个漏洞可以完美组合: 1. 不受信任应用通过 Binder 调用 `AtlasService.setEvent("atlas_event_multimedia_audio_dumpsys", "")`。 2. AtlasService 设置属性 `oplus.audio.dumpinfo.type` 并触发 `ctl.start=audiodumpinfo`。 3. `init` 以 `u:r:dumpstate:s0` 启动 `/system_ext/bin/audioDumpInfo`。 4. `audioDumpInfo` 读取我们的属性,使用它调用 `system()`,我们以 `dumpstate` 身份获得 shell。 5. 仍以 `dumpstate` 运行,我们通过 Binder 调用 olc2 HAL 的 `doShell(cmd)`。 6. olc2 HAL fork/exec `/vendor/bin/sh -c cmd`,最终进入 `u:r:vendor_qti_init_shell:s0`。 策略将 `dumpstate` 放入 `hal_oplus_olc_aidl_client` 属性中,该属性被明确允许调用 `hal_oplus_olc_aidl_server`: ``` $ sesearch -A -s dumpstate -c binder sepolicy_clean.bin | grep olc allow hal_oplus_olc_aidl_client hal_oplus_olc_aidl_server:binder { call transfer }; ``` ……以及一长串其他 HALs(`debuglog`、`hal_camera_default`、`hal_charger_oplus` 等),因此 HAL 端的 `getCallingUid() == 0` 检查是*唯一*的权限门槛。任何在这些包含属性域中的 uid-0 进程都可以使用它。 ### 构建概念验证 (https://blog.nns.ee/2026/09/24/oneplus-root#building-the-poc) PoC 是一个常规 Android 应用,在清单中无需特殊权限,目标 API 为 35。该应用在 `MainActivity.onCreate` 中执行以下操作: 1. 将一个小型 `boot.sh` 写入 `getExternalFilesDir()`,该脚本使用我们的类执行 `app_process`。 2. 将 `classes.dex` 从我们自己的 APK 解压到同一目录。 3. 使用执行 `sh` 的载荷调用 `AtlasService.setEvent`,其中 `` 指向我们已安装的 APK。 结果证明,这*不*起作用。已安装的 APK 标记为 `apk_data_file`,而 `dumpstate` 无法读取该标签。但应用的外部文件目录标记为 `media_rw_data_file`/fuse,dumpstate*可以*读取。因此我们将 `classes.dex` 从自己的 APK 中解压(实际上就是解压缩)并放置在那里。`boot.sh` 最终如下所示: ``` #!/system/bin/sh export CLASSPATH=/sdcard/Android/data/com.research.poc/files/classes.dex exec /system/bin/app_process /system/bin com.research.poc.Pwn olc /sdcard/Android/data/com.research.poc/files/cmd.txt ``` `app_process` 是在 Android 上启动 JVM 托管进程的二进制文件(zygote 也使用它)。通过设置 `CLASSPATH` 环境变量,我们可以从任何想要的 .dex 文件运行一个类。 `Pwn.main` 以 `dumpstate` 身份运行,并执行对 `olc2.doShell(cmd)` 的 Binder 调用。这时我遇到了一个特殊情况。 ### Java 的 `writeInterfaceToken` 与厂商 AIDL HAL (https://blog.nns.ee/2026/09/24/oneplus-root#java-writeinterfacetoken-against-a-vendor-aidl-hal) olc2 HAL 是使用 AIDL NDK C++ 绑定编写的。从 Java 调用 AIDL 厂商 HAL 的官方方式……实际上并不存在。你根本不应该从应用进程与厂商 HAL 通信。但 `dumpstate` 是系统进程,所以*它*可以,如果你能弄清楚正确的线上格式。 我以为这会很难。厂商 Binder 服务通常有严格的接口令牌版本控制,并且存在一个与常规 `android.os.IBinder`/`ServiceManager.getService` 路径分离的 `android.os.IHwBinder` 路径,用于与 HIDL 厂商 HAL 通信。由于这是 AIDL 而非 HIDL,我不确定自己处于哪一边。或者,更确切地说,我以为我不确定。出于直觉,我尝试了 `ServiceManager.getService("vendor.oplus.hardware.olc2.IOplusLogCore/default")`,用 `Parcel.writeInterfaceToken("vendor.oplus.hardware.olc2.IOplusLogCore")` 写入接口令牌,写入字符串参数,`binder.transact(6, data, reply, 0)`。它竟然直接成功了。 ``` IBinder b = ServiceManager.getService("vendor.oplus.hardware.olc2.IOplusLogCore/default"); Parcel data = Parcel.obtain(), reply = Parcel.obtain(); data.writeInterfaceToken("vendor.oplus.hardware.olc2.IOplusLogCore"); data.writeString(cmd); b.transact(6, data, reply, 0); ``` 还有一个关于 `Parcel.writeString8` 与 `writeString` 的细微差别。稳定的 AIDL 以 UTF-16(`String16` 线上格式)传输字符串,而 Java 的 `Parcel.writeString` 也发射这种格式——因此对于 olc2 调用,`writeString` 直接有效。另一方面,AtlasService 使用较旧的 `String8` 线上格式。线上的 `String8` 格式为 `int32 长度, UTF-8 字节, '\0', 填充至4字节`。即使在解除隐藏 API 限制后,Java 的 `Parcel` 在 API 35 上并未直接暴露此格式,因此我最终手动模拟:`writeInt(len)`,然后使用辅助 `Parcel` 的 `writeByteArray(bytes + '\0')`,再从辅助 Parcel 的自身长度前缀之后开始,对主 parcel 使用 `appendFrom`。有些笨拙但确实有效。 ### 在 OP15 上测试 (https://blog.nns.ee/2026/09/24/oneplus-root#testing-on-the-op15) 在 OP12 上一切就绪后,我将同一未修改的 APK 安装到我的 OP15(CPH2747,OxygenOS 16.0.3.503,安全补丁级别 2026-02-01,内核 6.12.23)上尝试: 概念验证应用命令输出显示权限提升 (https://blog.nns.ee/img/2026-09-24-oneplus-root/poc.png) 一次成功。鉴于两款不同的 OnePlus 机型在不同的内核分支上都存在此问题,我将其视为 OxygenOS 16 的普遍问题,而非特定于某款手机。 ### 修复方案 (https://blog.nns.ee/2026/09/24/oneplus-root#remediation) 如果由我来修复,对于 AtlasService,我会(或更确切地说,两者都做): 1. 对 `AtlasService::setEvent` 应用调用者 UID / SELinux 对等体检查。 2. 停止将未经过清理的用户数据传入 `audioDumpInfo` 中的 `system()` 调用。在一个可通过启动触发的服务中执行 `system("chmod 777 " + untrusted_input)` 在 2026 年绝对是个“选择”。 对于 olc2,修复方法要么完全移除整个 `doShell` 方法(这东西到底为什么存在?),要么至少添加 SELinux 对等体过滤器,使得只有非常特定的调试守护进程可以访问它,而不是任何具有 uid 0 的进程。 --- ## 其他事项 (https://blog.nns.ee/2026/09/24/oneplus-root#miscellaneous) 我在过程中遇到的一些事情,不够有趣放入主线故事,但我想记录下来。 ### 逆向 AtlasService 事件分发 (https://blog.nns.ee/2026/09/24/oneplus-root#reversing-the-atlasservice-event-dispatch) `libatlasservice.so::OplusAtlasLogWriter::handleEvent` 对硬编码字符串进行了多次 `memcmp` 调用。字符串字面量并非存储为单个 `const char*`;而是编码为一对 64 位四字和一个 32 位双字,作为立即数加载。Ghidra 并未将这些显示为明显的字符串比较——你会看到 `if (*(long*)v == 0x5f73616c7461 && ...)` 并且必须手动反转字节序。用于转储它们的小型脚本: ``` import struct def qw(x): return struct.pack("<Q", x) def dw(x): return struct.pack("<I", x) # ... 解码逻辑 ``` 在这个服务上我看到了三种代码(1, 2, 3),在确定代码 2 读取两个 `String8` 参数之前,我尝试了所有代码。代码 1 是 `registerNativeClient`,它本身又是一个麻烦(它允许你注册一个回调 Binder,服务会在 root 下调用它,这可能是一个单独的漏洞,但我尚未深入研究)。 --- ## 2026-09-28 更新 (https://blog.nns.ee/2026/09/24/oneplus-root#2026-09-28-update) OnePlus 已联系并分享了当前修复状态的更多细节。引用如下: > 我们已与负责团队确认,该漏洞已在新版本中修复。相关代码已于七月底合并。此审查覆盖仍在维护中的 OPPO/realme/OnePlus 出口型号:151 款型号已修复,18 款待发布。所有待发布型号计划于十月发布。这些现有维护分支的发布周期为两到三个月。尽管修复已于七月底合并,但只能在维护窗口期内通过 OTA 推送,因此存在一段更新仍待发布的短暂时期。 > > 修复后的构建版本可如下识别: > - 在 16.1.0 系列,修复构建始于 16.0.10.500。当最后一位数字为 500 或更大时,构建版本已修复。 > - 在 16.0.0 系列,修复构建始于 16.0.5.1200。当最后一位数字为 1,200 或更大时,构建版本已修复。 > - 在 15.0.0 系列,修复构建始于 15.0.0.2000。当最后一位数字为 2,000 或更大时,构建版本已修复。 --- ## 时间线 (https://blog.nns.ee/2026/09/24/oneplus-root#timeline)

相似文章

Pixel 10 的零点击利用链

Hacker News Top

谷歌 Project Zero 发布了针对 Pixel 10 的零点击利用链,利用 Dolby 漏洞和新 VPU 驱动缺陷实现 Android 根权限获取。