前排提示,纯ai文,直接ai总结即可
开源仓库:AndroidReverser-Test/KCLF
分析版本:KCLF 1.5.4,源码基线为 6d53c294,核对日期:2026-10-31。
本文依据当前源码和上游文档进行静态分析,面向自有设备、测试 App 和授权研究;不把设计意图等同于真机验证结果,也不承诺任何检测工具的绕过率。
谈到 Android SO 注入,最容易想到的流程是:附加目标进程,修改寄存器,让目标调用 dlopen(),然后恢复执行。KCLF 解决的也是“让一个 SO 在目标进程里运行”这个问题,但它改变的不只是调用入口。
理解这个框架,需要先拆开两个经常被混为一谈的问题:谁负责取得目标线程的执行机会,以及谁负责把 ELF 变成可运行的内存镜像。
KCLF 的第一层答案是:由 KernelPatch 内核模块通过 task_work 安排工作,在目标线程返回用户态时重定向执行,不建立 ptrace 跟踪关系。
第二层答案则有两个:默认 manual 后端自行完成 ELF 映射、符号解析和重定位;显式 linker 后端仍调用目标进程中的 bionic loader。
因此,KCLF 最准确的定位是:一个内核控制的线程借用执行框架,配有独立手工装载器和系统 linker 两种后端。 它不是把 SO 放到内核里运行,也不是一个能让用户态代码从内存中消失的“隐身容器”。
“注入技术”通常同时涉及三件事。
这三件事可以组合。ptrace 不等于 dlopen,手工映射也不是内核框架专有的能力;Frida 则是一整套动态插桩系统,不能被简化成某一个注入系统调用。
放到这个坐标系中,KCLF 有两条清晰的组合路径:
后面讨论“加载器看不见”“没有 SO 路径”等现象时,必须说明是哪条路径。manual 的性质不能自动套用到 linker 后端。
KCLF 构建为 KernelPatch 加载的可重定位 KPM,最终使用 ld -r 生成模块,而不是常规 Linux LKM。控制命令通过受 superkey 保护的 KPM 接口进入框架。设备必须已经具备可用的 KernelPatch 环境;KCLF 本身不是提权漏洞,也不是普通 App 可以直接调用的无权限注入接口。
下文用 EL0 表示用户态、EL1 表示内核态,payload 则指待注入的 SO 载荷。
核心流程可以概括为:
kclf_work_prepare_body() 在目标线程自己的上下文中执行。这一点很重要:准备阶段面对的是目标的地址空间、凭据和命名空间,而不是控制端的环境,也不是另起一个普通内核 worker 去冒充目标。
默认通知方式为 TWA_RESUME,等待目标返回用户态;可选的 wake 使用经过兼容性检查的 TWA_SIGNAL,帮助唤醒可中断等待。这里的 SIGNAL 是 task_work 通知机制的名称,不代表框架给 App 人为投递了一个供信号处理函数消费的用户信号,更不是一次 SIGSTOP 附加。
不过,“没有 ptrace-stop”不等于“没有暂停感”。文件读取、准备映射和执行构造函数仍然占用被借用线程的时间;在主线程上运行缓慢的构造函数,同样可能造成界面卡顿。
准备阶段创建临时匿名区域,容纳生成代码、ABI 参数、私有现场,以及必要时使用的临时栈。随后再排队 hijack 工作,在经过验证的 pt_regs 帧中保存原始现场,将返回 PC 指向跳板入口,将 SP 指向选定栈。
kclf_exec_hijack() 还会检查目标是否为 64 位 EL0、用户地址是否合理、是否存在不支持的向量状态,以及相应的跟踪/模拟标志。默认还检查系统调用边界和 linker 内部执行位置;这些检查属于降低风险的启发式,不是“这个时刻一定没有锁”的证明。
实现还把 syscallno 设为 -1,避免把新跳板入口当成原系统调用返回进行重启处理。用户函数则始终在 EL0 执行:内核准备地址和元数据,不在 EL1 直接调用 SO 构造函数。
对应实现见 kclf_work_prepare_body() / kclf_work_hijack_body() 和 kclf_exec_hijack()。
EL0 跳板完成工作后,先把结果复制到私有页,清零 ABI 参数页,再恢复基础浮点/SIMD(FPSIMD)状态、栈指针 SP、条件标志 NZCV 和通用寄存器,写入每请求不同的完成 token,最后返回原 PC。
这里有一个重要例外:最终通过 RET x16 返回,x16 被用于保存原 PC,因此原 x16 的值没有恢复。不能把这个过程描述为“所有寄存器完全无损恢复”;后文会讨论其影响。
完成 token 只是一笔内存写入,不会主动产生专用系统调用、故障或信号。内核主要通过两条路径发现它:
源码的观察池包含 70 个系统调用,实际安装数取决于内核与 KernelPatch 环境。这些 hook 的职责是读取请求状态,不是改写、吞掉 App 的系统调用,也不是过滤其读取到的 maps 内容。
由于 token 在最终返回前就被写入,poll 路径还会检查目标 PC 是否已经离开整个临时区域,避免过早解除仍在执行的代码映射。完整结果被收集后,cleanup 才应用延迟的 RELRO 保护、提交或释放镜像,并回收临时区。见完成观察与清理实现及退出临时区检查。
KCLF 分开管理请求槽与 manual 镜像表。请求槽描述一次执行事务,镜像表记录已经装载的 SO 及引用计数。回收请求槽,不会顺便删除驻留 SO。
同一进程、同一地址空间、完全相同路径字符串的重复 manual 加载,可以复用镜像并增加引用,不重复执行构造函数。最后一个引用被释放时才执行析构并解除镜像映射;该引用计数由 KCLF 自己管理,不是 bionic 的引用计数。
因此,观察到 state=done 也不能立刻断言资源全部清理:还要区分残留的 pending、临时 map 和有意保持驻留的镜像。相关实现见镜像事务登记与 kclf_manual_finish()。
默认 manual 后端不调用 bionic 的 dlopen、dlsym、dlclose,也不编辑 soinfo。它自行承担了系统 loader 的一部分职责。
新镜像主要经过以下步骤:
这里的重定位不是简单“把地址整体加上基址”:例如 RELATIVE 使用装载偏移加 addend,GLOB_DAT / JUMP_SLOT 等还需要找到目标符号及其版本。KCLF 会检查表和写入地址的边界,并拒绝未实现的语义。具体入口见 kclf_manual_prepare()及 ELF 解析与重定位代码。
为什么这会影响模块枚举?以 AOSP bionic 为例,do_dl_iterate_phdr() 遍历的是 linker 管理的 soinfo 列表,不是遍历进程中每一块可执行内存。manual 镜像没有登记进去,自然不会仅因为“已经可以执行”就出现在这份清单中。这是从装载方式和枚举实现共同得出的结论,而不是 KCLF 伪造了 API 返回值。AOSP linker 实现
不过,系统 loader 没有管理它,也意味着系统 loader 不会自动提供完整的运行时服务:dladdr 地址归属查询、常规 dlsym 查找、异常展开、TLS 和调试器集成都不能照搬普通 SO 的预期。KCLF 返回的 manual image-id 是框架 ID,不能当作标准 dlopen handle 使用。
同名 SONAME 在 Android 进程里不一定只有一份。KCLF 根据导入对象和候选提供库的来源路径打分,综合同目录、同 APEX、同分区等线索;无法消除歧义时失败,而不是任意取第一份。
这套机制是在已加载对象中寻找合适依赖的启发式,不是对 bionic namespace、全局符号抢占、可见性及引用管理的完整重现。Android linker namespace 本身负责库之间的隔离和允许的连接关系,因此“没有调用 linker”与“正确复刻全部 linker 语义”是两回事。Android linker namespace 文档
更重要的是,manual 不会自动加载缺失依赖,也不会为提供库增加 bionic 引用计数。调用方必须保证依赖已加载,并在整个 payload 生命周期内保持有效;另一个线程并发 dlclose 或解除相关映射,仍可能破坏执行条件。见依赖收集、来源选择与符号解析。
显式选择 linker 时,框架扫描目标已有映射,解析 loader 入口,优先使用 __loader_dlopen,必要时回退到 libdl.so 的 dlopen;随后由 EL0 跳板调用。加载失败可以获取 dlerror,一次性模式可以调用 dlclose。
这一后端的差异在“如何得到执行机会”,而不在“是否经过系统动态链接器”。SO 及其依赖通常仍按 bionic 的装载规则进入对应管理结构,文件映射和模块枚举也仍具有普通动态库的特征。
__loader_dlopen 的 caller address 参与 namespace 选择。当前 KCLF 优先取已发现的 libc 地址,再考虑 libdl 或 linker 地址;这不等于自动拥有任意 ClassLoader 对应 namespace 的访问权,也不保证任意 SO 路径都能打开。KCLF loader 解析、AOSP loader 入口
两个后端是显式选择关系:manual 失败不会自动退回 linker。另一个容易忽略的事实是,直接调用 native loader 不等于经过 Java VM 的 Native Library 装载流程;不能把“切换 linker 后端”理解为框架会自动调用 JNI_OnLoad 或补齐全部 ART/JNI 集成。
先给出整体对照。表中的可见性描述针对典型路径,具体工具可以额外组合手工映射、清理或其他机制。
ptrace 提供线程级观察与控制能力,包括读写内存、寄存器,以及围绕停止/继续组织执行。典型远程函数调用式注入是在这个机制上构造一次目标内的调用;ptrace 自身并不要求一定使用 dlopen。Linux ptrace(2)
KCLF 不通过外部 tracer 来完成这件事,而是让内核在目标返回用户态之前安排执行现场。由此形成四个关键差异。
第一,权限基础不同。 ptrace 路径依赖相应的附加权限和安全策略;KCLF 路径依赖已经部署的 KernelPatch 及内核控制能力。这不是在相同权限下换一个更隐蔽的用户态 API,而是把控制点放到了不同层级。
第二,跟踪关系不同。 KCLF 不因本次注入建立 ptrace 关系,因此不会因这次附加产生对应的 tracer 身份。TracerPid 表示当前的跟踪者,而不是“历史上是否曾发生注入”;ptrace 方案解除附加后,也不能只靠该字段判断之前做过什么。多线程场景还需要区分主线程与各 TID 的状态。Linux /proc/pid/status 文档
第三,停止和恢复协议不同。 传统 PTRACE_ATTACH 会发送 SIGSTOP,但不能因此说所有 ptrace 都必须这样:PTRACE_SEIZE 附加时不直接停止目标,后续可使用 PTRACE_INTERRUPT。真正的区别是 KCLF 没有依赖 ptrace 的跟踪与停止协议,而不是简单比较“有没有 SIGSTOP”。Linux ptrace(2) 的 attach/seize 语义
第四,工程风险的落点不同。 KCLF 可以把准备映射和完成观察放在内核侧,但必须适配目标内核 ABI、管理 task_work 生命周期、选择安全返回点,并处理当前 x16 恢复限制。内核入口并没有消除重入、锁和异步状态问题,也不能据此直接得出性能必然优于 ptrace 的结论。
尤其要强调:匿名映射、擦除 ELF 头、缩短临时映射寿命、随机化跳板,都不是“只要不用 ptrace 就自动获得”的性质;用户态手工装载器也可以采用其中一些策略。KCLF 的特点是这些策略与内核控制入口、独立装载器、EL0 自恢复路径的组合。
启动预加载或在 App 中显式装载库,利用的是受控的进程启动环境或既有应用执行流。Android 的 wrap.sh 是官方支持的启动包装机制之一,但它有 API 级别、打包和 debuggable 条件,并不是对所有线上 App 都可直接使用的入口。Android NDK wrap.sh 文档
把 Gadget 或其他库集成进 APK,则意味着控制了应用构建/集成流程;如果采用重打包,还改变了 APK 制品本身。KCLF 的核心路径不要求修改目标 APK,也不要求目标预先调用某个加载函数,但代价是设备侧必须先具备内核模块运行条件。
所以这里不能笼统说谁“更方便”:前者要求控制启动或应用,后者要求控制内核环境。它们对研究者和部署环境的前置条件不同。
Zygisk 提供 App 和 system_server specialization 前后的模块回调,天然靠近进程创建阶段。KCLF 则把某个已存在线程作为操作对象,在合适的返回边界借用它执行工作。前者便于在应用生命周期早期介入,后者不以重新走一次 Zygote 创建流程为前提。Zygisk 官方模块 API
Xposed / LSPosed 还涉及 ART 方法 Hook、模块 API 和运行时管理。以 LSPosed 项目自己的定位为例,它是建立在 Riru / Zygisk 模块入口上的 ART Hook 框架。KCLF 当前不提供这些上层能力;成功把一个 SO 的构造函数跑起来,并不等于已经拥有 Java 方法替换、ClassLoader 管理和完整 JNI 生命周期。LSPosed 项目说明
Frida 官方区分 Injected、Embedded 和 Preloaded 等运行模式。其价值不只在进入进程,还包括 Gum/GumJS、函数拦截、内存操作、脚本执行,以及需要时的双向通信。因此,“Frida 就是 ptrace 注入”不是准确描述。Frida 运行模式文档
KCLF 当前核心不要求在 App 内常驻一个脚本引擎、RPC 服务或管理线程;它主要负责装载、执行和生命周期协调。这减少了框架必须引入的用户态常驻组件,但也意味着高级插桩能力需要 payload 自行提供。
如果 payload 自己创建线程、通信服务或 Hook 框架,这些组件仍会形成它们自己的可观察行为。不能因为入口换成了 KCLF,就把载荷的特征也视为被消除;更不能假定任意大型 agent 都符合 manual 后端的 ELF 与运行时限制。
memfd_create() 创建的是匿名文件,解决的是内容如何以文件对象存在的问题,而不是自动解决线程控制或模块登记问题。如果之后仍交给系统 linker 装载,是否登记模块仍由 loader 路径决定。Linux memfd_create(2)
KCLF manual 当前从指定路径读取 SO,再复制到匿名内存;它不是一个 memfd 加载器,也不因此自动删除源文件。“磁盘上是否有文件”“maps 是否显示源路径”“linker 是否登记镜像”是三个独立问题。
这里的“普通 App”指没有额外内核权限,但可以通过自身 Native 代码检查自己的地址空间、线程和运行时状态的应用。讨论重点是它在进程内部能观察到什么,而不是假设检测者已经可以任意检查内核。
KCLF 的策略并非统一“屏蔽检测 API”,而是减少一部分痕迹的产生,弱化另一部分痕迹的固定形状,并缩短临时资源的存在时间。
框架使用 task_work 和返回现场重定向,没有通过 ptrace 附加目标,也没有实现把 /proc 中 TracerPid 改成零的逻辑。
区别在于:它减少的是这次加载产生的“被调试器附加”状态,而不是事后遮盖这个状态。因此,单纯检查是否存在 tracer,不足以覆盖这类执行入口;同样,TracerPid=0 也不能证明一个进程没有额外代码。
这项性质适用于 manual 和 linker 两个后端,但与 SO 在内存中是否可被识别没有直接等价关系。
manual 的映射、符号解析和重定位发生在内核准备路径中,不需要通过目标的 dlopen / dlsym 完成这次装载。因此,仅监控这些用户态装载入口,或只枚举 linker 模块表,会缺失这一类镜像。
这不是“先用 dlopen 正常加载,再偷偷摘链”,而是该镜像从一开始就由另一套装载器管理。它也没有为隐藏自身而修改目标现有函数入口或已有库的 GOT/PLT;payload 自己安装的 Hook 不在这一结论内。
边界同样清晰:linker 后端依旧调用真实 loader;manual payload 的构造函数或其他代码也仍可以自行调用库函数、分配内存或触发新的动态装载。框架没有屏蔽这些后续行为。manual 后端定义及实现
这是 manual 后端针对“按路径找库”和“按头部认库”的主要处理。
首先,镜像使用匿名私有映射承载,而不是把源 SO 文件直接映射为对应的文件 VMA。因此,maps 中不会因为这块镜像的文件后备关系而自动出现源 SO 路径。注意,这不表示源文件不存在,也不表示 SO 名称不会以字符串形式出现在其他内存中。
其次,镜像前预留 1–8 页随机大小的 pad,并增加对齐余量。保护设置时,前导区域采用首个装载段的权限,使其具备与相邻区域合并的条件,降低“VMA 起点就是 ELF 起点”这一固定关系。实际 VMA 合并仍受内核条件影响,不能把它描述成任意设备上的绝对保证。
最后,默认擦除内存中的 ELF 头;只有 program header 表满足首个页与受限前缀范围条件时,才一并清除。keepelf 可以保留头部,适用于需要检查自身 ELF 信息的载荷。map_image()、protect_image()、scrub_headers()
这三项处理降低了简单路径匹配和 ELF magic 扫描的直接命中机会,但没有加密或重写整个 payload。代码段、动态字符串、常量、重定位后的指针关系,以及其他未清除内容仍可能提供识别线索。
KCLF 不是每次复制完全相同的静态 trampoline blob,而是通过 kclf_prog_build() 生成 AArch64 指令。当前实现会变化的内容包括:
这降低了固定字节串、固定偏移和固定邻接关系的可靠性。准确说法是“ABI 页和私有页没有固定相对位置”,而不是“两页永远不会相邻”;随机布局中仍可能出现相邻情况。私有布局生成失败时,代码也准备了紧凑回退布局。
这种随机化针对的是模板特征,不是加密隔离。页内容仍在目标地址空间中,语义、控制流和访问关系仍然存在;高熵填充也不保证更像某个具体 App 的正常内存。临时映射与私有布局、机器码生成器