背景

一加系统自带的录音机支持部分应用的语音通话捕获,但只支持特定几个应用,无法覆盖到所有 VoIP 通话场景。此外,通话录音文件的保存路径为 /sdcard/Music/Recordings/Call Recordings/,该位置对外部应用可见,存在被第三方应用读取或删除的安全隐患。

因此,我希望设计一种更加安全可靠的 VoIP 通话录音方案,确保第三方应用无法访问核心代码和录音文件。具体需求如下:

  • 隐蔽性:无可见的通知或界面,即使设备被物理获取也难以发现录音功能的存在
  • 低功耗:需要长期驻留后台,不能因占用过高系统资源而影响电池续航
  • 不抢占:不能干扰正常的音视频通话流程,需在通话过程中自动启动录音
  • 稳定性:使用 app_process 在高权限环境下运行,配合 watchdog 监控进程状态,崩溃后自动重启
  • 安全性:避免与第三方应用共享存储空间,防止录音数据被扫描或窃取
  • 云同步:数据自动上传至可靠的云端存储,防止因设备损坏等不可抗力因素导致数据丢失

分析

核心思路是通过对比通话前后音频系统的状态变化,定位负责语音捕获的目标进程。

第一次 Dump(通话前):

执行以下命令获取当前音频客户端信息:

1
adb shell dumpsys media.audio_flinger | grep Client

此时系统中已有一些常规音频客户端在运行,主要包括系统 UI 和后台应用:

  • com.android.systemui (PID 5207)
  • com.android.launcher (PID 5684)
  • 其他系统音频服务

输出中显示多个 “1 Clients” 条目,代表各音频线程上的活跃客户端。

接通通话并启用 AI 语音摘记:

使用 QQ 进行视频通话,并手动开启「AI 语音摘记」功能。

第二次 Dump(通话中):

再次执行相同命令,对比发现新增了两个活跃的录音客户端:

1
2
3
4
Active     Id Client(pid/uid) Session Port Id  S  Flags   Format Chn mask  SRate Source
yes 2556 19973/ 10375 19073 2896 A 0x000 00000001 00000010 16000 7
yes 2565 18492/ 10161 19129 2924 A 0x000 00000001 00000010 16000 7
yes 2564 18492/ 10161 19121 2923 A 0x100 00000001 00000010 16000 8

关键信息解读:

  • PID 19973 (uid 10375):采样率 16000Hz,单声道,Source=7 (VOICE_COMMUNICATION)
  • PID 18492 (uid 10161):采样率 16000Hz,单声道,Source=7/8 (VOICE_COMMUNICATION/REMOTE_SUBMIX)

定位 Package:

通过 PID 反查对应进程:

1
adb shell ps -A | grep -E "(19973|18492)"

执行结果:

1
2
u0_a161      18492  ...  com.coloros.accessibilityassistant
u0_a375 19973 ... com.tencent.mobileqq:video

分析发现:

  • PID 19973 对应 QQ 视频通话进程(符合预期)
  • PID 18492 对应 com.coloros.accessibilityassistant —— ColorOS 无障碍助手应用,即「AI 语音摘记」功能的宿主应用。该应用同时录制两条音频流,分别对应上行链路与下行链路

录制

com.coloros.accessibilityassistant 进行反编译,提取核心逻辑进行分析。

下行链路

捕获下行链路相对简单,具备 Root 权限的进程能够直接通过系统权限检查。经测试发现,直接使用 AudioSource.REMOTE_SUBMIX 创建的 AudioRecord 实例似乎只能捕获媒体音量,无法捕获 VOICE_COMMUNICATION 的输出。因此,这里采用一种间接的方式来创建 AudioRecord:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private fun initDownlink(): AudioRecord {
val format = AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(CHANNEL_CONFIG)
.setEncoding(AUDIO_FORMAT)
.build()

val rule = AudioMixingRule.Builder()
.addRule(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build(),
AudioMixingRule.RULE_MATCH_ATTRIBUTE_USAGE
)
.build()

val mix = AudioMix.Builder(rule)
.setFormat(format)
.setRouteFlags(AudioMix.ROUTE_FLAG_LOOP_BACK_RENDER)
.build()

val policy = AudioPolicy.Builder(mContext)
.addMix(mix)
.build()

Refine.unsafeCast<AudioManagerHidden>(mAudioManager).registerAudioPolicy(policy)

return policy.createAudioRecordSink(mix)
}

事实上,在 Android 13+,Scrcpy 也采用了 类似的方式 来捕获媒体输出,以实现在不影响正常音频输出的前提下进行并行捕获。不过该 issue 中也提到「Apps can opt out from being captured」,即应用可以选择不被捕获。因此在实际应用中,可能还需要辅以注入或 Hook 等手段来绕过这一限制。

上行链路

Google 在 Android 10 中引入了 共享音频输入 的限制。我们的目标是让正在进行 VoIP 通话的进程与录音进程能够同时捕获 VOICE_COMMUNICATION 音源。对于一加的语音摘记服务而言,实现这一点相对简单,因为无障碍服务天然处于该限制的豁免范围内。但对于通过 app_process 直接启动的进程,实现起来较为困难,因为我们无法将其注册为无障碍服务

直接创建 VOICE_COMMUNICATION 的 AudioRecord,在接通通话后启动,再 dump,发现被静默:

1
2
3
4
adb shell dumpsys media.audio_flinger | grep "^[[:space:]]*yes"
# Active Id Client(pid/uid) Session Port Id S Flags Format Chn mask SRate Source Server FrmCnt FrmRdy Sil Latency
yes 2579 30116/ 1000 19209 2959 A 0x000 00000001 00000010 16000 7 00009240 2560 0 s 0.06 t
yes 2573 27601/ 10375 19177 2945 A 0x000 00000001 00000010 16000 7 0009F100 960 0 n 0.06 t

通过阅读 Android 源码,音频捕获权限的判断逻辑位于 AudioPolicyService::updateUidStates_l 函数中。该函数实现了一套复杂的并发录音策略,即使进程拥有 Root 权限或 System UID,并且具备 BYPASS_CONCURRENT_RECORD_AUDIO_RESTRICTION 权限,仍然会被无障碍服务、Assistant 应用等检查逻辑拦截,无法获得录音豁免权。

既然常规途径行不通,那就只能采取更底层的手段——直接修改系统行为。

Inline Hook 方案

经过进一步分析,发现音频客户端的状态最终由 AudioPolicyService::setAppState_l 函数控制。该函数接收一个 app_state_t 类型的状态参数,决定客户端是否可以进行音频捕获:

1
2
3
4
5
typedef enum {
APP_STATE_IDLE = 0, /* client is idle: cannot capture */
APP_STATE_FOREGROUND, /* client has a foreground service: can capture */
APP_STATE_TOP, /* client has a visible UI: can capture and select use case */
} app_state_t;

当客户端被判定为需要静默时,系统会将其状态设置为 APP_STATE_IDLE,从而阻止其录音;而其他正常客户端则会被设置为 APP_STATE_FOREGROUNDAPP_STATE_TOP

基于这一机制,我们设计一个 Hook 方案:拦截 setAppState_l 函数,当检测到特定客户端(如 UID 为 0 的 Root 进程)的状态被设置为 APP_STATE_IDLE 时,将其修改为 APP_STATE_FOREGROUND,从而绕过并发录音限制。

理论上,可以在函数开头插入如下汇编指令来实现这一逻辑:

1
2
cmp x2, #0       ; if state == 0:
cinc x2, x2, eq ; state += 1

然而,直接在内联位置插入指令并不现实——函数体内的指令排列通常十分紧凑,很难找到足够的空间插入额外的指令。

寻找代码空洞

仔细分析目标库文件的 ELF 结构,或许能找到可用的内存空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
> llvm-readelf -l libaudiopolicyservice.so     

Elf file type is DYN (Shared object file)
Entry point 0x0
There are 13 program headers, starting at offset 64

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x02c98c 0x02c98c R 0x1000
LOAD 0x02d000 0x000000000002d000 0x000000000002d000 0x080d60 0x080d60 R E 0x1000
LOAD 0x0ae000 0x00000000000ae000 0x00000000000ae000 0x009c70 0x00a000 RW 0x1000
LOAD 0x0b8000 0x00000000000b8000 0x00000000000b8000 0x0000e8 0x000240 RW 0x1000
TLS 0x0ae000 0x00000000000add60 0x00000000000add60 0x000000 0x000018 R 0x8
DYNAMIC 0x0b66d0 0x00000000000b66d0 0x00000000000b66d0 0x000490 0x000490 RW 0x8
GNU_RELRO 0x0ae000 0x00000000000ae000 0x00000000000ae000 0x009c70 0x00a000 R 0x1
GNU_EH_FRAME 0x017eb0 0x0000000000017eb0 0x0000000000017eb0 0x002a14 0x002a14 R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x0
GNU_PROPERTY 0x000368 0x0000000000000368 0x0000000000000368 0x000020 0x000020 R 0x8
NOTE 0x000318 0x0000000000000318 0x0000000000000318 0x000050 0x000050 R 0x4
NOTE 0x000368 0x0000000000000368 0x0000000000000368 0x000020 0x000020 R 0x8

Section to Segment mapping:
Segment Sections...
00
01 .note.android.ident .note.android.pad_segment .note.gnu.build-id .note.gnu.property .dynsym .gnu.version .gnu.version_r .gnu.hash .dynstr .rela.dyn .relr.dyn .rela.plt .rodata .eh_frame_hdr .eh_frame
02 .text .plt
03 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt .relro_padding
04 .data .bss
05 .tbss
06 .dynamic
07 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt .relro_padding
08 .eh_frame_hdr
09
10 .note.gnu.property
11 .note.android.ident .note.android.pad_segment .note.gnu.build-id
12 .note.gnu.property
None .shstrtab .gnu_debugdata

注意到代码段的 LOAD 项:

1
LOAD           0x02d000 0x000000000002d000 0x000000000002d000 0x080d60 0x080d60 R E 0x1000

该段的文件大小(FileSiz)和内存大小(MemSiz)均为 0x080d60。由于操作系统通过 mmap 系统调用将 ELF 段映射到内存时,必须将内存大小对齐到页面大小(通常为 4KB)的整数倍,这就导致实际加载后,该段在内存中的前后可能存在未使用的空洞区域。

而对于我们的 Inline Hook 而言,只需要能放下几条指令的内存就够了!

我们编写一个程序来定位这些内存空洞,然后将 shellcode 写入其中。由于空洞位置与目标函数位于同一个 ELF 文件内,可以假设它们在单条 ARM64 分支指令(B 指令)的跳转范围内。这样,只需替换目标函数的一条指令,将其重定向到空洞区域执行 Hook 逻辑,即可完成整个 Hook 过程。

实际实现中还需要处理一些复杂情况,例如绕过 ARM64 的 BTI(Branch Target Identification)和 PAC(Pointer Authentication)安全保护机制,这里不再详细展开。

身份验证与偏移计算

Hook 成功生效后,还需要解决身份验证问题——确保只对特定的客户端(如 UID 为 0 的进程)放行,而不是无差别地允许所有客户端录音。

重点关注 AudioPolicyService::setAppState_l 的第一个参数,这是一个强引用 sp<AudioRecordClient>,其中包含了客户端的 AttributionSourceState 信息,只要确定了其中客户端 UID 的偏移量,就可以通过它来识别用户身份

先看外层,sp<T> 本质就是个只能指针,它的内存布局和指针完全一样,没什么好在意的

内层的 AudioRecordClient 就比较麻烦了,由于参数里边有 DeviceIdVector 等乱七八糟的东西,甚至还有 RefBase 基类的干扰,因此想主动调用其构造函数,通过内存染色来确定 attributionSource 的偏移量变得相当困难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class AudioRecordClient : public AudioPolicyService::AudioClient {
public:
AudioRecordClient(
const audio_attributes_t attributes,
const audio_io_handle_t io,
const audio_session_t session,
audio_port_handle_t portId,
const DeviceIdVector deviceIds,
const AttributionSourceState &attributionSource,
const uint32_t virtualDeviceId,
bool canBypassConcurrentPolicy,
bool shouldExemptFgListening, // if true, leave unsilenced when go to bkgd
wp<AudioPolicyService::AudioCommandThread> commandThread
) : AudioClient(attributes, io, attributionSource, session, portId, deviceIds),
attributionSource(attributionSource),
virtualDeviceId(virtualDeviceId),
startTimeNs(0),
canBypassConcurrentPolicy(canBypassConcurrentPolicy),
silenced(false),
mOpRecordAudioMonitor(OpRecordAudioMonitor::createIfNeeded(attributionSource, virtualDeviceId, attributes,
shouldExemptFgListening, commandThread)) {
}

~AudioRecordClient() override = default;

bool hasOp() const {
return mOpRecordAudioMonitor ? mOpRecordAudioMonitor->hasOp() : true;
}

const AttributionSourceState attributionSource; // attribution source of client
const uint32_t virtualDeviceId; // id of the virtual device associated with the audio device
nsecs_t startTimeNs;
const bool canBypassConcurrentPolicy;
bool silenced;

private:
sp<OpRecordAudioMonitor> mOpRecordAudioMonitor;
};

AudioRecordClient 继承自 AudioClient,而后者又虚继承自 RefBase。查看基类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class AudioClient : public virtual RefBase {
public:
AudioClient(const audio_attributes_t attributes,
const audio_io_handle_t io,
const AttributionSourceState &attributionSource,
const audio_session_t session,
audio_port_handle_t portId,
const DeviceIdVector deviceIds
) : attributes(attributes),
io(io),
attributionSource(attributionSource),
session(session),
portId(portId),
deviceIds(deviceIds),
active(false) {
}

~AudioClient() override = default;

const audio_attributes_t attributes; // source, flags ...
const audio_io_handle_t io; // audio HAL stream IO handle
const AttributionSourceState attributionSource; //client attributionsource
const audio_session_t session; // audio session ID
const audio_port_handle_t portId;
const DeviceIdVector deviceIds; // selected input device port IDs
bool active; // Playback/Capture is active or inactive
};

基类虚继承于 RefBase,这很好,这意味着在内存布局的前边不会再有其他成员干扰。虚继承的情况下,派生类对象的起始位置是 vptr,虚基类的成员被放到对象末尾,所以 AudioRecordClient 的内存布局大概长这样:

1
2
3
4
5
6
offset 0: vptr
offset 8: AudioPolicyService::AudioClient 的成员
...
offset n + 8: AudioRecordClient 自己的成员
...
offset x: RefBase 相关字段(vptr,成员……)

再回到 AudioPolicyService::AudioClient,这个类也有一个 attributionSource,但前面垫了 attributes 和 io 两个成员,来看看它们各自占多大:

首先是 audio_attributes_t

1
2
3
4
5
6
7
8
9
/* Audio attributes */
#define AUDIO_ATTRIBUTES_TAGS_MAX_SIZE 256
typedef struct {
audio_content_type_t content_type;
audio_usage_t usage;
audio_source_t source;
audio_flags_mask_t flags;
char tags[AUDIO_ATTRIBUTES_TAGS_MAX_SIZE]; /* UTF8 */
} __attribute__((packed)) audio_attributes_t; // sent through Binder;

audio_attributes_t 固定为 272 bytes,带了 __attribute__((packed)) 所以不会有对齐填充。并且 git blame 的上次修改时间是 2015 年,看起来很稳定。

再来看 audio_io_handle_t,其实就是 int 的一个别名,大小也固定。

所以最终的 offset 是 8(vptr)+ 272(attributes)+ 4(io)+ 4(padding)= 288 = 0x120,我们可以在这个位置读取到 AttributionSourceState

唯一需要注意的是,在 Android 13 及以前,attributionSource 成员的类型是指针(AttributionSourceState*),而 Android 14 起改成了值类型直接内联存储,需要多一次指针解引用:

这里我选择摆烂,只支持 Android 14+

接下来看看 AttributionSourceState 的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
parcelable AttributionSourceState {
/** The PID that is accessing the permission protected data. */
int pid = -1;
/** The UID that is accessing the permission protected data. */
int uid = -1;
/** The default device ID from where the permission protected data is read.
* @see Context#DEVICE_ID_DEFAULT
*/
int deviceId = 0;
/** The package that is accessing the permission protected data. */
@nullable @utf8InCpp String packageName;
/** The attribution tag of the app accessing the permission protected data. */
@nullable @utf8InCpp String attributionTag;
/** Unique token for that source. */
@nullable IBinder token;
/** Permissions that should be considered revoked regardless if granted. */
@nullable @utf8InCpp String[] renouncedPermissions;
/** The next app to receive the permission protected data. */
// TODO: We use an array as a workaround - the C++ backend doesn't
// support referring to the parcelable as it expects ctor/dtor
AttributionSourceState[] next;
}

再算内部偏移量,AttributionSourceState 在 C++ 侧同样是一个类,前面有 vptr,然后依次是 pid 和 uid,所以 uid 的偏移是 8 (vptr) + 4 (pid) = 12 = 0xc。因此从 AudioRecordClient 对象到 uid 的总偏移量是 0x120 + 0xc = 0x12c。

最终的汇编代码如下,backup 是被替换的原指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
arm64asm!(ops
// if *((int *) (client + 0x12c)) == 0 && state == 0 {
// state = 1;
// }
; ldr x17, [x1]
; ldr w17, [x17, #0x12c] // load uid from: 0x12c offset + 0x8 vptr + 0x4 pid
; cbnz w17, >skip
; cmp x2, #0
; cinc x2, x2, eq
; skip:
;; ops.extend(backup)
; b #offset as i32
);

在做的过程中间踩了一次坑,起初直接 ldr x17, [x1, #0x12c] 来读取 uid,没考虑到 itanium C++ 的调用约定sp<T> 具有非平凡的构造和析构函数,因此参数是通过指针而非值拷贝传递的,需要多做一次解引用,也就是代码开头的 ldr x17, [x1]

Hook 生效后,再次进行测试。当 VoIP 通话正在进行时,启动一个录音进程,执行以下命令查看音频客户端状态:

1
adb shell dumpsys media.audio_flinger | grep "^[[:space:]]*yes"

输出结果显示,UID 为 0 的进程(PID 312)成功获得了录音权限,与 QQ 通话进程(PID 31253)同时处于活跃状态:

1
2
3
# Active      Id Client(pid/uid) Session Port Id  S  Flags   Format Chn mask  SRate Source   Server FrmCnt FrmRdy Sil   Latency
yes 59 31253/ 10375 5919577 48 A 0x000 00000001 00000010 16000 7 0001E640 960 0 n 0.06 t
yes 60 312/ 0 5919585 50 A 0x000 00000001 00000010 16000 7 00007580 2560 0 n 0.06 t

这表明 Hook 方案成功绕过了 Android 的并发录音限制,允许 Root 进程与 VoIP 应用同时捕获音频输入。

绕过并发捕获限制的 PoC 已开源到 GitHub:

Mufanc/aap-bypass-poc - GitHub

混流

未完待续……