Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop
SystemProperties
__system_property_get
这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!
系统实现分析 首先,我们有这几个问题需要解答:
ro.
开头的属性是只读的,只能被设置一次,系统是怎么实现的?系统里的属性那么多,难免会有一些 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) { 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_codename
、dex2oat-flags
这些我们用过的属性的名字的一段(以点分割)。点开 ContextsSerialized
里面是一些字典树之类的无趣的数据结构内容;而我们要明白它们的作用,其实可以看看没有这个文件时(如低版本系统)用的 ContextsSplit
的作用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 bool ContextsSplit::InitializeProperties() { 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 { 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(¤t->children, memory_order_relaxed); if (children_offset != 0 ) { root = to_prop_bt(¤t->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(¤t->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(¤t->prop, memory_order_relaxed);if (prop_offset != 0 ) { return to_prop_info(¤t->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(¤t->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 return currentNode } return null
而在这个混合结构中,节点 node
的 left
right
和 node
本身其实是平级关系,它们的父节点是同一个,child
才是节点的子节点。 而理解了这一点之后,要看懂上面这幅图也就不难了。 还是以 ro.secure=1
来举例。首先对属性名 ro.secure
以点分割,得 ro
和 secure
,先从树上查找到 ro
,而 ro
的左右节点 sys
和 net
与 ro
是平级关系。接着,从 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" ; } ProcessKernelDt(); ProcessKernelCmdline(); ProcessBootconfig(); 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 监听写入然后手动更新解决,但是是体力活。但是,我们的确发明了我们自己的魔法!至于这个好玩的新东西能做什么,可以发挥你的想象力 :)