Unidbg 中处理文件访问(一)

  1. Unidbg 中处理文件访问(一)
    1. 一、引言
    2. 二、概述
    3. 三、文件访问基本处理
    4. 四、环境检测
    5. 五、总结
    6. 六、参考

Unidbg 中处理文件访问(一)

一、引言

补文件访问在 Unidbg 补环境这项工作里的重要性仅次于补 JNI 环境,它有两个主要特征:

一是出现频率高
它的出现频率远高于补系统调用、补库函数、补虚拟模块这些情况,仅次于 JNI 调用。信息获取和环境检测都会大量使用文件访问。

二是容易被补漏
Unidbg 对文件访问提供了很好的监控和打印,所谓容易补漏,主要是使用者的问题。因为安卓碎片化严重、版本多、权限管理混乱等等原因,因为即使在真实 Android 环境里,访问某个文件没成功也是很常见的事,因此开发上会做大量的兼容处理。换句话说,大部分文件补了最好,不补也不一定有事。因此许多 Unidbg 的使用者走向了极端——完全不关注文件访问,这确实省时省力,而且 80% 样本来说没有影响。但是,在 20% 的样本和场景里会出问题,而且往往越难的样本越会出问题。

因此学号怎么补文件访问还是挺重要的,本篇就是系统地讲这个问题。

二、概述

文件访问最主要服务于环境检测和信息收集,举例如下。

  • 访问 /proc/self/maps 检测 frida/xposed 等模块。
  • 访问 /proc/net/tcp 检测 frida/ida server 默认端口。
  • 访问 /proc/self/cmdline 确认在自身进程内。
  • 访问 apkfile 做签名校验,解析资源文件等。
  • 访问 /xbin/su 检测 root。
  • 访问 data/data/package/xxx 读取自身有关文件

Unidbg 通过文件处理器处理文件。默认的文件处理器是 AndroidResolver,我们可以在它的 resolve 方法里处理样本发起的文件访问。

//source:src/main/java/com/github/unidbg/linux/android/AndroidResolver.java
    @Override
public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String path, int oflags) {
    // 相关逻辑,此处省略
    return null;
}

在这里写代码可不是一个好方案,出于两方面原因:

  • 可移植性差:AndroidResolver 属于 Unidbg 源码的一部分,修改 AndroidResolver 使其失去了灵活性。如果想迁移项目,需要对整个Unidbg 环境打包。
  • 环境混乱:我们一般会在 Unidbg 环境里处理多个样本,它们都有文件访问的需求,对于同一个文件,不同样本的预期返回不一定相同,比如 /proc/self/cmdline 查看包名,在 AndroidResolver 中难以做区分。

Unidbg 并不会让我们陷入泥潭,在文件处理上它采用了责任链模式的设计。用户可以自定义一个或多个文件处理器,当文件访问发生时,多个文件处理器都有机会处理该文件访问,直到其中某个对它处理成功,或最终失败返回为止。责任链模式将多个文件处理器串成链,然后让文件访问在链上传递。

img

先依次进入用户自定义的 IOResolver A 和 B ,然后就是 AndroidResolver,最后是虚拟文件系统。如果文件访问在较早的某处得到了处理,那就会直接返回。图例所对应的代码如下。

protected final FileResult<T> resolve(Emulator<T> emulator, String pathname, int oflags) {
    FileResult<T> failResult = null;
    // 依次进入每个文件解析器,以期得到处理
    for (IOResolver<T> resolver : resolvers) {
        FileResult<T> result = resolver.resolve(emulator, pathname, oflags);
        if (result != null && result.isSuccess()) {
            emulator.getMemory().setErrno(0);
            return result;
        } else if (result != null) {
            if (failResult == null || !failResult.isFallback()) {
                failResult = result;
            }
        }
    }
    if (failResult != null && !failResult.isFallback()) {
        return failResult;
    }
	// 虚拟文件系统
    FileResult<T> result = emulator.getFileSystem().open(pathname, oflags);
    if (result != null && result.isSuccess()) {
        emulator.getMemory().setErrno(0);
        return result;
    }

   // 一些杂项路径判断和处理,此处省略
    
    return failResult;
}

Unidbg 在这里的设计也并非完美,一些特殊以及杂项文件的处理没有设计对应的文件处理器,而是较为零散的放在各处。事实上,将它们重构为一个 procFileIOResolver 会更合理,感兴趣的读者可以去做尝试。

if (("/proc/" + emulator.getPid() + "/fd").equals(pathname) || "/proc/self/fd".equals(pathname)) {
    return createFdDir(oflags, pathname);
}
if (("/proc/" + emulator.getPid() + "/task/").equals(pathname) || "/proc/self/task/".equals(pathname)) {
    return createTaskDir(emulator, oflags, pathname);
}

上面我们从一个比较高的视角观察了 Unidbg 中文件访问的处理流程,接下里进入细节,可不能眼高手低。首先是定义和添加文件处理器的具体写法,addIOResolver 用于添加一个文件处理器对象,将如下代码放在早于 loadLibrary 的时机即可生效。

emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        System.out.println("file open:"+pathname);
        return null;
    }
});

有时候我们会让目标类实现 IOResolver 接口,那么就是 addIOResolver(this)。

package com.test3;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Emulator;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.file.FileResult;
import com.github.unidbg.file.IOResolver;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.AbstractJni;
import com.github.unidbg.linux.android.dvm.DalvikModule;
import com.github.unidbg.linux.android.dvm.DvmClass;
import com.github.unidbg.linux.android.dvm.VM;
import com.github.unidbg.memory.Memory;

import java.io.File;

public class TEST3 extends AbstractJni implements IOResolver<AndroidFileIO> {
    private final AndroidEmulator emulator;
    private final DvmClass LibBili;
    private final VM vm;

    public TEST3() {
        emulator = AndroidEmulatorBuilder
                .for32Bit()
                .addBackendFactory(new Unicorn2Factory(true))
                .setProcessName("tv.danmaku.bili")
                .build();
        Memory memory = emulator.getMemory();
        memory.setLibraryResolver(new AndroidResolver(23));
        vm = emulator.createDalvikVM(new File("D:\\Compilation_Enviroment\\unidbg-master\\unidbg-android\\src\\test\\java\\com\\test3\\bilibili.apk"));
        vm.setJni(this);
        vm.setVerbose(true);

        // add
        emulator.getBackend().registerEmuCountHook(10000); // 设置执行多少条指令切换一次线程
        emulator.getSyscallHandler().setVerbose(true);
        emulator.getSyscallHandler().setEnableThreadDispatcher(true);

        emulator.getSyscallHandler().addIOResolver(this);
        //加载Dalvik的模块
        DalvikModule dm = vm.loadLibrary("bili", true);
        //加载目标类
        LibBili = vm.resolveClass("com.bilibili.nativelibrary.LibBili");
        dm.callJNI_OnLoad(emulator);
    }

    public static void main(String[] args) {
        TEST3 test3 = new TEST3();
    }

    @Override
    public FileResult resolve(Emulator emulator, String pathname, int oflags) {
        System.out.println("lilac open: " + pathname);
        return null;
    }


}

在处理很复杂的样本时,我们希望逻辑分离,那么可以把 IOSresolver 从主类中摘出去。

package com.test3;

import com.github.unidbg.Emulator;
import com.github.unidbg.file.FileResult;
import com.github.unidbg.file.IOResolver;
import com.github.unidbg.file.linux.AndroidFileIO;

public class BiliIOResolver implements IOResolver<AndroidFileIO> {
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        System.out.println("file open:"+pathname);        
        return null;
    }
}

主类代码

package com.test3;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Emulator;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.file.FileResult;
import com.github.unidbg.file.IOResolver;
import com.github.unidbg.file.linux.AndroidFileIO;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.AbstractJni;
import com.github.unidbg.linux.android.dvm.DalvikModule;
import com.github.unidbg.linux.android.dvm.DvmClass;
import com.github.unidbg.linux.android.dvm.VM;
import com.github.unidbg.memory.Memory;

import java.io.File;

public class TEST3 extends AbstractJni{
    private final AndroidEmulator emulator;
    private final DvmClass LibBili;
    private final VM vm;

    public Bili() {
        emulator = AndroidEmulatorBuilder.for32Bit()
                .addBackendFactory(new Unicorn2Factory(false))
                .build();
        emulator.getBackend().registerEmuCountHook(10000); // 设置执行多少条指令切换一次线程
        emulator.getSyscallHandler().setVerbose(true);
        emulator.getSyscallHandler().setEnableThreadDispatcher(true);
        Memory memory = emulator.getMemory();
        memory.setLibraryResolver(new AndroidResolver(23));
        vm = emulator.createDalvikVM(new File("unidbg-android/src/test/resources/bili/bili.apk"));
        vm.setVerbose(true);
        vm.setJni(this);

        emulator.getSyscallHandler().addIOResolver(new BiliIOResolver());

        DalvikModule dm = vm.loadLibrary("bili", true);
        LibBili = vm.resolveClass("com.bilibili.nativelibrary.LibBili");
        dm.callJNI_OnLoad(emulator);
    }

    public void calla(){
        String appkey = LibBili.callStaticJniMethodObject(emulator, "a(Ljava/lang/String;)Ljava/lang/String;", "android").getValue().toString();
        System.out.println("result:"+appkey);
    }


    public static void main(String[] args) {
        TEST3 test3 = new TEST3();
        test3.calla();
    }
}

添加多个自定义文件处理器的场景里(比如处理某公司旗下的多个样本,它们在多数文件访问上类似,但少部分需要各自处理),需要注意后添加的优先级更高。

/**
 * 后面添加的优先级高
 */
void addIOResolver(IOResolver<T> resolver);

比如下面的逻辑里,文件访问会首先经过B,其次才是A

emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        System.out.println("Resolver A");
        return null;
    }
});

emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        System.out.println("Resolver B");
        return null;
    }
});

自定义以及添加文件处理器需要关注的内容就这么多,接下来是具体的文件处理。

三、文件访问基本处理

这是本篇的主要内容,也是补环境的实际指导。如果想学好它,需要意识到它的知识由三部分构成。

image.png

举个例子,我们设置了自定义文件处理器,打印样本所访问的文件,假如日志里出现了下面这么一行

file open:/proc/sys/kernel/random/boot_id

Linux/Android 文件系统的知识:boot_id 文件在开机时生成,在设备关机前不会改变内容。我们将这个文件从真机上 push 出来

对业务的经验:boot_id 常用于构建设备指纹或单纯的信息采集,需要补它。

Unidbg 对应的实现和映射:像下面这样补它

emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        //参数二: 文件路径
        //参数三: 操作文件标志
        switch (pathname){
            case "/proc/sys/kernel/random/boot_id":{
                return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/dewu/cpu/boot_id"), pathname));
            }
        }
        return null;
    }
});

我们主要讲解第一部分,也会适时补充第二、三部分的内容。先看代码,我们用switch case 对 pathname 做判断,也对 boot_id 做处理,return 语句看起来有些复杂,我们拆解一下。

最外层是 FileResult.success,我们可以返回任意 AndroidFileIO 类型的文件,这么说有点怪,这是因为 Unidbg 是一个 Android/IOS 双端的 Native 模拟器,除了 AndroidFileIO 还有对应于 IOS 的DarwinFileIO,所以由此限制。

对文件访问有 success、failed、fallback 三种处理

  • success 表示文件顺利访问,参数是 NewFileIO。
  • failed 表示文件访问失败,参数是 error 错误码。
  • fallback 表示回退、降级,只有在其他处理器无法处理对该文件的访问时,才由 fallback 指定的文件 IO 来处理。

第一种使用最多,只有文件访问的意图正常——信息收集,我们都很乐意正常予以返回。因为我们希望程序收集到足够多且内容合理的设备信息,以顺利执行完函数以及规避简单风控。

SimpleFileIO 时最常用的 IO 类型,它代表普通文件,上面补 boot_id 就用的它。resolve 所提供的 oflags 和 path 原封不动的传给它,file是从真机 pull 出来的 boot_id 文件。

public SimpleFileIO(int oflags, File file, String path) {
    super(oflags);
    this.file = file;
    this.path = path;

    if (file.isDirectory()) {
        throw new IllegalArgumentException("file is directory: " + file);
    }
    if (!file.exists()) {
        throw new IllegalArgumentException("file not exists: " + file);
    }
}

读者可能会像一个问题,boot_id 在一定程度上用于标识设备,那么在生产环境中,我们需要对它做随机化以躲避风控。使用 SimpleFileIO 就不太好处理这个问题,这时候就可以用 ByteArrayFileIO。

emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        System.out.println("Resolver A");
        switch (pathname){
            case "/proc/sys/kernel/random/boot_id":{
                return FileResult.<AndroidFileIO>success(new ByteArrayFileIO(oflags,  pathname, UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)));
            }
        }
        return null;
    }
});

ByteArrayFileIO 在 oflags、pathname 上和 SimpleFileIO 无差别,但它接收一个字节数组而非 File 文件,对于灵活补文件内容有很大的帮助。读者也需要知道它的副作用——没办法对文件做写入操作,ByteArrayFileIO 像浮萍漂泊,无根无依。

@Override
public int write(byte[] data) {
    throw new UnsupportedOperationException();
}

如果样本访问的是一个目录呢?这时候就要用 DirectoryFileIO,File 传入文件夹即可。

case "/data/data/com.sankuai.meituan":{
    return FileResult.<AndroidFileIO>success(new DirectoryFileIO(oflags, pathname, new File("unidbg-android/src/test/resources/meituan/data")));
}

但对于文件夹访问而言,使用虚拟文件系统是更好的选择。

使用虚拟文件系统,我们对文件的掌控程度会下降,在补常规文件时不算好办法,但补文件夹算是一个特殊情况。举个例子,如果样本访问本机的 /sysytem/fonts 目录,对所有字体文件做 md5 然后上传。如果用 DirectoryFileIO 补,那么在补了 fonts 文件夹后,还需要用 SimpleFileIO 处理每个字体文件的访问,工作量很大,而使用虚拟文件系统,只要把文件夹对对应位置放好,就可以顺利处理整个逻辑。

需要注意,补文件访问时不要犯低级错误,比如样本访问 proc/version,尽管和 /proc/version 是一回事,但你可不要多了一个斜杠,pathname 字符串匹配找不到了。

80%的情况里,你就会遇到这么点文件访问的需求——从真机中把对应的文件拖出来,用SimpleFileIO、ByteArrayFileIO、DirectoryFileIO 以及虚拟文件系统去处理它。

比如补 apk,在任何情况下,都不应该对访问 apk 忽略不管,因为它往往用于资源读取或签名校验,不补风险很大。

case "/data/app/com.jingdong.app.mall-jJGgNO9r6uKMLj1ytVwSRw==/base.apk":{
    return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/jd/jd.apk"), pathname));
}

补 CPU 信息

// https://bbs.pediy.com/thread-229579-1.htm
case "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq":{
    return FileResult.<AndroidFileIO>success(new ByteArrayFileIO(oflags, pathname, "1766400".getBytes()));
}

补电池信息

case "/sys/class/power_supply/battery/temp":{
    return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/meituan/files/battery/temp"), pathname));
}

case "/sys/class/power_supply/battery/voltage_now":{
    return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/meituan/files/battery/voltage_now"), pathname));
}

接下来说说 failed,它表示文件访问失败,使用场景其实也不多。因为只要我们什么都不补,AndroidResolver 里没有处理这个文件,虚拟文件系统中也不包含它,那就自动访问失败 + 设置错误码。

但有个情况比较例外——如果文件访问 /data,判断是都有写权限,进而确认设备环境是否 Root 的话,我们需要手动处理。

case "/data":{
    return FileResult.failed(UnixEmulator.EACCES);
}

因为虚拟文件系统会自动生成 data 目录,进而访问成功。错误码列表如下,也可以直接使用看 Linux 的错误码文档进行设置,Unidbg 本就是它的简单映射。

package com.github.unidbg.unix;

public interface UnixEmulator {

    int EPERM = 1; /* Operation not permitted */
    int ENOENT = 2; /* No such file or directory */
    int ESRCH = 3; /* No such process */
    int EINTR = 4; /* Interrupted system call */
    int EBADF = 9; /* Bad file descriptor */
    int EAGAIN = 11; /* Resource temporarily unavailable */
    int ENOMEM = 12; /* Cannot allocate memory */
    int EACCES = 13; /* Permission denied */
    int EFAULT = 14; /* Bad address */
    int EEXIST = 17; /* File exists */
    int ENOTDIR = 20; /* Not a directory */
    int EINVAL = 22; /* Invalid argument */
    int ENOTTY = 25; /* Inappropriate ioctl for device */
    int ENOSYS = 38; /* Function not implemented */
    int ENOATTR = 93; /* Attribute not found */
    int EOPNOTSUPP = 95; /* Operation not supported on transport endpoint */
    int EAFNOSUPPORT = 97; /* Address family not supported by protocol family */
    int EADDRINUSE = 98; /* Address already in use */
    int ECONNREFUSED = 111; /* Connection refused */

}

四、环境检测

当样本做环境检测相关的文件访问时,主要检测这些文件是否存在,以及是否有权限

  • Root检测(检测su、magisk、riru、检测市面上的 Root 工具)
  • 模拟器检测(检测Qemu、检测各家模拟器,比如夜神、雷电、MuMu 等模拟器的文件特征、驱动特征等)
  • 危险应用检测(各类多开助手、按键精灵、接码平台)
  • 云手机检测(以各种云手机产品为主)
  • Hook框架(以Xposed、Substrate、Frida 为主)
  • 脱壳机(以 Fart、DexHunter、Youpk三者为主)

我们选择什么都不补就行,因为不管时自定义还是默认的文件处理器,以及虚拟文件系统里,都不会有这样的内容,这正和我们的意。就像下面这样,什么都不补就行了。

img

读者需要自行分辨某个文件访问是否属于此类,如果琢磨不定建议 Google 搜索这个文件的主要用途。

五、总结

本篇介绍了 Unidbg 中文件处理的基本逻辑和基本使用,80%的场景中它们已经够用。但如果你想探索剩下的20%,以及更多的原理层的内容,就需要看接下来的数篇。

六、参考

[Unidbg 中处理文件访问(一)](https://www.yuque.com/lilac-2hqvv/lfssh8/euffrv?# 《Unidbg 中处理文件访问(一)》)


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1621925986@qq.com

💰

×

Help us with donation