引言
在 Android 应用安全领域,LSPosed 作为 Xposed 框架的现代化继承者,为开发者提供了强大的系统级 Hook 能力。然而,对于应用开发者和安全研究人员来说,检测设备上是否存在 LSPosed 框架已成为一项重要的安全需求。本文将详细介绍几种有效的 LSPosed 检测方法,帮助开发者提升应用的安全防护能力。
提前声明:这些检测都没什么鸟用(((
方法一:检测 Linker SO 列表
原理说明
通过扫描进程中加载的动态链接库(SO)列表,检测是否存在与 LSPosed、Magisk、Zygisk 等 Hook 框架相关的特征字符串。这是最基础但也最实用的检测方法之一。
实现代码
let features = [ss!("lsposed"), ss!("zygisk"), ss!("riru"), ss!("magisk")];
let mut paths = HashSet::new();
// 方式 1: 通过 dl_iterate 扫描
if let Ok(ctx) = ModuleLinker::scan_via_dl_iterate() {
for module_info in ctx {
let path = module_info.path.replace("_", "").replace("-", "").to_lowercase();
paths.insert(path);
}
}
// 方式 2: 通过 linker 结构扫描
if let Ok(ctx) = ModuleLinker::scan_via_linker() {
for path in ctx {
let path = path.replace("_", "").replace("-", "").to_lowercase();
paths.insert(path);
}
}
// 方式 3: 通过 /proc/self/maps 扫描
if let Ok(ctx) = ModuleLinker::scan_via_proc_maps() {
for module in ctx {
let path = module.path.replace("_", "").replace("-", "").to_lowercase();
paths.insert(path);
}
}
// 检测特征字符串
for path in paths {
if features.iter().any(|feature| path.contains(feature)) {
// 检测到可疑模块
return true;
}
}技术要点
SO List(共享对象列表):操作系统维护的已加载动态链接库列表
多路径扫描:结合
dl_iterate、linker 内部结构和/proc/self/maps三种方式,提高检测准确率字符串归一化:移除下划线和连字符,转换为小写,避免简单的混淆绕过
方法二:检测 dex2oat 编译参数特征
原理说明
LSPosed 通过 mount --bind 方式拦截了 dex2oat 的调用,并添加了 --inline-max-code-units=0 参数以确保正常 Hook。该参数会在编译后的 base.odex 文件中留下痕迹。
检测步骤
定位应用的 odex 文件:
cd /data/app/~~*==/com.example.app-*/oat/arm64/提取编译参数:
strings base.odex | grep dex2oat-cmdline -A3检查是否包含
--inline-max-code-units=0参数
技术说明
dex2oat:Android 运行时的 AOT(Ahead-Of-Time)编译器
inline-max-code-units:控制方法内联的参数,设置为 0 表示禁用内联优化
此特征在 LSPosed 启用时会持久化到 odex 文件中
方法三:检查 ArtMethod 结构异常
背景知识
ArtMethod 是 Android Runtime(ART)中表示 Java 方法的 C++ 结构体。LSPosed 通过修改 ArtMethod 的入口点(Entry Point)来实现 Hook。
实现步骤
1. 定位 ArtMethod 字段偏移
@Keep
public class JNIHook {
static {
nativePlaceHolder();
try {
Method nativePlaceHolder = JNIHook.class.getDeclaredMethod("nativePlaceHolder");
nativeInit(nativePlaceHolder);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}
public static void init() {
// 初始化入口
}
private static native void nativePlaceHolder();
private static native void nativeInit(Method foo);
}2. Native 层获取入口点偏移
具体源代码可以看这里:https://github.com/bytedance/btrace/blob/f259e584ba455f44531877c0936b89b62773a344/btrace-android/rhea-library/rhea-inhouse/src/main/cpp/utils/JNIHook.cpp#L155
namespace rheatrace::jni_hook {
static int jniEntranceIndex_ = -1;
void init(JNIEnv* env, jobject foo, void* fooJNI) {
void** fooArtMethod;
// Android 10+ 使用反射获取 artMethod 字段
if (android_get_device_api_level() >= 30) {
jclass Executable = env->FindClass("java/lang/reflect/Executable");
jfieldID artMethodField = env->GetFieldID(Executable, "artMethod", "J");
fooArtMethod = (void**) env->GetLongField(foo, artMethodField);
} else {
fooArtMethod = (void**) env->FromReflectedMethod(foo);
}
// 遍历 ArtMethod 结构,定位 JNI 入口点
for (int i = 0; i < 50; ++i) {
if (fooArtMethod[i] == fooJNI) {
jniEntranceIndex_ = i;
break;
}
}
}
} // namespace3. 检测匿名 RWX 内存区域
// 检查 idx 和 idx+1 两个位置的 entry point
for check_idx in [idx, idx + 1] {
let offset = check_idx * ptr_size;
let addr = (base_addr + offset) as *const usize;
let ptr = addr.read_unaligned() as *const c_void;
if ptr.is_null() {
continue;
}
let mut dl_info: Dl_info = MaybeUninit::zeroed().assume_init();
let resolved = dladdr(ptr, &mut dl_info) != 0;
// 如果无法解析到具体的动态库,检查是否指向可疑内存区域
if !resolved || dl_info.dli_fname.is_null() {
let ptr_addr = ptr as u64;
for map in &maps {
if ptr_addr >= map.address.0 && ptr_addr < map.address.1 {
let map_path = match &map.pathname {
MMapPath::Other(s) => s.clone(),
MMapPath::Path(p) => p.to_string_lossy().to_string(),
_ => "".to_string(),
};
// LSPosed Hook 特征:匿名 RWX 内存区域
if map_path.is_empty() &&
map.perms.contains(MMPermissions::READ |
MMPermissions::WRITE |
MMPermissions::EXECUTE) {
return Ok(JNI_TRUE);
}
break;
}
}
}
}关键检测点
LSPosed 启用后会自动 Hook 以下方法:
java.lang.Thread.dispatchUncaughtExceptionandroid.app.ActivityThread.attachdalvik.system.DexFile.openInMemoryDexFiledalvik.system.DexFile.openInMemoryDexFilesdalvik.system.DexFile.openDexFile
检测策略:获取这些方法的 Method 对象,传递给 Native 层检查入口点是否指向匿名 RWX 内存区域。
技术原理
早期 Xposed:将 Java 方法改为 Native 方法,通过 JNI 入口点实现 Hook
LSPosed:创建匿名的可读可写可执行(RWX)内存区域,修改入口点指向该区域
检测依据:正常的方法入口点应指向有名称的代码段,而非匿名 RWX 区域
方法四:内存漫游检测 ClassLoader
原理说明
通过 JVMTI 或反射技术遍历堆内存中的所有 ClassLoader 实例,检查是否存在与 LSPosed 相关的 ClassLoader。
实现代码
// 获取所有 ClassLoader 实例
var choose: List<ClassLoader> = ChooseUtils.choose(ClassLoader::class.java, true)
if (choose.isEmpty()) {
choose = ChooseUtils.choose(BaseDexClassLoader::class.java, true) as List<ClassLoader>
}
// 检查 toString() 输出是否包含 LSPosed 特征
for (classLoader in choose) {
val description = classLoader.toString()
if (description.contains("Lsp", ignoreCase = true)) {
// 检测到可疑的 ClassLoader
return true
}
}实现方式
Native 方式:基于 JVMTI 的 API
Java 方式:利用反射和 Debug API 遍历对象
参考资源
详细实现可参考 @珍惜 的相关文章或看雪论坛的 ChooseUtils 实现。
方法五:基于时序的侧信道攻击
原理说明
LSPosed 拦截了 ActivityManagerService,对特定的 Transaction Code 会执行额外的检查和处理逻辑。通过测量 Binder 调用的耗时差异,可以推断是否存在 LSPosed。
Transaction Code 定义
// 开源版本的固定 Code
private const val TRANSACTION_CODE_LSP =
('_'.code shl 24) or ('L'.code shl 16) or ('S'.code shl 8) or 'P'.code
// 对照组 Code
private const val TRANSACTION_CODE_LSI =
('_'.code shl 24) or ('L'.code shl 16) or ('S'.code shl 8) or 'I'.code
private const val TRANSACTION_CODE_LSG =
('_'.code shl 24) or ('L'.code shl 16) or ('S'.code shl 8) or 'G'.code检测实现
private fun checkLSPosedWithTimingAttack(activityService: IBinder, realCode: Int): Boolean {
val samples = 10
val results = mutableListOf<Boolean>()
val times = 10_0000 // 多少合适呢?
repeat(samples) {
// 测量两个无关 Code 的平均耗时
val tFake1 = measureNanos {
repeat(times) {
testTransact(activityService, TRANSACTION_CODE_LSI, "LSPobad")
}
}
val tFake2 = measureNanos {
repeat(times) {
testTransact(activityService, TRANSACTION_CODE_LSG, "LSPosad")
}
}
// 测量目标 Code 的耗时
val tReal = measureNanos {
repeat(times) {
testTransact(activityService, realCode, "LSPosed")
}
}
val avgFake = (tFake1 + tFake2) / 2.0
val ratio = tReal / avgFake
// 如果目标耗时显著高于基准,说明存在额外处理
results.add(ratio > 1.5)
}
// 70% 以上的样本满足条件才判定为阳性
return results.count { it } >= (samples * 0.7).toInt()
}
private inline fun measureNanos(block: () -> Unit): Long {
val start = System.nanoTime()
block()
return System.nanoTime() - start
}LSPosed-IT 的变种 Code
LSPosed-IT 版本使用基于 GitHub 用户名生成的动态 Code,以下是已知泄露的部分 Code:
private val LSPOSED_IT_LEAK_CODE = arrayOf(
TRANSACTION_CODE_LSP, // fork Code
0x1c17b702, // @LinYuuA
0x19efdc06, // @pc040905
0x1ae66ee4, // @SigmoidZebra844
0x1a569938, // @RainyXeon
0x1a8b6f8c, // @qlenlen
0x1a0178cd, // @LSPosedInternalTest
0x1a046843, // @EslamGamalEssa
0x1a335faa, // @Cycle1337
0x1851f7c8, // @5ec1cff
0x1a1ed36d, // @fuengfah
0x1a0d36a6, // @LarsStrand
0x19a389fe, // @2985834246
0x19a968eb, // @RanaElhadary
0x19a67975, // @Hons123
0x1997cc24, // @limerence527
0x19805071, // @dsfdfc
0x19c6c38c, // @noire7777
0x19bdf528, // @Sumo1210
)局限性
Android 15+ 限制:当多次触发 Binder 错误时,Binder 连接会被系统冻结,导致此检测方法失效。此外,LSPosed-IT 的 Code 并非固定值,需要持续收集和更新。
方法六:/data/misc 目录异常检测
原理说明
LSPosed 和其他一些Xposed模块在运行时可能会在 /data/misc 目录下创建额外的子目录用于存储配置、日志等数据。由于普通应用无法直接 ls 列出该目录内容,我们可以通过以下策略检测异常:
使用
stat系统调用获取/data/misc的子目录总数(通过st_nlink字段)遍历所有已知的系统合法子目录,使用
access检查其是否存在如果实际子目录数量多于已知目录数量,说明存在未知目录
st_nlink 字段:在 Unix/Linux 文件系统中,目录的硬链接数等于 子目录数量 + 2(. 和 .. 各占一个链接)。因此实际子目录数 = st_nlink - 2。
权限限制:由于 SELinux 策略限制,普通应用无法使用 opendir/readdir 遍历 /data/misc,但可以:
使用
stat获取目录元数据使用
access检查特定路径是否存在
实现代码
use std::ffi::CString;
use std::mem;
use rayon::prelude::*;
/// 已知的系统合法子目录列表
/// 注意:此列表需要根据 Android 版本和厂商定制进行调整
let known_sub_dirs = &[
// === 核心系统目录 ===
ss!("adb"), // ADB 调试配置
ss!("audio"), // 音频配置
ss!("audioserver"), // 音频服务器
ss!("bluetooth"), // 蓝牙配置
ss!("bluedroid"), // 新版蓝牙
ss!("camera"), // 相机配置
ss!("cameraserver"), // 相机服务器
ss!("keychain"), // 密钥链
ss!("keystore"), // 密钥存储
ss!("keymaster"), // 密钥管理(TEE)
ss!("net"), // 网络配置
ss!("nfc"), // NFC 配置
ss!("radio"), // 无线电配置
ss!("wifi"), // WiFi 配置
ss!("vpn"), // VPN 配置
ss!("user"), // 用户数据
ss!("profiles"), // 用户配置文件
// === 多媒体与显示 ===
ss!("media"), // 媒体服务
ss!("bootanim"), // 开机动画
ss!("gpu"), // GPU 配置
ss!("display"), // 显示配置(高通)
// === 安全与认证 ===
ss!("gatekeeper"), // 设备认证
ss!("credstore"), // 凭证存储
ss!("biometric"), // 生物识别
ss!("fingerprint"), // 指纹数据
ss!("faces"), // 面部识别
ss!("facedata"), // 面部数据(厂商特定)
ss!("iris"), // 虹膜识别
// === 系统服务 ===
ss!("logd"), // 日志守护进程
ss!("statsd"), // 系统统计
ss!("stats-data"), // 统计数据
ss!("stats-metadata"), // 统计元数据
ss!("stats-service"), // 统计服务
ss!("stats-active-metric"), // 活跃指标
ss!("installd"), // 安装守护进程
ss!("vold"), // 卷管理
ss!("storaged"), // 存储统计
// === 更新与回滚 ===
ss!("update_engine"), // 系统更新引擎
ss!("update_engine_log"), // 更新日志
ss!("update_verifier"), // 更新验证
ss!("apex"), // APEX 模块
ss!("apexdata"), // APEX 数据
ss!("apexrollback"), // APEX 回滚
ss!("rollback"), // 系统回滚
ss!("recovery"), // 恢复模式
// === 性能与追踪 ===
ss!("perfetto"), // 性能追踪
ss!("perfetto-traces"), // 追踪数据
ss!("perfetto-configs"), // 追踪配置
ss!("trace"), // 系统追踪
ss!("boottrace"), // 启动追踪
ss!("bootstat"), // 启动统计
ss!("wmtrace"), // 窗口管理追踪
ss!("a11ytrace"), // 无障碍追踪
ss!("a11y_trace"), // 无障碍追踪(变体)
ss!("heapprofd"), // 堆分析
ss!("profman"), // 配置文件管理
ss!("iorapd"), // I/O 优化
// === 网络与连接 ===
ss!("dhcp"), // DHCP 配置
ss!("connectivity"), // 连接服务
ss!("connectivityblobdb"), // 连接 Blob 数据库
ss!("ethernet"), // 以太网
ss!("wireguard"), // WireGuard VPN
ss!("apns"), // APN 配置
ss!("telephonyconfig"), // 电话配置
ss!("carrierid"), // 运营商 ID
ss!("emergencynumberdb"), // 紧急号码
ss!("sms"), // 短信配置
ss!("network_watchlist"), // 网络监控(Android 9+)
// === 数据与存储 ===
ss!("shared_relro"), // 共享只读数据
ss!("shared"), // 共享数据
ss!("misc_ce"), // 凭证加密的杂项
ss!("misc_de"), // 设备加密的杂项
ss!("zoneinfo"), // 时区信息
ss!("timezone"), // 时区数据
ss!("time"), // 时间数据
ss!("font"), // 字体缓存
// === 虚拟化与容器(Android 13+) ===
ss!("virtualization"),
ss!("virtualizationservice"),
ss!("virtualization_service"),
// === 设备特定服务 ===
ss!("sensors"), // 传感器
ss!("sensor"), // 传感器(变体)
ss!("location"), // 位置服务
ss!("contexthub"), // 上下文中心
ss!("thermal"), // 温度管理
ss!("power"), // 电源管理
ss!("cpu"), // CPU 配置
ss!("usb"), // USB 配置
ss!("fm"), // FM 收音机
// === 应用与兼容性 ===
ss!("appcompat"), // 应用兼容性
ss!("appops"), // 应用操作
ss!("textclassifier"), // 文本分类器(重要!)
ss!("healthconnect"), // 健康连接(Android 14+)
ss!("accounts"), // 系统账户
// === 调试与测试 ===
ss!("incidents"), // 事件报告
ss!("critical-events"), // 关键事件
ss!("snapshotctl_log"), // 快照日志
ss!("snapuserd"), // 快照用户守护进程
ss!("dropcache"), // 缓存清理
ss!("checkin"), // 签入数据
ss!("reboot"), // 重启相关
ss!("dmesgd"), // 内核日志
ss!("audit"), // SELinux 审计
ss!("gcov"), // 代码覆盖率
// === 高通(Qualcomm)===
ss!("tloc"), // TLocation 定位服务
ss!("perfd"), // 性能守护进程
ss!("iop"), // I/O 性能优化
ss!("ipa"), // IP Accelerator 网络加速
ss!("dpm"), // Data Power Manager
ss!("qsee"), // Secure Execution Environment
ss!("spss"), // Secure Processor SubSystem
ss!("qvop"), // Voice Print 语音识别
ss!("qvr"), // Qualcomm VR
ss!("hbtp"), // Host Based Touch Processing
ss!("qlogd"), // Qualcomm 日志守护进程
ss!("qti-logkit"), // QTI 日志工具包
ss!("dts"), // DTS 音频
ss!("audio_pp"), // 音频后处理
ss!("qcc"), // Qualcomm Crypto Cipher
ss!("qdma"), // Qualcomm DMA
ss!("usf"), // Ultrasound framework
ss!("ramdump"), // SSR ramdumps
// === 联发科(MTK)===
ss!("mcRegistry"), // TrustZone 注册表
ss!("nvdata"), // NV 数据
// === 三星(Samsung)===
ss!("knox"), // Knox 安全
ss!("wireless"), // 无线服务
ss!("mcafee"), // McAfee 安全
// === OPPO/一加 ===
ss!("oplus_update_engine_log"),
ss!("midas"), // 性能监控(小米也有!)
ss!("gjdw"), // OPPO 特定服务
ss!("ostats"), // OPPO 统计
ss!("ostats_pullerd"),
ss!("ostats_tpd"),
ss!("upprobestats-configs"),
ss!("cutback"), // Cutback 服务(摩托罗拉)
ss!("stargate"), // QFP daemon(指纹)
ss!("FTM_AP"), // Factory Test Mode
ss!("akmd_set.txt"), // AKM 磁力计配置
ss!("vendor"), // 厂商数据
ss!("systemkeys"), // 系统密钥
ss!("firmware"), // 固件
ss!("elabel"), // 电子标签
ss!("lockscreen"), // 锁屏
ss!("profiles-metadata"), // 配置元数据
ss!("swap"), // 交换空间
ss!("seemp"), // System Event & Event Monitoring Platform
ss!("threadnetwork"), // Thread 网络(IoT)
ss!("bthci"), // 蓝牙 HCI
ss!("bluetoothd"), // 旧版蓝牙守护进程
ss!("hcid"), // 旧版蓝牙守护进程
ss!("wpa_supplicant"), // WiFi Supplicant
ss!("wimax"), // WiMAX(较老设备)
ss!("sxraux"), // SXR Auxiliary
ss!("rad"), // Radio Access Daemon
ss!("odrefresh"), // OD Refresh
ss!("odsign"), // OD Sign
ss!("ood"), // Out of Domain
ss!("iopgp"), // I/O PGP
ss!("vm-system"), // VM 系统
ss!("prereboot"), // 预重启
ss!("tmpstor"), // 临时存储
ss!("train-info"), // 训练信息
ss!("perfprofd"), // 性能分析守护进程
ss!("vehiclehal"), // 车载 HAL(Android Automotive)
ss!("port_bridge"), // 端口桥接
ss!("SelfHost"), // RIDL - Remote IDL
];
/// 使用 libc stat 获取目录的子目录数量
#[inline]
fn count_subdirs_by_libc_stat(path: &str) -> Option<u32> {
let c_path = CString::new(path).ok()?;
unsafe {
let mut stat: libc::stat = mem::zeroed();
if libc::stat(c_path.as_ptr(), &mut stat) == 0 {
// st_nlink 包含 . 和 .. 两个特殊目录
// 实际子目录数 = st_nlink - 2
if stat.st_nlink >= 2 {
Some((stat.st_nlink - 2) as u32)
} else {
Some(0)
}
} else {
None
}
}
}
/// 使用 libc access 检查路径是否存在
#[inline]
fn check_access(path: &str) -> bool {
let c_path = match CString::new(path) {
Ok(p) => p,
Err(_) => return false,
};
unsafe { libc::access(c_path.as_ptr(), libc::F_OK) == 0 }
}
/// 执行检测
pub fn detect_data_misc_anomaly() -> Option<Finding> {
let misc_path = s!("/data/misc");
// 获取实际子目录总数
let actual_count = count_subdirs_by_libc_stat(misc_path)?;
// 并行检查已知目录的存在性
let found_dirs = known_sub_dirs
.par_iter()
.filter(|&dir_name| {
let full_path = format!("{}/{}", misc_path, dir_name);
check_access(&full_path)
})
.collect::<Vec<_>>();
let known_count = found_dirs.len() as u32;
// 如果实际数量大于已知数量,可能存在异常
if actual_count > known_count { // 建议是不等于(((
let unknown_count = actual_count - known_count;
Some(Finding::new(
&format!(
"检测到 /data/misc 存在 {} 个未知子目录(实际: {}, 已知: {})\n\
可能的原因:\n\
1. LSPosed/Magisk 等 Hook 框架创建的目录\n\
2. 厂商特定的定制目录(需要更新白名单)\n\
3. 第三方系统服务的数据目录",
unknown_count, actual_count, known_count
),
ThreatLevel::Medium, // 使用中等威胁级别,因为可能是误报
false,
))
} else {
None
}
}
由于不同厂商和 Android 版本可能有额外的系统目录,建议根据不同厂商定制不同的列表,减少遍历耗时
不同的android版本也有差异,我们可以收集一下不同安卓版本的差异去做一个针对安卓版本的表,减少遍历耗时
参考资料
LSPosed 开源项目代码分析
Android Runtime (ART) 官方文档
免责声明:本文内容仅供安全研究和技术交流使用,请勿用于非法目的。开发者应遵守相关法律法规和平台规则。