Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

系统实现分析

首先,我们有这几个问题需要解答:

  1. ro. 开头的属性是只读的,只能被设置一次,系统是怎么实现的?
  2. 系统里的属性那么多,难免会有一些 app 读取不了的敏感属性,系统是怎么限制我们读取的?

Linus 大神有句话很出名:“Read the F*cking Source Code。”想要解答这些问题,阅读源码是必须的。

Property Context

我们通常使用 __system_properties_get 这个 API 去获取系统属性,点开这个函数看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static SystemProperties system_properties;

__BIONIC_WEAK_FOR_NATIVE_BRIDGE
int __system_property_get(const char* name, char* value) {
return system_properties.Get(name, value);
}

int SystemProperties::Get(const char* name, char* value) {
const prop_info* pi = Find(name);
if (pi != nullptr) {
return Read(pi, nullptr, value);
} else {
value[0] = 0;
return 0;
}
}

先去调用 Find() 函数找到一个叫做 prop_info* 的东西,然后从里面读出值来。

1
2
3
4
5
6
7
8
9
10
11
const prop_info* SystemProperties::Find(const char* name) {
if (!initialized_) {
return nullptr;
}
prop_area* pa = contexts_->GetPropAreaForName(name);
if (!pa) {
async_safe_format_log(ANDROID_LOG_WARN, "libc", "Access denied finding property \"%s\"", name);
return nullptr;
}
return pa->find(name);
}

看到这里你是不是一头雾水,这函数有个 initialized_ 一看就是要初始化的,谁去初始化的?contexts_ prop_area 又是什么?这里就不卖关子了,在 libc.so 被加载的时候,它的 .init.array 段里面的函数会被自动执行,而有 __attribute__((constructor(1)) 的函数就会被放到 .init.array 段从而被自动执行。里面兜兜转转,会调用一个 __system_properties_init() 函数,而这个函数正是初始化上面这些东西的关键所在。

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
#define PROP_FILENAME "/dev/__properties__"
int __system_properties_init() {
return system_properties.Init(PROP_FILENAME) ? 0 : -1;
}

bool SystemProperties::Init(const char* filename) {
// This is called from __libc_init_common, and should leave errno at 0 (http://b/37248982).
// ...
strcpy(property_filename_, filename);
if (is_dir(property_filename_)) {
if (access("/dev/__properties__/property_info", R_OK) == 0) {
contexts_ = new (contexts_data_) ContextsSerialized();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
} else {
contexts_ = new (contexts_data_) ContextsSplit();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
}
} else {
contexts_ = new (contexts_data_) ContextsPreSplit();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
}
initialized_ = true;
return true;
}

名词越来越多了……先别急着头晕,聪明的你肯定发现,这里出现了两个文件路径:/dev/__properties__/dev/__properties__/property_info。连上手机 ls 一下看看:

1
2
3
4
5
6
7
8
vince:/ # ls -lZ /dev/__properties__
total 1376
-r--r--r-- 1 root root u:object_r:properties_serial:s0 131072 1970-07-08 15:56 properties_serial
-r--r--r-- 1 root root u:object_r:property_info:s0 62540 1970-07-08 15:56 property_info
-r--r--r-- 1 root root u:object_r:aac_drc_prop:s0 131072 1970-07-08 15:56 u:object_r:aac_drc_prop:s0
-r--r--r-- 1 root root u:object_r:aaudio_config_prop:s0 131072 1970-07-08 15:56 u:object_r:aaudio_config_prop:s0
-r--r--r-- 1 root root u:object_r:ab_update_gki_prop:s0 131072 1970-07-08 15:56 u:object_r:ab_update_gki_prop:s0
-r--r--r-- 1 root root u:object_r:adbd_config_prop:s0 131072 1970-07-08 15:56 u:object_r:adbd_config_prop:s0

而查看 /dev/__properties__/property_info 这个文件是一堆二进制数据。细心的你可能已经发现了我们上面的 u:object_r:adbd_config_prop:s0 这些奇怪的文件名,同时还有 release_or_codenamedex2oat-flags 这些我们用过的属性的名字的一段(以点分割)。点开 ContextsSerialized 里面是一些字典树之类的无趣的数据结构内容;而我们要明白它们的作用,其实可以看看没有这个文件时(如低版本系统)用的 ContextsSplit 的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool ContextsSplit::InitializeProperties() {
// Use property_contexts from /system & /vendor, fall back to those from /
if (access("/system/etc/selinux/plat_property_contexts", R_OK) != -1) {
if (!InitializePropertiesFromFile("/system/etc/selinux/plat_property_contexts")) {
return false;
}
if (access("/vendor/etc/selinux/vendor_property_contexts", R_OK) != -1) {
InitializePropertiesFromFile("/vendor/etc/selinux/vendor_property_contexts");
} else {
// Fallback to nonplat_* if vendor_* doesn't exist
InitializePropertiesFromFile("/vendor/etc/selinux/nonplat_property_contexts");
}
} else {
// ... 省略 其他文件路径
}
return true;
}

找一部手机,看看 /system/etc/selinux/plat_property_contexts 是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#line 1 "system/sepolicy/private/property_contexts"
##########################
# property service keys
#
#
net.rmnet u:object_r:net_radio_prop:s0
# ...
net. u:object_r:system_prop:s0
dev. u:object_r:system_prop:s0
ro.runtime. u:object_r:system_prop:s0
ro.runtime.firstboot u:object_r:firstboot_prop:s0
hw. u:object_r:system_prop:s0
ro.hw. u:object_r:system_prop:s0
sys. u:object_r:system_prop:s0
# ...

左边的 ro. net. 很明显是属性的前缀,右边的 u:object_r:system_prop:s0 不是刚好对应我们上面看到的文件名吗?
找一个文件 cat 一下,没错,属性的名字和值都在里面。而实际上,上面的 prop_area 也是根据属性名搜索到对应的文件位置然后将它 mmap 到内存中获得的。
而我们再回头看看这个文件名,是不是很熟悉?如果你有了解过 SELinux 这一 Android 安全模型的重要组成部分,你很容易就会发现这个文件名就是 SELinux 中所谓的 Context 的格式。而再回头看上面 ls -Z 的输出,你会发现里面所有名字是这个格式的文件,它们的名字和“SELinux 上下文”都一样。

自此,我们得到了第一个结论:
Android 系统按照 Context 将属性们分为一个个“组”,获取属性值时,首先通过名字查询到这个属性所在的组,然后再在对应的文件里面查询。

Property Area

回到我们刚刚的 SystemProperties::Find() 这个函数里,它拿到对应的 prop_area 也就是所谓的“属性组”之后,又调用了它的 find() 函数在里面搜索,点开看看:

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
39
40
41
42
43
const char* remaining_name = name;
prop_bt* current = trie;
while (true) {
const char* sep = strchr(remaining_name, '.');
const bool want_subtree = (sep != nullptr);
const uint32_t substr_size = (want_subtree) ? sep - remaining_name : strlen(remaining_name);
if (!substr_size) {
return nullptr;
}
prop_bt* root = nullptr;
uint_least32_t children_offset = atomic_load_explicit(&current->children, memory_order_relaxed);
if (children_offset != 0) {
root = to_prop_bt(&current->children);
} else if (alloc_if_needed) {
uint_least32_t new_offset;
root = new_prop_bt(remaining_name, substr_size, &new_offset);
if (root) {
atomic_store_explicit(&current->children, new_offset, memory_order_release);
}
}
if (!root) {
return nullptr;
}
current = find_prop_bt(root, remaining_name, substr_size, alloc_if_needed);
if (!current) {
return nullptr;
}
if (!want_subtree) break;
remaining_name = sep + 1;
}
uint_least32_t prop_offset = atomic_load_explicit(&current->prop, memory_order_relaxed);
if (prop_offset != 0) {
return to_prop_info(&current->prop);
} else if (alloc_if_needed) {
uint_least32_t new_offset;
prop_info* new_info = new_prop_info(name, namelen, value, valuelen, &new_offset);
if (new_info) {
atomic_store_explicit(&current->prop, new_offset, memory_order_release);
}
return new_info;
} else {
return nullptr;
}

prop_bt prop_info 又是什么?这一堆什么 subtree left right 看着就像一棵树,它的结构是什么样的呢,其实注释里已经写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Properties are stored in a hybrid trie/binary tree structure.
// Each property's name is delimited at '.' characters, and the tokens are put
// into a trie structure. Siblings at each level of the trie are stored in a
// binary tree. For instance, "ro.secure"="1" could be stored as follows:
//
// +-----+ children +----+ children +--------+
// | |-------------->| ro |-------------->| secure |
// +-----+ +----+ +--------+
// / \ / |
// left / \ right left / | prop +===========+
// v v v +-------->| ro.secure |
// +-----+ +-----+ +-----+ +-----------+
// | net | | sys | | com | | 1 |
// +-----+ +-----+ +-----+ +===========+

这是一颗二叉树和字典树的混血儿。在传统的二叉树结构中,假如一个节点 node 拥有 left right 两个成员:node { var left, right },其中的 left right 都是 node 这个节点的孩子;在传统二叉树的搜索中,如果给定一个 key,那么流程大概会是这样的伪代码:

1
2
3
4
5
6
7
8
9
while (currentNode != null) {
if (key < currentNode.key)
currentNode = currentNode.left
else if (key > currentNode.key)
currentNode = currentNode.right
else // key == currentNode.key
return currentNode
}
return null

而在这个混合结构中,节点 nodeleft rightnode 本身其实是平级关系,它们的父节点是同一个,child 才是节点的子节点。
而理解了这一点之后,要看懂上面这幅图也就不难了。
还是以 ro.secure=1 来举例。首先对属性名 ro.secure 以点分割,得 rosecure,先从树上查找到 ro,而 ro 的左右节点 sysnetro 是平级关系。接着,从 ro 的 child 指向的节点里查找 secure,很明显第一个就是。找到了之后,根据节点的 prop 值指向的一个 prop_info 结构就能读取到值。
因此 prop_bt 其实只是用来树上的一个个节点,而它指向的 prop_info 才是真正存放属性值的地方。

属性区域的初始化

我们还有一个问题:我们上面的操作都是在 /dev/__properties__ 这个文件夹下面玩的,那这个文件夹和里面的文件是哪来的呀?
其实早在 init 进程启动时,它就调用了 PropertyInit() 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void PropertyInit() {
mkdir("/dev/__properties__", S_IRWXU | S_IXGRP | S_IXOTH);
CreateSerializedPropertyInfo();
if (__system_property_area_init()) {
LOG(FATAL) << "Failed to initialize property area";
}
if (!property_info_area.LoadDefaultPath()) {
LOG(FATAL) << "Failed to load serialized property info file";
}
// If arguments are passed both on the command line and in DT,
// properties set in DT always have priority over the command-line ones.
ProcessKernelDt();
ProcessKernelCmdline();
ProcessBootconfig();
// Propagate the kernel variables to internal variables
// used by init as well as the current required properties.
ExportKernelBootProps();
PropertyLoadBootDefaults();
}

这个函数先是创建了 /dev/__properties__ 随后将 split context 的一堆文件“编译”成二进制树的形式并序列化到 property_info 中以加速其他进程的启动速度,而后将内核命令行、default.prop、build.prop 等的内容加载进来,然后我们的属性区域就有值了!

再往后看,init 进程还会调用一个 StartPropertyService() 的东西,这个 property service 又是什么呢,这里我就不放源码了,它建立了一个 Socket Server,__system_properties_set() 时就会去通过 socket 连上它,然后这个 property service 再进行实际的写入。

我们上面提到的鉴权的问题,也已经有了答案:对于获取属性,因为每个属性都有对应的 context,进程如果权限不够,尝试打开这个 context 对应文件时就会被 SELinux 直接挡住;而对于写入属性,其他进程尝试写入属性实际都是通过 socket 连上运行在 init 的一个 service,由这个 service 来做鉴权、阻止只读属性被覆盖的检查以及最终的写入操作。

其实到这里,属性系统还有很多内容没有介绍,比如 long value、persist props 这些东西,但由于篇幅限制,只能到这里了,有兴趣的同学可以自己查阅源代码哦 :)

魔法时刻

绕过 ro 属性的只读限制

上面提到,ro 属性的检查是在 init 进程手动做的,那如果我们直接操作对应内存,是不是就能绕开这个限制呢?事实上,由于这些文件的权限以及 SELinux 相关限制,在 init 进程以外你在正常的 Android 系统里几乎没办法写入这些文件;但如果你的进程有足够高的权限,这是可行的!事实上,Magisk 的 resetprop 就是这么实现的,你甚至还可以做到更神奇的事,比如删除属性!

属性的“隔离”操作

什么是“隔离”?通常来说,就是 14+7

我们想要的“隔离”效果大概就是,两个进程在同一时刻对同一个属性获取到不同的值。

Android 系统原生是不支持这种操作的,但是我们可以使用黑魔法来完成这一点。

在 Linux 中,有一个概念叫做 namespace,它就是一种资源隔离方案。在它可以隔离的资源中,有一种东西,叫 mount。

什么概念:简单点说,如果两个进程拥有不一样的 mount namespace,进程 A 搞破坏把 /data 给 mount 到了 /system,但是这一切进程 B 是看不见的,它所看见的 /system 还是原来那个。

结合我们上面提到的,属性其实是存在一堆“一次性”文件里的这一点,我们有了一个大胆的想法:首先 unshare 分离 namespace,把某个 context 对应的文件给复制一份,然后把这个复制品 bind mount 到原来的文件,直接修改这个复制品里的属性值,就能实现只让一个进程看见对应属性的修改!

我们还可以改变一下,实现一个可以回滚的 resetprop:在 zygote 启动之前和 init 同命名空间的某个高权限进程里把复制品 bind mount 回原文件,然后修改里面的内容,之后启动的任何进程(包括 zygote)都会看见被修改后的属性;然后在需要回滚修改的 app 进程里 unmount 这些文件,随后因为有些文件可能已经在 zygote 里被提前 mmap 过了,我们需要把它们 unmap 再重新 mmap 以确保 app 读到的永远是未被修改的真实值,然后我们就实现了属性修改的回滚!

我已经在自己的 Magisk fork 上进行了简单测试,确认可行,源码在这里:https://github.com/canyie/Magisk/commit/d50a060a23727236b36d79afc696632519247fd8

当然这个方法有一些小小缺陷,比如我们 hijack 一个 context 后所有归属于这个 context 的其他属性都会受到影响,之后的 setprop 只会对进行了回滚的进程生效,因为 init 已经打开了所有文件,只会对原文件进行写入操作。这个问题可以通过 inotify 监听写入然后手动更新解决,但是是体力活。但是,我们的确发明了我们自己的魔法!至于这个好玩的新东西能做什么,可以发挥你的想象力 :)