Rani367/battery-spoof
GitHub: Rani367/battery-spoof
利用 Frida 动态插桩和原生 arm64e 守护进程,在 Apple Silicon macOS 上通过内存补丁伪造系统设置中显示的电池健康度百分比的逆向工程实验项目。
Stars: 0 | Forks: 0
# battery-spoof
尝试在一台运行 macOS Tahoe (26.3) 的 MacBook Air M2 上伪造电池健康度百分比。纯软件操作,无硬件修改。
这是在一台即将被丢弃的 MacBook 上进行的,所以整个目的就是摸索一下看看能实现什么。事实证明,这是可行的——但 Apple 让实现它的过程变得*非常*烦人。
## 可用的解决方案
这里有两个版本——一种是基于 Frida 的快速实现方法,另一种是永久性的原生守护进程。
### 选项 A:原生守护进程(持久化,可在更新后保留)
一个微型的原生 arm64e 二进制文件 (`batteryd`),作为 LaunchDaemon 运行。它会监视 `PowerPreferences` 扩展进程,在其渲染 UI 之前使用 `SIGSTOP` 将其冻结,接着利用 Mach VM 重映射修补内存中的 `+[PLBatteryUIBackendModel getMaximumCapacity]`(以绕过 Apple Silicon 的 W^X 限制),最后将其恢复运行。零依赖——不需要 Python,不需要 Frida。它通过解析二进制文件的符号表来动态查找方法偏移量,因此可以跨 macOS 更新生效。
#### 一行命令安装
首先禁用 SIP(恢复模式 → 终端 → `csrutil disable && csrutil authenticated-root disable` → 重启),然后执行:
```
curl -sL https://raw.githubusercontent.com/Rani367/battery-spoof/refs/heads/main/install-remote.sh | sudo bash
```
就这样。它会自动下载、编译、安装并启动该守护进程。在重启和 macOS 更新后会自动保留生效。
恢复出厂设置后,SIP 会被重新启用——只需再次在恢复模式下禁用它并重新运行这行命令即可。
要更改百分比(默认值为 100):
```
curl -sL https://raw.githubusercontent.com/Rani367/battery-spoof/refs/heads/main/install-remote.sh | sudo bash -s 65
```
### 选项 B:Frida 一次性脚本(快速,临时性)
使用 [Frida](https://frida.re) 在运行时 hook `PowerPreferences`:
1. hook `PLBatteryUIBackendModel.getMaximumCapacity` 使其返回一个伪造的值
2. 在内存偏移量 `0x68` 处修补 `BatteryHealthViewModel` 缓存的百分比
3. 触发 KVO (Key-Value Observing) 通知,诱骗 SwiftUI 重新渲染
```
pip3 install frida-tools
./spoof.sh 65 # show 65% health
./spoof.sh 100 # look brand new
./spoof.sh 42 # the answer to everything
./spoof.sh 1 # pain
```
在它附加成功后,关闭电池健康信息弹窗(点击“完成”),然后点击 (i) 图标重新打开。此时应会显示新的百分比。按 Ctrl+C 分离。下次打开“系统设置”时,真实数值就会恢复。
#### 前置条件(两个选项通用)
- 运行于 Apple Silicon 上的 macOS(已在 macOS 26.3 Tahoe,MacBook Air M2 上测试)
- 禁用 SIP(通过恢复模式执行 `csrutil disable`)
- 设置 `arm64e_preview_abi` 启动参数(`sudo nvram boot-args="-arm64e_preview_abi"`)
## 原生守护进程的工作原理
该守护进程 (`batteryd.m`) 每隔 50 毫秒执行以下操作:
1. 轮询 `proc_listpids` 查找名为 `PowerPreferences` 的进程
2. 找到后,立即发送 `SIGSTOP` 以在 UI 渲染前将其冻结
3. 使用 `task_for_pid` 获取 Mach task port
4. 读取 `TASK_DYLD_INFO` 以在目标进程中找到二进制文件的基地址
5. 计算方法地址:`base + 0x46c4`(`getMaximumCapacity` 在 PowerPreferences 二进制文件中的偏移量)
6. 使用 **重映射技术** 在 Apple Silicon 上修补可执行内存:
- 在目标中分配一个临时的 RW (可读写) 页面
- 将原始代码页复制到其中
- 在该方法上写入 `movz x0, #65; ret`(两条 arm64 指令)
- 使用 `mach_vm_remap` 将修补后的页面重新映射覆盖原始页面
- 将该页面重新设置为 RX (可读可执行)
7. 发送 `SIGCONT` 恢复进程运行——它将使用我们修补过的方法继续初始化
重映射技术是必不可少的,因为 Apple Silicon 通过 APRR 在硬件级别强制执行 W^X (write XOR execute) 原则。你无法简单地通过 `mach_vm_protect` 将代码页设置为可写。
## 探索之旅(我们尝试过的所有方法)
这不是一个“我一开始就知道该怎么做”的顺利过程。这耗费了数小时去不断尝试、看着它们失败,然后再尝试下一个方法。以下是完整的经过。
### 尝试 1:IOKit 注册表写入
电池数据位于 IOKit 注册表中 `AppleSmartBattery` 下。想法很简单——只需向注册表写入新值即可。
编写了一个 C 程序 (`failed-attempts/set_battery.c`),调用 `IORegistryEntrySetCFProperty` 来设置 `NominalChargeCapacity` 和 `MaxCapacity`。
**结果:** 驱动程序直接拒绝了所有写入操作。报错 `kIOReturnUnsupported` (0xe00002c1)。在 Apple Silicon 上,电池数据来自 Secure Enclave / PMU 硬件,内核驱动程序不允许用户空间触碰它。这种方法在 M 系列 Mac 上行不通(这在带有 SMBus 电池的旧款 Intel Mac 上是可行的)。
### 尝试 2:DYLD_INSERT_LIBRARIES 替换
经典的 macOS hook 技术——编译一个用于替换 (interpose) IOKit 函数的 dylib,并通过 `DYLD_INSERT_LIBRARIES` 进行注入。
编写了 `failed-attempts/interpose.c`,对 `IORegistryEntryCreateCFProperty`、`IOPSGetPowerSourceDescription` 等进行了替换。
**问题:**
- “系统设置”是一个 `arm64e` 二进制文件。我们的 dylib 也需要被编译为 `arm64e`(而不能仅仅是 `arm64`)
- 即使关闭了 SIP,系统卷也是只读的(已签名系统卷),因此我们无法在原处重新对二进制文件进行签名
- 复制“系统设置”并重新签名会剥夺其 entitlements,导致它启动时不显示窗口
- 通过 `launchctl setenv` 注入到真实的“系统设置”中会导致其不断崩溃——arm64e 的指针认证 (PAC) 与函数替换 (interposing) 兼容性极差
**结果:** 每种变体要么导致应用程序崩溃,要么虽然加载了,但并未 hook 到正确的进程。
### 尝试 3:hook powerd(电池守护进程)
`powerd` 是一个守护进程,它从内核读取电池数据并提供给所有客户端。如果我们能修改 powerd 提供的数据,每个应用程序都会看到伪造的数据。
使用 Frida 附加到 powerd (`failed-attempts/hook_powerd.js`),hook 了 `IORegistryEntryCreateCFProperty` 和 `IOPSSetPowerSourceDetails`。
**结果:** 没有一个 hook 被触发。powerd 在开机时只读取一次电池数据并将其缓存起来。等到我们附加的时候,它早已读取完毕。缓存确实存在于内存中,但我们一开始搜索的字节模式不对(搜索了 `FD 0F` 而不是 `DD 0F`——即 4093 与 4061 的区别。令人扶额)。
还尝试了直接修补 powerd 的内存 (`failed-attempts/patch_powerd.js`)。找到并替换了 `AppleRawMaxCapacity` (3934),但 `NominalChargeCapacity` (4061,实际用于计算健康度 % 的值) 并不在可写内存中。而且,即使修补了原始容量,也未能改变“系统设置”中显示的内容。
### 尝试 4:修改 PowerLog sqlite 数据库
发现电池数据被记录在 `/var/db/powerlog/Library/BatteryLife/CurrentPowerlog.PLSQL` 中。`PLBatteryAgent_EventBackward_Battery` 表包含 `NominalChargeCapacity`、`AppleRawMaxCapacity`、`DesignCapacity` 等列。
更新了所有行:`UPDATE PLBatteryAgent_EventBackward_Battery SET NominalChargeCapacity = 2966`。
**结果:** 没用。这个数据库是用于分析的历史日志,并非实时的数据源。“系统设置”并不从这里读取数据。
### 尝试 5:Frida 附加到了错误的进程 (GeneralSettings)
我们花了不少时间 hook `GeneralSettings.appex`,以为电池面板就在那里。我们 hook 了每一个 IOKit 和 IOPowerSources 函数,并安装了全面的追踪器。
**结果:** 没有一个 hook 被触发。连一个与电池相关的函数调用都没有。事实证明,电池并不在“通用”设置下——它是侧边栏中拥有独立扩展进程的一个独立版块。
### 尝试 6:找到正确的进程 (PowerPreferences)
编写了 `failed-attempts/find_battery_process.sh`,用于对比点击“电池”前后正在运行的进程。找到了罪魁祸首:
```
/System/Library/ExtensionKit/Extensions/PowerPreferences.appex/Contents/MacOS/PowerPreferences
```
这就是负责处理电池设置面板的 ExtensionKit 扩展。它只在你点击“电池”时才会生成。
### 尝试 7:与 Frida 附加过程赛跑
由于 `PowerPreferences` 是在点击“电池”时重新生成的,我们试图以比它读取数据更快的速度附加 Frida:
- bash `pgrep` 循环 (`failed-attempts/race.sh`) ——实在太慢了
- python 紧凑循环 (`failed-attempts/race.py`) ——尝试了 1514 次后才附加成功,依然太晚
**结果:** 该扩展在初始化期间、在任何外部工具能够附加之前,就已经读取了 `PLBatteryUIBackendModel.getMaximumCapacity()`。我们确认 hook 是有效的(在 hook 之后调用 `getMaximumCapacity()` 会返回 65),但 UI 早已缓存了 89%。
### 尝试 8:内存扫描 + 修补(接近成功)
附加到 `PowerPreferences`,扫描了堆 (`failed-attempts/patch.js`)。发现了 83 个与电池相关的 ObjC 类,其中包括几个核心类:
- `PowerPreferences.BatteryHealthViewModel` —— SwiftUI 视图模型(1 个实例)
- `PLBatteryUIBackendModel` —— 拥有 `+getMaximumCapacity` 类方法,返回值为 89
- `BUIPowerSource` —— 包含 `maxCapacity`、`currentCapacity` 等
导出了 `BatteryHealthViewModel` 实例的原始内存,发现:
- 偏移量 `0x50`:字符串 `"Normal"`(电池状况)
- 偏移量 `0x68`:字节 `0x59` = **89**(健康度百分比!)
在内存中将其修补为 65。但是 UI 并没有更新——SwiftUI 只有在通过 Combine/KVO 发出状态更改信号时才会重新渲染,底层内存被悄悄修改时则不会。
### 尝试 9:KVO 通知(Frida 解决方案)
Frida 方法的最后一块拼图:在修补内存后,触发视图模型上的 KVO (Key-Value Observing) 通知。这等于在告诉 SwiftUI“嘿,有个属性变了,请重新渲染”:
```
vm.willChangeValueForKey_("maximumCapacity");
vm.didChangeValueForKey_("maximumCapacity");
```
我们不知道确切的属性名称(它们是 Swift 专属的,对 ObjC runtime 不可见),所以我们只是针对一堆可能的名称触发了通知。其中一个命中了,SwiftUI 就会使用修补后的值重新渲染。
**结果:成功了。** 关闭并重新打开电池健康度弹窗,就会显示 65%(或你设置的任何值)。
### 尝试 10:结合 SIGSTOP + mach_vm_remap 的原生守护进程(永久解决方案)
Frida 方法虽然有效,但需要运行脚本。为了实现完全自动化的解决方案,我们编写了一个原生的 arm64e 守护进程,它会:
1. 通过 `proc_listpids` 监视 `PowerPreferences`
2. 在 UI 渲染前使用 `SIGSTOP` 将其冻结
3. 使用 `task_for_pid` + `TASK_DYLD_INFO` 找到二进制文件的基地址
4. 使用重映射技术在偏移量 `0x46c4` 处修补方法(分配 RW 页面 → 复制代码 → 修补 → `mach_vm_remap` 覆盖原文件 → 设置为 RX)
5. 使用 `SIGCONT` 恢复其运行
将其作为 LaunchDaemon 安装在系统卷上,开机即启动,并且每次你打开电池设置时都会进行修补。恢复出厂设置后依然有效。
**结果:完全自动化、零依赖、持久生效。**
## 关键发现
- **Apple Silicon 上的电池健康度是硬件强制执行的。** 该值源自 Secure Enclave / PMU,流经一个拒绝所有写入操作的内核驱动程序,再通过会将其缓存的 `powerd`,经由 `IOPowerSources` XPC,最终传递到 UI。你无法在源头更改它。
- **macOS 的“系统设置”被拆分成了几十个 ExtensionKit 扩展进程。** 侧边栏中的每个项目都是一个独立的 `.appex` 进程。电池由 `PowerPreferences.appex` 处理,而不是 `GeneralSettings.appex`。
- **健康度百分比来自 `NominalChargeCapacity / DesignCapacity`。** 在我们的机器上:4061 / 4563 = 89%。它不是来自 `MaxCapacity`(那只是当前充电水平 = 100),也不是来自 `AppleMaxCapacity`(值为 3934)。
- **`PLBatteryUIBackendModel.getMaximumCapacity`** 是向 UI 返回健康度百分比的唯一的类方法。它位于 `PowerPreferences` 二进制文件本身内部(而不是某个共享框架中)。
- **SwiftUI 视图不会因为原始内存修补而更新。** 你需要触发 Combine/KVO 流水线。在视图模型上触发 `willChangeValueForKey:` / `didChangeValueForKey:` 即可起效(Frida 方法),或者只要在 UI 加载之前进行修补即可(原生守护进程方法)。
- **DYLD interposing 在 arm64e 上基本上已经行不通了。** 指针认证使得将 dylib 注入系统进程几乎成为不可能,即使禁用了 SIP 也是如此。
- **Apple Silicon 在硬件级别强制执行 W^X。** 你无法通过 `mach_vm_protect` 将代码页设置为可写。变通的方法是重映射技术:分配一个新的 RW 页面,复制 + 修补,然后使用 `mach_vm_remap` 将其覆盖到原始位置并设置为 RX。
- **PAC (Pointer Authentication) 隐藏了真实地址。** Frida 返回的是经过 PAC 签名的指针,例如 `0xc349000102...` ——你需要使用 `.strip()` 来获取实际地址 (`0x102...`)。
## 文件
```
batteryd.m — native daemon source (the permanent solution)
batteryd.c — earlier C version (couldn't load the ObjC class)
com.battery.spoof.plist — LaunchDaemon plist
before-reboot.sh — install daemon to /Library/LaunchDaemons
after-reboot.sh — install to system volume (factory reset survival)
spoof.sh — frida one-shot script
spoof.js — frida hook script
daemon.py — python/frida daemon (replaced by native)
failed-attempts/ — everything that didn't work (see above)
set_battery.c — IOKit registry write attempt
interpose.c — DYLD_INSERT_LIBRARIES attempt
trace_dylib.c — tracing dylib attempt
hook_powerd.js — powerd hooking attempt
patch_powerd.js — powerd memory patching attempt
race.py — frida race condition attempt
patch.js — heap scanning attempt
find_battery_process.sh — process discovery script
...and more
```
## 免责声明
这纯粹是显示层面上的修改。它只会影响“系统设置”中显示的内容。它不会改变 `ioreg` 的输出,不会影响实际的电池行为,也无法骗过 coconutBattery 等第三方工具。这完全是为了好玩而在一台即将被回收的机器上进行的。
标签:Apple Silicon, Cutter, CVE监控, 云资产清单, 内存补丁, 动态注入, 系统底层, 自定义脚本, 逆向工具, 逆向工程