LSPosed - 检测LSPosed的那些事

引言

在 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 文件中留下痕迹。

检测步骤

  1. 定位应用的 odex 文件:

cd /data/app/~~*==/com.example.app-*/oat/arm64/
  1. 提取编译参数:

strings base.odex | grep dex2oat-cmdline -A3
  1. 检查是否包含 --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;
        }
    }
}

} // namespace

3. 检测匿名 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 以下方法:

  1. java.lang.Thread.dispatchUncaughtException

  2. android.app.ActivityThread.attach

  3. dalvik.system.DexFile.openInMemoryDexFile

  4. dalvik.system.DexFile.openInMemoryDexFiles

  5. dalvik.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 列出该目录内容,我们可以通过以下策略检测异常:

  1. 使用 stat 系统调用获取 /data/misc 的子目录总数(通过 st_nlink 字段)

  2. 遍历所有已知的系统合法子目录,使用 access 检查其是否存在

  3. 如果实际子目录数量多于已知目录数量,说明存在未知目录

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版本也有差异,我们可以收集一下不同安卓版本的差异去做一个针对安卓版本的表,减少遍历耗时


参考资料

  1. 看雪论坛 - LSPosed 检测技术讨论

  2. ByteDance btrace 开源项目

  3. LSPosed 开源项目代码分析

  4. Android Runtime (ART) 官方文档


免责声明:本文内容仅供安全研究和技术交流使用,请勿用于非法目的。开发者应遵守相关法律法规和平台规则。