三大Root框架通杀检测与反检测方法分析

admin 2026-05-14 14:23:48 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文分析安卓三大Root框架(Magisk、KernelSU、APatch)的通杀检测与反检测方法。Duck-Detector通过appzygote进程查询SELinux策略,检测Root相关上下文存在性和访问权限。DirtySepolicy提供精简版检测PoC,直接调用FrameworkAPI查询脏策略规则。KernelSU采用selinuxhide机制,为应用UID呈现备份策略以实现隐藏。文章详细解析了检测原理、代码实现及反制方案。 综合评分: 85 文章分类: 移动安全,漏洞分析,安全工具,逆向分析,恶意软件


cover_image

三大Root框架通杀检测与反检测方法分析

原创

非虫 非虫

软件安全与逆向分析

2026年5月12日 12:21 湖北

在小说阅读器读本章

去阅读

最近比较流行通杀!前面讲过两篇Linux的通杀漏洞,

这次轮到安卓了。这一次又是Duck-Detector在搞事,老版本这个检测器C语言编写的,现在新版本使用C++重构开源了,挺值得学习的。

Duck这次公开了一个检测方法。可用于全版本Magisk检测,实际它也可以用于对KernelSUAPatch的检测。随后LSPosed沿用同一思路,整理出更精简的DirtySepolicy检测器。

与此同时,KernelSUAPatch社区也亮出了反检测的思路与代码。接下来,本篇主要介绍一下这个通杀检测的原理,以及三种过这个检测的方法。

文章作者:非虫([email protected])

Duck-Detector检测原理

这次检测的核心思路,是把探针下沉到app_zygote进程,借助它对SELinux策略的查询能力,跳过传统的包名、进程名与文件路径扫描,直接向内核发问两件事:目标上下文是否存在、目标访问是否被放行。前者通过写入/sys/fs/selinux/context完成,后者则依赖android.os.SELinux.checkSELinuxAccess接口。

预加载类是整条检测链路的起点。当currentUidappInfo.uid不一致,或Native库未能加载时,探针会直接产出一份可解析的失败快照,把”载体失效”与”发现Root痕迹”严格区分开来,避免环境异常被误判为命中。

classAppZygotePreload : ZygotePreload {
overridefundoPreload(appInfo: ApplicationInfo) {
val result = runCatching {
val currentUid = Os.getuid()
if (currentUid != appInfo.uid) {
                fallbackPayload("UID mismatch: $currentUid != app uid ${appInfo.uid}.")
            } elseif (!SelinuxContextValidityBridge.isNativeLibraryLoaded) {
                fallbackPayload("SELinux native library unavailable.")
            } else {
                SelinuxContextValidityBridge.nativeCollectContextValiditySnapshot()
                    .ifBlank { fallbackPayload("SELinux native snapshot payload was empty.") }
            }
        }.getOrElse { throwable ->
            fallbackPayload(throwable.message ?: "SELinux app zygote preload failed.")
        }
        SelinuxContextValidityBridge.setPreloadedRawData(result)
    }
}

Native层首先声明被检测的上下文与控制组。其中u:r:ksu:s0u:object_r:ksu_file:s0u:object_r:magisk_file:s0是Root相关类型,剩余四个上下文则用于自检oracle的可信度。

constexprconstchar *kSelinuxContextPath = "/sys/fs/selinux/context";
constexprconstchar *kExpectedCarrierType = "app_zygote";
constexprconstchar *kKsuContext = "u:r:ksu:s0";
constexprconstchar *kKsuFileContext = "u:object_r:ksu_file:s0";
constexprconstchar *magiskFileContext = "u:object_r:magisk_file:s0";
constexprconstchar *kNegativeControlContext = "u:r:duckdetector_context_oracle_sentinel:s0";
constexprconstchar *kStockFileControlContext = "u:object_r:system_data_file:s0";
constexprconstchar *kNegativeFileControlContext =
"u:object_r:duckdetector_context_oracle_sentinel_file:s0";

/sys/fs/selinux/context的职责是将输入上下文转换为内核sid。只要当前策略认可目标type,write()便会返回非负值,这正是判定”上下文存在”的唯一信号。

ContextCheckResult check_context_validity(constchar *context){
    ContextCheckResult result;
int fd = open(kSelinuxContextPath, O_RDWR | O_CLOEXEC);
if&nbsp;(fd <&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; result.valid =&nbsp;false;
&nbsp; &nbsp; &nbsp; &nbsp; result.note = std::string("Unavailable: ") + context +&nbsp;" errno="&nbsp;+&nbsp;strerror(errno);
return&nbsp;result;
&nbsp; &nbsp; }
constssize_t&nbsp;written =&nbsp;write(fd, context, std::strlen(context) +&nbsp;1);
constint&nbsp;error = errno;
close(fd);
if&nbsp;(written >=&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; result.valid =&nbsp;true;
return&nbsp;result;
&nbsp; &nbsp; }
&nbsp; &nbsp; result.valid =&nbsp;false;
&nbsp; &nbsp; result.note = std::string("Unavailable: ") + context +&nbsp;" errno="&nbsp;+&nbsp;strerror(error);
return&nbsp;result;
}

为防止偶然误判,检测链路对每个Root相关上下文都重复查询两次。只要两次结果不一致即进入UNSTABLE_RESULTS状态,这样即便Root管理器只拦截一次、或在不同入口给出不同答案,依然能够被识别。控制组中”正常上下文应通过、哨兵上下文应失败”四组判定全部符合预期之后,Root相关结果才会被采信;否则视为自检失败,所有KSU结论降级为附加信息。

checkSELinuxAccess走的则是访问向量计算的路径。Duck-Detectorapp_zygote的身份充当查询者,覆盖以下四组访问关系:

| 检测点 | source | target | class | perm | 严重级 | | — | — | — | — | — | — | | 通用脏策略 | system_server | system_server | process | execmem | WARNING | | Magisk binder | untrusted_app | magisk | binder | call | WARNING | | KernelSU binder | untrusted_app | ksu | binder | call | WARNING | | LSPosed file read | untrusted_app | lsposed_file | file | read | DANGER |

正向控制(app_zygoteisolated_appprocess dyntransition)与反向控制(到哨兵文件类型)同样会做两次稳定性确认。其中MagiskKernelSUbinder call属于辅助证据,而lsposed_file的读权限才是LSPosed残留的直接证据。Native快照只负责生产数据,展示层不再重新查询策略。

DirtySepolicy独立PoC

DirtySepolicy将同一检测入口浓缩为一个最小化的App。它舍弃了Duck的完整展示层与Native快照包装,重点是证明app_zygote可以直接调用SELinux.checkSELinuxAccess查询全局策略。配置层面,isolatedProcess配合useAppZygote会触发应用私有的Zygote,再由zygotePreloadName指定预加载类,载体便稳稳落在app_zygote之上。

<application
android:zygotePreloadName="org.lsposed.dirtysepolicy.AppZygote">
<service
android:name=".DirtySepolicyService"
android:isolatedProcess="true"
android:useAppZygote="true"&nbsp;/>
</application>

与Duck一样,PoC也把载体可信性的校验置于检测之前:先确认UID未被换掉、当前上下文确实是app_zygote,并验证getContext()getPidContext()/proc/self三处文件上下文完全一致。自检通过之后,再直接调用Framework API完成查询。

if&nbsp;(SELinux.checkSELinuxAccess("u:r:system_server:s0",&nbsp;"u:r:system_server:s0",&nbsp;"process",&nbsp;"execmem")) {
&nbsp; &nbsp; sb.append("system_server can execmem; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:fsck_untrusted:s0",&nbsp;"u:r:fsck_untrusted:s0",&nbsp;"capability",&nbsp;"sys_admin")) {
&nbsp; &nbsp; sb.append("neverallow violated; ");
}
if&nbsp;(Build.TYPE.equals("user") && SELinux.checkSELinuxAccess("u:r:shell:s0",&nbsp;"u:r:su:s0",&nbsp;"process",&nbsp;"transition")) {
&nbsp; &nbsp; sb.append("found AOSP su in user build; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:adbd:s0",&nbsp;"u:r:adbroot:s0",&nbsp;"binder",&nbsp;"call")) {
&nbsp; &nbsp; sb.append("found adb_root; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:untrusted_app:s0",&nbsp;"u:r:magisk:s0",&nbsp;"binder",&nbsp;"call")) {
&nbsp; &nbsp; sb.append("found Magisk; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:untrusted_app:s0",&nbsp;"u:object_r:ksu_file:s0",&nbsp;"file",&nbsp;"read")) {
&nbsp; &nbsp; sb.append("found KernelSU; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:untrusted_app:s0",&nbsp;"u:object_r:lsposed_file:s0",&nbsp;"file",&nbsp;"read")) {
&nbsp; &nbsp; sb.append("found LSPosed; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:untrusted_app:s0",&nbsp;"u:object_r:xposed_data:s0",&nbsp;"file",&nbsp;"read")) {
&nbsp; &nbsp; sb.append("found Xposed; ");
}
if&nbsp;(SELinux.checkSELinuxAccess("u:r:zygote:s0",&nbsp;"u:object_r:adb_data_file:s0",&nbsp;"dir",&nbsp;"search")) {
&nbsp; &nbsp; sb.append("found ZygiskNext; ");
}

相比Duck,PoC的查询面更宽,涵盖了fsck_untrustedsys_adminshellsu的迁移、adbdadbrootbinder call,以及Xposed与ZygiskNext的残留特征。它真正的价值并不在UI,而是揭示了一个事实:只要app_zygote能向内核问到当前策略,脏规则便会化作可读信号,用户态进程根本绕不开这道查询口。

KernelSU运行时过检测

KernelSU上游新增selinux_hide之后,过检测的思路便从”清掉策略”演变为”给应用UID呈现另一份备份策略”。当前策略仍然保留Root所需的全部规则,但应用态的查询入口被悄悄转向注入之前的策略副本。

backup_sepolicy在KernelSU追加自身域与文件类型之前便已生成。如此一来,后续隐藏逻辑无需伪造任何字符串,只需拿一张真实的旧策略表重新计算答案。

系统UID仍然走原始的context写入口,应用UID则改走backup_sepolicy。当检测器提交u:r:ksu:s0u:object_r:ksu_file:s0时,旧策略里并不存在对应类型,转换随即失败,检测侧看到的便是”上下文不存在”。

staticssize_tmy_write_context(struct&nbsp;file *file,&nbsp;char&nbsp;*buf,&nbsp;size_t&nbsp;size)
{
if&nbsp;(likely(current_uid().val <&nbsp;10000)) {
return&nbsp;orig_context_write(file, buf, size);
&nbsp; &nbsp; }
char&nbsp;*canon =&nbsp;NULL;
&nbsp; &nbsp; u32 sid, len;
ssize_t&nbsp;length;

&nbsp; &nbsp; length = security_context_to_sid_with_policy(backup_sepolicy, buf, size, &sid, SECSID_NULL, GFP_KERNEL);
if&nbsp;(length)
goto&nbsp;out;
&nbsp; &nbsp; length = security_sid_to_context_with_policy(backup_sepolicy, sid, &canon, &len);
if&nbsp;(length)
goto&nbsp;out;
memcpy(buf, canon, len);
&nbsp; &nbsp; length = len;
out:
&nbsp; &nbsp; kfree(canon);
return&nbsp;length;
}

checkSELinuxAccess最终触发的是访问向量计算。应用UID的输入被引导至backup_sepolicy重新计算,因此untrusted_appksubinder call,以及untrusted_appksu_filefile read,不再按当前策略给出结果。

staticssize_tmy_write_access(struct&nbsp;file *file,&nbsp;char&nbsp;*buf,&nbsp;size_t&nbsp;size)
{
if&nbsp;(likely(current_uid().val <&nbsp;10000)) {
return&nbsp;orig_access_write(file, buf, size);
&nbsp; &nbsp; }
/* 省略参数解析与错误处理 */
&nbsp; &nbsp; length = security_context_to_sid_with_policy(backup_sepolicy, scon,&nbsp;strlen(scon),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&ssid, SECSID_NULL, GFP_KERNEL);
if&nbsp;(length)&nbsp;goto&nbsp;out;
&nbsp; &nbsp; length = security_context_to_sid_with_policy(backup_sepolicy, tcon,&nbsp;strlen(tcon),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&tsid, SECSID_NULL, GFP_KERNEL);
if&nbsp;(length)&nbsp;goto&nbsp;out;

&nbsp; &nbsp; security_compute_av_user_with_policy(backup_sepolicy, ssid, tsid, tclass, &avd);

&nbsp; &nbsp; length = scnprintf(buf, SIMPLE_TRANSACTION_LIMIT,&nbsp;"%x %x %x %x %u %x",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;avd.allowed,&nbsp;0xffffffff, avd.auditallow,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;avd.auditdeny, avd.seqno, avd.flags);
return&nbsp;length;
}

除此之外,setprocattr current入口同样被纳入隐藏面,目标上下文统一交由backup_sepolicy解析,以防检测器从/proc/self/attr/current一侧绕开selinuxfs查询。

运行时方案的优势在于不必修改设备内核源码。具体做法是:通过kallsyms_lookup_name("write_op")定位selinuxfs的写操作表,再用ksu_patch_textSEL_CONTEXTSEL_ACCESS两个槽位替换为自定义函数,同时挂上selinux_setprocattr的LSM hook。

selinux_write_op = kallsyms_lookup_name("write_op");
context_write = &selinux_write_op[SEL_CONTEXT];
orig_context_write = *context_write;
ksu_patch_text(context_write, &my_write_context,&nbsp;sizeof(my_write_context),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;KSU_PATCH_TEXT_FLUSH_DCACHE);

access_write = &selinux_write_op[SEL_ACCESS];
orig_access_write = *access_write;
ksu_patch_text(access_write, &my_write_access,&nbsp;sizeof(my_write_access),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;KSU_PATCH_TEXT_FLUSH_DCACHE);

ksu_lsm_hook(&selinux_setprocattr_hook);

管理器侧的”隐藏SELinux修改”开关最终对应到KSU_FEATURE_SELINUX_HIDE这一特性位。开启之后,应用UID所见即为备份策略的答案;关闭之后,selinuxfs入口回归原始实现。截图中Duck的Danger显示为0,恰好与该功能的工作面相互印证。

内核源码级过检测补丁

Andrea仓库里的补丁直接动了security/selinux/ss/services.c。如果说运行时方案改的是selinuxfs入口,那么源码级方案改的便是SELinux服务函数本身:上下文解析与访问向量计算,统统在内核源码层被过滤。

补丁优先使用KernelSU上游已经暴露的backup_sepolicy;若该符号不存在,则回退到静态规则表与隐藏type表。静态表所覆盖的,恰好正是DirtySepolicy与Duck所查询的访问关系。

externstructselinux_policy&nbsp;*backup_sepolicy&nbsp;__attribute__((weak));

staticconststructselinux_hidden_av_ruleselinux_hidden_av_rules[] =&nbsp;{
&nbsp; &nbsp; {&nbsp;"system_server",&nbsp;"system_server",&nbsp;"process",&nbsp;"execmem"&nbsp;},
&nbsp; &nbsp; {&nbsp;"fsck_untrusted",&nbsp;"fsck_untrusted",&nbsp;"capability",&nbsp;"sys_admin"&nbsp;},
&nbsp; &nbsp; {&nbsp;"shell",&nbsp;"su",&nbsp;"process",&nbsp;"transition"&nbsp;},
&nbsp; &nbsp; {&nbsp;"adbd",&nbsp;"adbroot",&nbsp;"binder",&nbsp;"call"&nbsp;},
&nbsp; &nbsp; {&nbsp;"untrusted_app",&nbsp;"magisk",&nbsp;"binder",&nbsp;"call"&nbsp;},
&nbsp; &nbsp; {&nbsp;"untrusted_app",&nbsp;"ksu",&nbsp;"binder",&nbsp;"call"&nbsp;},
&nbsp; &nbsp; {&nbsp;"untrusted_app",&nbsp;"ksu_file",&nbsp;"file",&nbsp;"read"&nbsp;},
&nbsp; &nbsp; {&nbsp;"untrusted_app",&nbsp;"lsposed_file",&nbsp;"file",&nbsp;"read"&nbsp;},
&nbsp; &nbsp; {&nbsp;"untrusted_app",&nbsp;"xposed_data",&nbsp;"file",&nbsp;"read"&nbsp;},
&nbsp; &nbsp; {&nbsp;"zygote",&nbsp;"adb_data_file",&nbsp;"dir",&nbsp;"search"&nbsp;},
};

staticconstchar&nbsp;*&nbsp;const&nbsp;selinux_hidden_context_types[] = {
"su",&nbsp;"adbroot",&nbsp;"ksu",&nbsp;"ksu_file",
"magisk",&nbsp;"magisk_file",&nbsp;"lsposed_file",&nbsp;"xposed_data",
};

过滤逻辑只对app_zygote生效。系统服务与内核内部的正常计算依旧沿用原策略,从而将对系统运行的影响降到最低;检测器恰好倚仗app_zygote,因此正好落入过滤分支。

staticboolselinux_hidden_av_query_caller(struct&nbsp;policydb *policydb,
struct&nbsp;sidtab *sidtab)
{
structcontext&nbsp;*ccontext;
constchar&nbsp;*ctype;

&nbsp; &nbsp; ccontext = sidtab_search(sidtab, current_sid());
if&nbsp;(!ccontext)
returnfalse;
&nbsp; &nbsp; ctype = sym_name(policydb, SYM_TYPES, ccontext->type -&nbsp;1);
return&nbsp;ctype && !strcmp(ctype,&nbsp;"app_zygote");
}

app_zygote提交ksuksu_filemagisk_file等隐藏type时,补丁会让security_context_to_sid_core提前返回-EINVAL,Duck的/sys/fs/selinux/context检测因此只能得到”上下文无效”的回应。

staticboolselinux_hide_context_validity_query(struct&nbsp;policydb *policydb,
struct&nbsp;sidtab *sidtab,
constchar&nbsp;*scontext,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; u32 scontext_len)
{
unsignedint&nbsp;i;

if&nbsp;(!selinux_hidden_av_query_caller(policydb, sidtab))
returnfalse;
if&nbsp;(selinux_hide_context_query_with_backup(scontext, scontext_len))
returntrue;
for&nbsp;(i =&nbsp;0; i < ARRAY_SIZE(selinux_hidden_context_types); i++) {
if&nbsp;(selinux_hidden_context_has_type(scontext, scontext_len,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; selinux_hidden_context_types[i]))
returntrue;
&nbsp; &nbsp; }
returnfalse;
}

至于访问向量计算,则视backup_sepolicy是否可用分作两条路径:可用时将源、目标上下文映射到备份策略,重新计算av_decision;不可用时则按静态规则表清掉命中的permission位,核心逻辑仅有寥寥数行。

for&nbsp;(i =&nbsp;0; i < ARRAY_SIZE(selinux_hidden_av_rules); i++) {
conststructselinux_hidden_av_rule&nbsp;*rule&nbsp;=&nbsp;&selinux_hidden_av_rules[i];
if&nbsp;(strcmp(rule->stype, stype) ||&nbsp;strcmp(rule->ttype, ttype) ||
strcmp(rule->tclass, tclass_name))
continue;
&nbsp; &nbsp; perm = selinux_perm_to_av(tclass_datum, rule->perm);
if&nbsp;(!perm)
continue;
&nbsp; &nbsp; avd->allowed &= ~perm;
&nbsp; &nbsp; avd->auditallow &= ~perm;
&nbsp; &nbsp; avd->auditdeny |= perm;
}

这段代码被插入到security_compute_av_user()之中。checkSELinuxAccess底层一旦计算完成,结果会被立刻过滤,应用态最终拿到的,只能是处理之后的av_decision。该路径与KernelSU运行时方案如出一辙:不删当前策略,只把查询结果改写成旧策略本应给出的答案。

KPM运行时补丁方案

APatch社区给出的反检测方案,是一份名为selinux_magisk_access_filter的KPM(kernel patch module)。它与KernelSU上游selinux_hide同属”运行时挂钩”路线,但走的是KPM框架的hook_wrap,无需重编内核,也无需依赖backup_sepolicy这一上游符号。

整体思路可以浓缩为一句话:先在SELinux就绪时悄悄抓取一份干净策略,再在应用UID走到查询入口时,让它看到那份干净策略的回答。

KPM在init阶段一次性挂上10个selinuxfs与SELinux服务函数的钩子,覆盖检测器可能用到的所有入口。各目标的角色如下:

| 目标符号 | 作用 | | — | — | | security_read_policy | 给应用UID返回干净policy副本,绕开当前dirty策略 | | security_load_policy | 标记”外部load”以识别脏策略加载 | | sel_write_load | 计数/sys/fs/selinux/load写入次数 | | sel_write_access | 拦截/sys/fs/selinux/access,命中Root类型即返回-EINVAL | | sel_write_context | 拦截/sys/fs/selinux/context,未在干净策略中出现即返回-EINVAL | | security_setprocattrselinux_setprocattr | 过滤procattr current一侧,防止从/proc/self/attr/current绕开 | | context_struct_compute_av | 把应用UID的policydb参数替换成干净副本 | | string_to_context_struct | 同上,用于上下文字符串到context_struct的转换 | | selinux_complete_init | SELinux首次就绪时触发干净策略快照 | | selinux_policy_commit | 每次新策略生效后维护first_policy并刷新快照 |

辅助符号则按需通过kallsyms_lookup_name解析,包括vmalloc/vmalloc_noprofvfreecopy_from_kernel_nofault(旧内核退化为probe_kernel_read)、security_context_to_sidselinux_policy_cancelsidtab_cancel_convert。任一关键符号缺失时,对应的过检测路径会安全降级而非崩溃。

干净策略的采集时机是selinux_complete_initselinux_policy_commitafter回调,对应SELinux首次就绪和后续每次新策略落地。snapshot_clean_policy会调用security_read_policy复制出一份原始blob,并在blob中扫描MAGISK字样以标记g_clean_policy_has_magisk

rc = g_security_read_policy(&blob, &len);
if&nbsp;(rc || !blob || !len)
return;

for&nbsp;(i =&nbsp;0; i +&nbsp;6&nbsp;<= len; i++) {
if&nbsp;((p[i] &&nbsp;0xDF) ==&nbsp;'M'&nbsp;&& (p[i +&nbsp;1] &&nbsp;0xDF) ==&nbsp;'A'&nbsp;&&
&nbsp; &nbsp; &nbsp; &nbsp; (p[i +&nbsp;2] &&nbsp;0xDF) ==&nbsp;'G'&nbsp;&& (p[i +&nbsp;3] &&nbsp;0xDF) ==&nbsp;'I'&nbsp;&&
&nbsp; &nbsp; &nbsp; &nbsp; (p[i +&nbsp;4] &&nbsp;0xDF) ==&nbsp;'S'&nbsp;&& (p[i +&nbsp;5] &&nbsp;0xDF) ==&nbsp;'K') {
&nbsp; &nbsp; &nbsp; &nbsp; magisk_seen =&nbsp;true;
break;
&nbsp; &nbsp; }
}

g_clean_policy_blob = blob;
g_clean_policy_len = len;

随后用security_load_policy把这份blob反序列化成内部load_state,作为应用态查询的备用引擎。需要注意的是,这次内部load是合法的”副本load”,因此KPM用g_internal_policy_load_depth计数把它与外部的脏策略load区分开来——只有计数为0的security_load_policy调用,才会被g_dirty_policy_seen标记。

sel_write_context钩子的核心,是用前面采集的干净blob反向校验当前查询样本是否合法。判定规则极简:把上下文按冒号切片,取最后一段type名,到干净blob里做字节比对。

if&nbsp;(!g_clean_load_state_ready || !g_clean_policy_blob ||
&nbsp; &nbsp; clean_context_exists(sample)) {
&nbsp; &nbsp; args->local.data0 =&nbsp;3; &nbsp; &nbsp; &nbsp; &nbsp;/* 放行 */
return;
}

args->skip_origin =&nbsp;1;
args->local.data0 =&nbsp;2; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;/* 拦截 */
args->ret = (u64)(-EINVAL);

sel_write_access的处理类似,只是要把”scontext tcontext tclass”中的前两段都校验一遍,任一段在干净策略中找不到,便直接-EINVAL返回。context_struct_compute_avstring_to_context_struct两个钩子则更进一步:它们把传入的policydb第一参数替换为g_clean_policydb,这样即便检测器绕过了selinuxfs字符接口直接走Framework调用,最终的av_decision仍然由干净策略计算得出。

本方案与KernelSU方案的对照:

| 维度 | KernelSUselinux_hide | APatch KPM | | — | — | — | | 入口替换方式 | kallsyms_lookup_name("write_op") +ksu_patch_text | KPM框架的hook_wrap | | 备份策略来源 | 内核内部backup_sepolicy,构造时机早于Root域追加 | 用security_read_policy+security_load_policy运行时重建 | | 覆盖入口 | SEL_CONTEXTSEL_ACCESSsetprocattr current | 同上,并额外覆盖compute_avstring_to_context等更深层入口 | | 适用平台 | 与KernelSU一同合入 | 任何能加载KPM的内核,无需源码改动 |

总体上,APatch的KPM思路与KernelSU运行时方案同源——都是”让应用UID看到一份不含Root类型的旧策略”。只是它把这件事完全放到模块层来做,连backup_sepolicy这一上游符号都可以舍弃,靠运行时再生成一份等价的”干净策略”来支撑后续所有查询路径。

KPM方案的限制

把上述思路真正落到产品级使用时,KPM方案有几条无法回避的限制需要正视。

限制一:加载时机必须早于Root域追加。

KPM的干净策略来自security_read_policy运行时读取,而不是像KernelSU那样在编译期就备份。一旦KPM加载晚于Root域注入,snapshot_clean_policy抓到的blob里就已经包含ksumagisk_file等Root相关type,clean_context_exists("u:r:ksu:s0")随即返回true,KPM不仅不会拦截,反而会主动放行,检测器写入成功后直接判定ksu_domain_valid=true命中。因此该KPM不适合热加载,必须由APatchbootkernel加载链在SELinux初始化之前就将其驻留到内核。

限制二:compute_av深层入口实际并未做策略重定向。

虽然KPM挂上了context_struct_compute_avstring_to_context_struct两个钩子,但其before_policydb_arg0回调中真正替换policydb参数的条件是g_clean_eval_depth > 0

if&nbsp;(g_clean_eval_depth && clean_db && incoming) {
&nbsp; &nbsp; args->arg0 = (u64)(uintptr_t)clean_db;
return;
}

g_clean_eval_depth只有在KPM自己调用g_security_context_to_sid做内部清洁评估时才会临时大于零;对外部直接调用这两个函数的情况,本钩子退化为观察者,只负责检测脏策略并触发快照,并不实际改写计算输入。这与KernelSUmy_write_access把应用UID的av_decision计算彻底引导到backup_sepolicy不同。

实战中这个不等效之所以没有暴露,是因为DuckDirtySepolicy走的都是/sys/fs/selinux/access这一用户态写入路径,被前面的sel_write_access钩子提前拦截了;一旦未来出现绕过/sys/fs/selinux/access、直接经compute_av系内核内部入口探测的检测器,本KPM将失守,而KernelSU仍可拦截。

限制三:clean_context_token_exists基于字节子串匹配。

KernelSU走的是security_context_to_sid_with_policy(backup_sepolicy, ...),由内核策略引擎给出标准答案;而本KPM则是在blob里做ASCII子串扫描,匹配的是”:后到行尾的最后一段type名”。这意味着若干净策略里恰好出现某Root相关字符串作为其他type名的子串,存在理论上的误判风险。实践中Root相关type名(ksumagiskmagisk_fileksu_filelsposed_filexposed_data)通常是独立token,干净策略里不存在与之构成部分匹配的合法type,故误判概率极低,但仍是潜在脆弱点。

限制四:security_read_policy双ABI不兼容。

本KPM对security_read_policy只按”无state参数”的2参形态调用:

typedefint(*security_read_policy_t)(void&nbsp;**data,&nbsp;size_t&nbsp;*len);

而Android 13/14对应的5.15内核里,该函数仍是int security_read_policy(struct selinux_state *, void **, size_t *)三参形态。在那类内核上直接以2参签名调用会因参数错位而security_read_policy提前失败,整个干净策略快照流程便走不下去,KPM也就退化为纯观察者。如要兼容旧内核,需要额外按kver分支选择正确的调用约定。

限制五:依赖kallsyms_lookup_name解析符号。

KPM挂钩的10个目标和7个辅助函数全部依赖kallsyms_lookup_name返回有效地址。在/proc/kallsyms被禁用或符号被剥离的发行版内核上,security_read_policysidtab_cancel_convertcopy_from_kernel_nofault等任一关键符号缺失,对应钩子就会被跳过,相关检测路径不再被拦截。KPM加载日志中需要逐项确认这些符号是否解析成功。

限制六:策略热更新场景下的快照陈旧问题。

若运行期间策略以外部方式被合法更新(例如系统OTA后init重新加载policy),before_security_load_policy会把这次load标记为g_dirty_policy_seen=true,从此snapshot_clean_policy不再刷新副本。这虽然能避免把脏策略当成干净副本,但也意味着干净blob会一直停留在最早那次快照的版本,长期运行后可能与系统真实策略产生越来越大的偏差,进而拦截到一些本应放行的合法上下文。

总之一句话,目前kpm的实现还有一些提升的空间,期待后续的完善。

最后,希望APatch快快提供代码方式来补丁,这样就不用折腾插件了。

总结

DuckDirtySepolicy的检测点都依赖app_zygote所拥有的SELinux查询能力。一边用/sys/fs/selinux/context询问上下文是否存在,另一边用checkSELinuxAccess询问访问是否放行,两者皆从策略层一举绕开包名隐藏、进程名隐藏与文件路径伪装。

KernelSU上游的selinux_hide,处理面已覆盖SEL_CONTEXTSEL_ACCESSsetprocattr current,对当前DuckDirtySepolicy的检测路径而言已经具备过检测能力。Andrea的内核源码补丁则把同一思路落到services.c之中,对定制内核而言更为直接,无需依赖运行时对write_op的patch。

至于Magisk,目前在其官方公开代码中尚未见到同级别的app_zygote策略查询隐藏层。只要目标设备仍然把magiskmagisk_file暴露在当前策略之中,Duck的上下文检测以及DirtySepolicy式的访问检测,仍会轻易命中。

若未启用任何隐藏措施,ksuksu_filemagiskmagisk_filelsposed_file这些类型都会被策略oracle一一读出。一旦开启KernelSU的selinux_hide,或将源码级隐藏补丁合入内核,检测器虽然仍在调用同一份API,但返回值已经接近一份干净策略,命中率自然随之下降。

参考资料

  • Duck-Detector

    PR#22:https://github.com/eltavine/Duck-Detector-Refactoring/pull/22

  • Duck-Detector

    最新实现:app/src/main/cpp/selinux/context_validity_probe.cpp

  • Duck-Detector

    预加载:app/src/main/java/com/eltavine/duckdetector/features/selinux/data/service/AppZygotePreload.kt

  • Duck-Detector

    LSPosed信号:app/src/main/java/com/eltavine/duckdetector/features/lsposed/data/probes/LSPosedDirtyPolicyProbe.kt

  • DirtySepolicy

    :https://github.com/LSPosed/DirtySepolicy

  • DirtySepolicy

    核心实现:app/src/main/java/org/lsposed/dirtysepolicy/AppZygote.java

  • AOSPapp_zygote.te:https://android.googlesource.com/platform/system/sepolicy/+/master/private/app_zygote.te

  • KernelSUselinux_hidekernel/feature/selinux_hide.c

  • KernelSUbackup_sepolicykernel/selinux/rules.c

  • KernelSU功能开关:uapi/feature.hmanager/app/src/main/cpp/ksu.cc

  • Andrea内核源码级补丁:https://github.com/Andrea-lyz/oppo_oplus_realme_sm8750/commit/db4883d7d243a9fc6ed8ea4071acce2e0be7b460

  • Andrea补丁文件:other_patch/70_selinux_hide_policy_query.patch

  • APatch KPM样例还原:kpms/selinux_hook/selinux_hook.c(基于selinux_magisk_access_filter v1.0.4反编译还原)

  • KPM框架hook_wrap接口:kpms/kpm/kernel/include/hook.h

  • Magiskmagiskpolicy说明:https://github.com/topjohnwu/Magisk/blob/master/docs/tools.md


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:软件安全与逆向分析 非虫 非虫《三大Root框架通杀检测与反检测方法分析》

评论:0   参与:  0