Porcupine 二进制库破解

背景

四五年前写过一个基于RNN的热词唤醒检测 (hotword / wake word detection,现在貌似research都改叫keyword spotting了),大概的作用基本和Hey Siri, Alexa, Ok Google类似,我在说特定词语的时候系统通过麦克风监听分辨出某些关键词并做出响应,需要做到低功耗、低延迟和高精度。最近所有电脑基本都换成M1 Family的Mac的时候问题就来了,之前用了一个科学计算库已经很久不更新了,里面一堆线性代数加速是X86 Specific的,arm上就硬是编译不过;重写是不可能重写的,于是就逛了一圈有没有现成的开源/开放API可以直接使用。

这个领域 (热词唤醒) 好像最近两年没什么变化了,很多GitHub上看到的项目最后更新日期都是2021, 2020, 甚至更早。为数不多能使用的就是这位Porcupine了:项目闭源,但是开放二进制下载;实现非常高效,极低功耗以及超高的精度;缺点就是license模式 (下图)。提供的二进制完全可以离线运行,之前的版本也都是可以直接用的,但是新版就是要硬加一个licencing功能,限制免费用户只能在三台设备上运行,并且每个月只能train三个wake word。这个免费的额度我其实是够用的,需要我的LiSA-HWD运行的设备只有两台,并且我也只需要1-2个热词,但是为了future-proof保不齐之后换地方住需要更多地设备做唤醒,以及看这个公司的吃相实在难看,决定还是一劳永逸破了它。

Picovoice官网的售价截图

二进制库探索

首先通过官方提供的API Demo,查看如何调用他提供的dll / dylib。本来想着C-API一定是最直观的,因为基本上头文件里的函数名和Type Signature能直接在二进制里找到对应的symbol,结果第一个令人震惊的工程设计就来了:

官方提供了C-API的header,里面有所有的导出函数 (右侧文件),但是在提供的demo里就是不用,直接用dlfcn动态加载 dylib,然后去做运行时的函数寻址,找到target函数手动提供类型签名去调用… 我不确定这样做有没有任何好处,但是坏处一大堆,比如运行时开销大、不利于逆向…

于是我就放弃从demo入口下手了,直接去dylib查看那几个公开export的函数。这里使用一个二进制分析&修改工具集 Radare 2以及对应的逆向工程GUI软件 Cutter.re

用户在使用porcupine的时候大致流程是

  1. 调用 pv_porcupine_init ,传入Access Key、模型路径、关键词列表和Sensitivity,进行初始化
    1. 读取设备识别相关的信息,包括操作系统、架构、硬件识别码,生成设备标识
    2. Access Key鉴权,首先parse输入的AK,然后和设备识别码一起,会有一次向 kmp1.picovoice.net 域名的HTTP请求
    3. 读取并加载指定路径下的模型
    4. 读取指定目录下的关键词列表和数据
  2. 调用 pv_porcupine_frame_length_func,获取PCM的frame长度
  3. 调用 pv_recorder_start 开始监听指定的音频输入设备,通常为板载麦克风或耳机
  4. 分配缓存的frame空间
  5. Forever While Loop
    1. 读取音频输入设备中的frame_length长度的PCM数据
    2. 调用 pv_porcupine_process 处理该音频片段
    3. 判断process函数返回的keyword index,如果不是-1,说明触发了某个热词并返回了该词的index
    4. 处理keyboard event,发现Ctrl-C或其他interupt事件时退出循环

其中Access Key的鉴权发生在第一步,会由 pv_porcupine_init 函数调用一个匿名 (二进制没有调试符号,我也不知道函数名) 的子函数去parse输入AK,准备就对那个函数下手。

绕过Access Key鉴权

起点: pv_porcupine_init()

先来看这个init函数,先表扬一下Cutter的GUI做的非常棒,有个Graph view可以把所有的branch instructions全部都可视化成不同的basic blocks,并且有箭头指示。可以看到init函数的参数类型都是int64,之前C-API侧的signature里string、float和其他数据结构都用int64_t的内存地址传入。AK是第一个参数 arg1,被消费是在一个子函数调用里,图中的中间那个block,在bl指令前把arg 1-6全部推到栈上了。

Cutter还有Ghidra支持挺意外的,反汇编这种东西,正常工作的时候结果看起来非常舒服和直观,但是大多数情况下不会正常工作,所以通常就是看一乐。左侧是Ghidra的反汇编结果,右侧是JSDec,Ghidra是NSA几年前开源的一个逆向工程工具。明显左边的更加简洁,右侧JSDec有很多冗余的赋值,并且对AArch64的call invention识别不是很准确。反编译出的伪代码也能非常清楚的看到arg1被传入了这个45d0结尾的函数里。

汇编反向编译,左侧为Ghidra,右侧为jsdec

第一跳: func.00045d0()

看这个函数整体的流程像是处理用户输入的,包括模型文件的检查,keyword文件的检查,以及重头戏AK的检查。比如最开始的这一段,除了读入函数参数、检查参数、定义一堆中间变量以外,就是检查sensitivity是否在0.0和1.0之间,这个看二进制的string内容就能知道。

接下来一块检查模型文件和keyword文件,again我也没细看,就看了一下提示应用的string内容,都是什么keyword file。其中func.00008380就是Print或者Log之类的函数,func.00006240是调向检查模型的函数。

接下来一部分比较重要的是函数最后一段,从4a38开始到最后,负责处理AK。func.00007188就是个Malloc类似的分配函数,会返回成功分配的字节数,所以cbz指令负责处理不为零的时候跳转到下一个Block 4aa0。然后最终会调用到func.0000c7d0,负责检查AK并和服务器通信。

第二跳: func.0000c7d0()

这个函数一开始就会调用另一个子函数 func.0000d4d4 做AK的格式检查,我们想要它直接检查不过,触发”Failed to parse AccessKey”,并且快速退出返回之前的45d0函数,走左图中加粗的红色和绿色线路。

快速退出的好处就是一旦判断AK格式不对,下面的代码通路就不会走,包括会跳过服务器端检查以及本地设备标识符的上传。要想触发这样的效果,我不需要更改任何东西,只需要初始化的时候AK直接给个“asdf”什么的函数就能自动退出,所以这个函数不修改。

第三跳: 返回 func.00045d0()

假设我们传了一堆乱码做AK,那么上一个函数c7d0的返回值就一定不是0,所以X28/W28 register里为非0值。最后函数有一个cbz会在0的时候跳转,i.e. 如果处理AK的函数正常返回,我们会跳转到下一个block 4ae0,否则会直接跳转 4ab8 触发 pv_porcupine_delete,并且异常返回。

所以目标就变成了修改单条指令:

// Original
0x00004ad0    9c000034    cbz w28, 0x4ae0
// Target
0x00004ad0    04000014    b 0x4ae0

从“如果W28里是0,那么跳转到4ae0”;到“直接跳转到4ae0”。

二进制改写&测试

echo 00004ad0: 04 00 00 14 | xxd -r - libpv_porcupine.dylib

以上命令直接将4ad0地址的指令改为unconditional jump,效果如下:

原本”asdf”不是一个有效的AK会直接报错退出,现在会直接继续流程,并且不再会检查AK (有效性、权限和过期日期)。最后还是忍不住感叹一句,这个Picovoice做推理优化是做的真好,模型精度特别高的同时后台监听唤醒词在CPU上跑甚至占用率不到1%,其他项目e.g. Snowboy轻松破5%风扇呼呼响…

至此 macOS ARM64 下的二进制库 libpv_porcupine.dylib 的破解就完成了,类似的操作也可以应用在 Linux ARM64 (树莓派) 和 Linux x86_64 上,原理都是一样的,这三个系统/架构的我都改写完了,并且测下来都能稳定运行。